#!/usr/bin/env bash
-# Desc
+# Description:
# - Finds audio and video files with specified extensions up to a max depth of 8.
# - Uses ffprobe to measure their durations.
# - Creates a playlist starting at a random location within the total runtime.
# - Stores durations and file paths in a dotfile for faster subsequent runs.
# - Prompts the user whether to use the cached data or regenerate it.
+# - Uses an EDL file to specify the starting offset for the first file.
# Usage: rand_media_pl.sh [DIR]
-# Version: 0.0.1
-# Dependencies: Bash 4, ffprobe, mpv
+# Version: 0.1.0
+# Dependencies: Bash 4, ffprobe, mpv, bc, shuf, GNU Parallel
-yell() { echo "$0: $*" >&2; } # print script path and all args to stderr
-die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status
-must() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails
+yell() { echo "$0: $*" >&2; } # Print script path and all args to stderr
+die() { yell "$*"; exit 111; } # Same as yell() but exits with code 111
+must() { "$@" || die "cannot $*"; } # Runs args as command, reports args if command fails
# Configurable variables
-SEARCH_DIR="$1";
+SEARCH_DIR="${1:-.}" # Default to current directory if no argument is provided
MAX_DEPTH=8;
EXTENSIONS=("*.flac" "*.mp3" "*.opus" "*.m4a" "*.m4b" "*.mp4" "*.mkv" "*.webm");
CACHE_FILE=".playlist_cache";
# Function to prompt the user
prompt_yes_no() {
- yell "STATUS:User input required.";
- local prompt_message="$1"
- local user_input
+ yell "STATUS: User input required.";
+ local prompt_message="$1";
+ local user_input;
while true; do
- read -rp "$prompt_message [y/n]: " user_input
+ read -rp "$prompt_message [y/n]: " user_input;
case "$user_input" in
[Yy]*) return 0 ;;
[Nn]*) return 1 ;;
# Function to find files with specified extensions
find_media_files() {
- yell "STATUS:Finding media files.";
+ yell "STATUS: Finding media files.";
local find_cmd=("find" "-L" "$SEARCH_DIR" "-maxdepth" "$MAX_DEPTH" "-type" "f" "(");
- #declare -p find_cmd 1>&2; # debug
for ext in "${EXTENSIONS[@]}"; do
find_cmd+=("-iname" "$ext" "-o");
- #declare -p find_cmd 1>&2; # debug
done;
- # Remove the last "-o" and add ")"
+ # Remove the last "-o" and add ")";
unset 'find_cmd[-1]';
find_cmd+=(")");
"${find_cmd[@]}";
generate_playlist_cache() {
yell "Generating playlist cache. This may take a while..."
# Initialize or empty the cache file
- if [[ ! -f "$CACHE_FILE" ]]; then
- touch "$CACHE_FILE";
- else
- rm "$CACHE_FILE";
- fi;
-
- total_duration=0
+ : > "$CACHE_FILE"; # Truncate or create the cache file
+
+ total_duration=0;
while IFS= read -r file; do
# Get the duration using ffprobe
- duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file")
- if [[ -n "$duration" ]]; then
+ duration=$(ffprobe -v error -show_entries format=duration \
+ -of default=noprint_wrappers=1:nokey=1 "$file");
+ declare -p file duration 1>&2; # Debugging statement
+
+ # Validate the duration
+ if [[ -n "$duration" && "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
# Append the file path and duration to the cache file
printf "%s|%s\n" "$file" "$duration" >> "$CACHE_FILE";
total_duration=$(echo "$total_duration + $duration" | bc);
+ else
+ yell "WARNING: Invalid duration '$duration' for file '$file', skipping." 1>&2;
fi;
- done < <(find_media_files | sort);
- total_duration_s="$(echo "scale=1; ${total_duration} / 1" | bc -l)";
- total_duration_h="$(echo "scale=1; ${total_duration} / 3600" | bc -l)";
-
- yell "Total duration of playlist: ${total_duration_s} seconds. (${total_duration_h} hours.)";
-};
+ done < <(find_media_files | parallel readlink -f '{}' | sort -u; );
+
+ total_duration_s="$(printf "%.1f" "$total_duration"; )";
+ total_duration_h="$(echo "scale=1; $total_duration / 3600" | bc -l; )";
+ yell "Total duration of playlist: ${total_duration_s} seconds (${total_duration_h} hours).";
+};
# Function to read the playlist cache
read_playlist_cache() {
- # Input: file_durations assoc. array
- # total_duration array
- yell "STATUS:Reading playlist cache.";
-
+ # Input: file_durations associative array
+ # total_duration scalar
+ yell "STATUS: Reading playlist cache."
+
total_duration=0
while IFS='|' read -r file duration; do
- file_durations["$file"]="$duration"
- total_duration=$(echo "$total_duration + $duration" | bc)
- done < "$CACHE_FILE"
+ declare -p file duration 1>&2 # Debugging statement
+
+ # Validate the duration
+ if [[ -n "$duration" && "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
+ file_durations["$file"]="$duration"
+ total_duration=$(echo "$total_duration + $duration" | bc)
+ else
+ yell "WARNING: Invalid duration '$duration' for file '$file', skipping." 1>&2
+ fi
+ done < "$CACHE_FILE";
+};
+# Function to get byte count of file name
+prepend_path_bc() {
+ # Desc: Prepends a file path with %[path bytecount]%
+ # Usage: prepend_filename_bc [FILE]
+ # Input: arg1 str file path
+ # Output: stdout str %[int path length]%path
+ # Example: 'foo.txt' yields '%7%foo.txt'
+ # Depends: GNU Coreutils 8.32 (for 'wc')
+ # BK-2020-03: yell(), die(), must()
+ # Ref/Attrib: See “Syntax of mpv EDL files” https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst#syntax-of-mpv-edl-files
+ fin="$1";
+
+ if [[ ! -f "$fin" ]]; then
+ yell "WARNING:File does not exist:${fin}";
+ fi;
+
+ bytecount="$(printf "%s" "$fin" | wc -c; )";
+ re='[0-9]';
+ if [[ ! "$bytecount" =~ $re ]]; then
+ die "FATAL:Not an int:${bytecount}; $(declare -p fin bytecount)";
+ fi;
+ printf "%%%s%%%s" "$bytecount" "$fin";
};
# Main script
# Check if any files were found
if [[ ${#file_durations[@]} -eq 0 ]]; then
declare -p file_durations 1>&2;
- die "FATAL:No media files found.";
-fi
+ die "FATAL: No media files found.";
+fi;
-# Get sorted list of files
-mapfile -t sorted_files < <(printf '%s\n' "${!file_durations[@]}" | sort)
+# Ensure total_duration is not empty
+if [[ -z "$total_duration" ]]; then
+ die "FATAL: total_duration is empty.";
+fi;
-# Generate a random starting point within the total duration
-random_point=$(awk -v max="$total_duration" 'BEGIN{srand(); print rand()*max}')
+# Convert total_duration to an integer
+total_duration_int=$(printf "%.0f" "$total_duration");
+
+# Check if total_duration_int is a valid integer
+if ! [[ "$total_duration_int" =~ ^[0-9]+$ ]]; then
+ die "FATAL: total_duration_int is not a valid integer.";
+fi;
+
+# Generate a random integer between 0 and total_duration_int - 1
+random_point=$(shuf -i 0-$((total_duration_int - 1)) -n1)
+yell "DEBUG: total_duration=$total_duration, total_duration_int=$total_duration_int, random_point=$random_point" 1>&2;
+
+# Get sorted list of files
+mapfile -t sorted_files < <(printf '%s\n' "${!file_durations[@]}" | sort; );
# Find the file and offset corresponding to the random starting point
-accumulated_duration=0
-for file in "${sorted_files[@]}"; do
- duration="${file_durations["$file"]}"
- new_accumulated_duration=$(echo "$accumulated_duration + $duration" | bc)
- if (( $(echo "$random_point < $new_accumulated_duration" | bc -l) )); then
- offset=$(echo "$random_point - $accumulated_duration" | bc)
- yell "Starting playback from $offset seconds into file: $file"
-
- # Create a playlist file
- playlist_file=$(mktemp)
- printf '%s\n' "${sorted_files[@]}" > "$playlist_file"
+accumulated_duration=0;
+accumulated_duration_int=0;
+
+for idx in "${!sorted_files[@]}"; do
+ file="${sorted_files[$idx]}";
+ duration="${file_durations["$file"]}";
+ declare -p idx file duration accumulated_duration 1>&2; # Debugging statement
+
+ # Validate the duration
+ if [[ -n "$duration" && "$duration" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
+ new_accumulated_duration=$(echo "$accumulated_duration + $duration" | bc)
+ # Convert accumulated durations to integers for comparison
+ accumulated_duration_int=$(printf "%.0f" "$accumulated_duration")
+ new_accumulated_duration_int=$(printf "%.0f" "$new_accumulated_duration")
+ else
+ yell "WARNING: Invalid duration '$duration' for file '$file', skipping." 1>&2
+ continue
+ fi
+
+ if (( random_point < new_accumulated_duration_int )); then
+ offset=$(echo "$random_point - $accumulated_duration_int" | bc; );
+ yell "Starting playback from $offset seconds into file: $file";
+
+ # Create an EDL file
+ edl_file=$(mktemp);
+ yell "DEBUG:EDL file at:${edl_file}"; # debug
+ echo "# mpv EDL v0" > "$edl_file";
+
+ # Add first file to start playback at random offset position
+ file_bc="$(prepend_path_bc "$file")"; # See https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst#syntax-of-mpv-edl-files
+ printf '%s,%s\n' "$file_bc" "$offset" >> "$edl_file";
+
+ # Append the rest of the files
+ declare -p file offset idx sorted_files edl_file 1>&2; # debug
+ for (( i=idx+1; i<${#sorted_files[@]}; i++ )); do
+ next_file="${sorted_files[$i]}";
+ next_file_bc="$(prepend_path_bc "$next_file")";
+ yell "STATUS:Adding:$next_file";
+ printf '%s\n' "$next_file_bc" >> "$edl_file";
+ done;
# Start playback using mpv
- mpv --playlist="$playlist_file" --start="$offset" \
- --playlist-start=$(($(printf '%s\n' "${sorted_files[@]}" | grep -n "^$file$" | cut -d: -f1)-1))
+ mpv "$edl_file" || exit 1;
- # Securely delete the playlist file
- if [[ -n "$playlist_file" && -f "$playlist_file" ]]; then
- rm "$playlist_file"; exit 0;
+ # Securely delete the EDL file
+ if [[ -n "$edl_file" && -f "$edl_file" ]]; then
+ rm "$edl_file";
+ exit 0;
else
- die "FATAL: playlist_file is either unset or not a valid file, skipping deletion.";
+ die "FATAL: edl_file is either unset or not a valid file, skipping deletion.";
fi;
- fi
+ fi;
accumulated_duration="$new_accumulated_duration";
-done
+ accumulated_duration_int="$new_accumulated_duration_int";
+done;
die "FATAL: Could not find file corresponding to random point.";
-