c326e419e35cfd9cfa338f07bac4d758348f14f5
[BK-2020-03.git] / user / bk-copy-rand-music.sh
1 #!/usr/bin/env bash
2 # Desc: Copies random music
3 # Usage: bk-copy-rand-music.sh [dir SOURCE] [dir DEST] [int DURATION]
4
5 declare -Ag appRollCall # Associative array for storing app status
6 declare -Ag fileRollCall # Associative array for storing file status
7 declare -Ag dirRollCall # Associative array for storing dir status
8 declare -a music_codecs # Array for storing valid codec names (e.g. "aac" "mp3")
9
10 # Adjustable parameters
11 music_codecs=("vorbis" "aac" "mp3" "flac" "opus"); # whitelist of valid codec_names ffprobe might return
12 max_loops=1000000; # max number of files to test whether are audio or not
13
14 yell() { echo "$0: $*" >&2; } # print script path and all args to stderr
15 die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status
16 try() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails
17 checkapp() {
18 # Desc: If arg is a command, save result in assoc array 'appRollCall'
19 # Usage: checkapp arg1 arg2 arg3 ...
20 # Version: 0.1.1
21 # Input: global assoc. array 'appRollCall'
22 # Output: adds/updates key(value) to global assoc array 'appRollCall'
23 # Depends: bash 5.0.3
24 local returnState
25
26 #===Process Args===
27 for arg in "$@"; do
28 if command -v "$arg" 1>/dev/null 2>&1; then # Check if arg is a valid command
29 appRollCall[$arg]="true";
30 if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
31 else
32 appRollCall[$arg]="false"; returnState="false";
33 fi;
34 done;
35
36 #===Determine function return code===
37 if [ "$returnState" = "true" ]; then
38 return 0;
39 else
40 return 1;
41 fi;
42 } # Check that app exists
43 checkfile() {
44 # Desc: If arg is a file path, save result in assoc array 'fileRollCall'
45 # Usage: checkfile arg1 arg2 arg3 ...
46 # Version: 0.1.1
47 # Input: global assoc. array 'fileRollCall'
48 # Output: adds/updates key(value) to global assoc array 'fileRollCall';
49 # Output: returns 0 if app found, 1 otherwise
50 # Depends: bash 5.0.3
51 local returnState
52
53 #===Process Args===
54 for arg in "$@"; do
55 if [ -f "$arg" ]; then
56 fileRollCall["$arg"]="true";
57 if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
58 else
59 fileRollCall["$arg"]="false"; returnState="false";
60 fi;
61 done;
62
63 #===Determine function return code===
64 if [ "$returnState" = "true" ]; then
65 return 0;
66 else
67 return 1;
68 fi;
69 } # Check that file exists
70 checkdir() {
71 # Desc: If arg is a dir path, save result in assoc array 'dirRollCall'
72 # Usage: checkdir arg1 arg2 arg3 ...
73 # Version 0.1.2
74 # Input: global assoc. array 'dirRollCall'
75 # Output: adds/updates key(value) to global assoc array 'dirRollCall';
76 # Output: returns 0 if all args are dirs; 1 otherwise
77 # Depends: Bash 5.0.3
78 local returnState
79
80 #===Process Args===
81 for arg in "$@"; do
82 if [ -z "$arg" ]; then
83 dirRollCall["(Unspecified Dirname(s))"]="false"; returnState="false";
84 elif [ -d "$arg" ]; then
85 dirRollCall["$arg"]="true";
86 if ! [ "$returnState" = "false" ]; then returnState="true"; fi
87 else
88 dirRollCall["$arg"]="false"; returnState="false";
89 fi
90 done
91
92 #===Determine function return code===
93 if [ "$returnState" = "true" ]; then
94 return 0;
95 else
96 return 1;
97 fi
98 } # Check that dir exists
99 displayMissing() {
100 # Desc: Displays missing apps, files, and dirs
101 # Usage: displayMissing
102 # Version 1.0.0
103 # Input: associative arrays: appRollCall, fileRollCall, dirRollCall
104 # Output: stderr: messages indicating missing apps, file, or dirs
105 # Output: returns exit code 0 if nothing missing; 1 otherwise
106 # Depends: bash 5, checkAppFileDir()
107 local missingApps value appMissing missingFiles fileMissing
108 local missingDirs dirMissing
109
110 #==BEGIN Display errors==
111 #===BEGIN Display Missing Apps===
112 missingApps="Missing apps :";
113 #for key in "${!appRollCall[@]}"; do echo "DEBUG:$key => ${appRollCall[$key]}"; done
114 for key in "${!appRollCall[@]}"; do
115 value="${appRollCall[$key]}";
116 if [ "$value" = "false" ]; then
117 #echo "DEBUG:Missing apps: $key => $value";
118 missingApps="$missingApps""$key ";
119 appMissing="true";
120 fi;
121 done;
122 if [ "$appMissing" = "true" ]; then # Only indicate if an app is missing.
123 echo "$missingApps" 1>&2;
124 fi;
125 unset value;
126 #===END Display Missing Apps===
127
128 #===BEGIN Display Missing Files===
129 missingFiles="Missing files:";
130 #for key in "${!fileRollCall[@]}"; do echo "DEBUG:$key => ${fileRollCall[$key]}"; done
131 for key in "${!fileRollCall[@]}"; do
132 value="${fileRollCall[$key]}";
133 if [ "$value" = "false" ]; then
134 #echo "DEBUG:Missing files: $key => $value";
135 missingFiles="$missingFiles""$key ";
136 fileMissing="true";
137 fi;
138 done;
139 if [ "$fileMissing" = "true" ]; then # Only indicate if an app is missing.
140 echo "$missingFiles" 1>&2;
141 fi;
142 unset value;
143 #===END Display Missing Files===
144
145 #===BEGIN Display Missing Directories===
146 missingDirs="Missing dirs:";
147 #for key in "${!dirRollCall[@]}"; do echo "DEBUG:$key => ${dirRollCall[$key]}"; done
148 for key in "${!dirRollCall[@]}"; do
149 value="${dirRollCall[$key]}";
150 if [ "$value" = "false" ]; then
151 #echo "DEBUG:Missing dirs: $key => $value";
152 missingDirs="$missingDirs""$key ";
153 dirMissing="true";
154 fi;
155 done;
156 if [ "$dirMissing" = "true" ]; then # Only indicate if an dir is missing.
157 echo "$missingDirs" 1>&2;
158 fi;
159 unset value;
160 #===END Display Missing Directories===
161
162 #==END Display errors==
163 #==BEGIN Determine function return code===
164 if [ "$appMissing" == "true" ] || [ "$fileMissing" == "true" ] || [ "$dirMissing" == "true" ]; then
165 return 1;
166 else
167 return 0;
168 fi
169 #==END Determine function return code===
170 } # Display missing apps, files, dirs
171 showUsage() {
172 # Desc: Display script usage information
173 # Usage: showUsage
174 # Version 0.0.1
175 # Input: none
176 # Output: stdout
177 # Depends: GNU-coreutils 8.30 (cat)
178 cat <<'EOF'
179
180 DESCRIPTION:
181 This script may be used to copy a random selection of files from
182 SOURCE to DEST.
183
184 USAGE:
185 bk-copy-rand-music [dir SOURCE] [dir DEST] [int DURATION]
186
187 EXAMPLE:
188 bk-copy-rand-music ~/Music /tmp/music-sample 3600
189
190 DEPENDENCIES:
191 ffprobe
192 GNU Coreutils 8.30
193 EOF
194 } # Display information on how to use this script.
195 check_parsable_audio_ffprobe() {
196 # Desc: Checks if ffprobe returns valid audio codec name for file
197 # Usage: check_parsable_audio_ffprobe [path FILE]
198 # Version: 0.0.1
199 # Input: arg1: file path
200 # Output: exit code 0 if returns valid codec name; 1 otherwise
201 # Depends: ffprobe, die()
202 local file_in ffprobe_out
203
204 if [[ $# -ne 1 ]]; then die "ERROR:Invalid number of args:$#"; fi;
205
206 file_in="$1";
207
208 # Check if ffprobe detects an audio stream
209 if ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in" 1>/dev/null 2>&1; then
210 return_state="true";
211 else
212 return_state="false";
213 fi;
214
215 # Fail if ffprobe returns no result
216 ffprobe_out="$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in")";
217 if [[ -z $ffprobe_out ]]; then
218 return_state="false";
219 fi;
220
221 # Report exit code
222 if [[ $return_state = "true" ]]; then
223 return 0;
224 else
225 return 1;
226 fi;
227 } # Checks if file has valid codec name using ffprobe
228 get_audio_format() {
229 # Desc: Gets audio format of file as string
230 # Usage: get_audio_format arg1
231 # Depends: ffprobe
232 # Version: 0.0.1
233 # Input: arg1: input file path
234 # Output: stdout (if valid audio format)
235 # exit code 0 if audio file; 1 otherwise
236 # Example: get_audio_format myvideo.mp4
237 # Note: Would return "opus" if full ffprobe report had 'Audio: opus, 48000 Hz, stereo, fltp'
238 # Note: Not tested with videos containing multiple video streams
239 # Ref/Attrib: [1] https://stackoverflow.com/questions/5618363/is-there-a-way-to-use-ffmpeg-to-determine-the-encoding-of-a-file-before-transcod
240 # [2] https://stackoverflow.com/questions/44123532/how-to-find-out-the-file-extension-for-extracting-audio-tracks-with-ffmpeg-and-p#comment88464070_50723126
241 local audio_format file_in;
242 local return_state;
243 file_in="$1";
244
245 # Return error exit code if not audio file
246 ## Return error if ffprobe itself exited on error
247 if ! ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in" 1>/dev/null 2>&1; then
248 return_state="false";
249 fi;
250
251 # Get audio format
252 audio_format="$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in")"; # see [1]
253
254 ## Return error if audio format is incorrectly formatted (e.g. reject if contains spaces)
255 pattern="^[[:alnum:]]+$"; # alphanumeric string with no spaces
256 if [[ $audio_format =~ $pattern ]]; then
257 return_state="true";
258 # Report audio format
259 echo "$audio_format";
260 else
261 return_state="false";
262 fi;
263
264 # Report exit code
265 if [[ $return_state = "true" ]]; then
266 return 0;
267 else
268 return 1;
269 fi;
270 } # Get audio format as stdout
271 get_media_length() {
272 # Use ffprobe to get media container length in seconds (float)
273 # Usage: get_media_length arg1
274 # Input: arg1: path to file
275 # Output: stdout: seconds (float)
276 # Depends: ffprobe 4.1.8
277 # Ref/Attrib: [1] How to get video duration in seconds? https://superuser.com/a/945604
278 local file_in
279 file_in="$1";
280 if [[ ! -f $file_in ]]; then
281 die "ERROR:Not a file:$file_in";
282 fi;
283 ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_in";
284 } # Get media container length in seconds via stdout
285 checkInt() {
286 # Desc: Checks if arg is integer
287 # Usage: checkInt arg
288 # Input: arg: integer
289 # Output: - return code 0 (if arg is integer)
290 # - return code 1 (if arg is not integer)
291 # Example: if ! checkInt $arg; then echo "not int"; fi;
292 # Version: 0.0.1
293 local returnState
294
295 #===Process Arg===
296 if [[ $# -ne 1 ]]; then
297 die "ERROR:Invalid number of arguments:$#";
298 fi;
299
300 RETEST1='^[0-9]+$'; # Regular Expression to test
301 if [[ ! $1 =~ $RETEST1 ]] ; then
302 returnState="false";
303 else
304 returnState="true";
305 fi;
306
307 #===Determine function return code===
308 if [ "$returnState" = "true" ]; then
309 return 0;
310 else
311 return 1;
312 fi;
313 } # Checks if arg is integer
314 checkIsInArray() {
315 # Desc: Checks if input arg is element in array
316 # Usage: checkIsInArray arg1 arg2
317 # Version: 0.0.1
318 # Input: arg1: test string
319 # arg2: array
320 # Output: exit code 0 if test string is in array; 1 otherwise
321 # Example: checkIsInArray "foo" "${myArray[@]}"
322 # Ref/Attrib: [1] How do I check if variable is an array? https://stackoverflow.com/a/27254437
323 # [2] How to pass an array as function argument? https://askubuntu.com/a/674347
324 local return_state input arg1 string_test
325 declare -a arg2 array_test
326 input=("$@") # See [2]
327 arg1="${input[0]}";
328 arg2=("${input[@]:1}");
329 #yell "DEBUG:input:${input[@]}";
330 #yell "DEBUG:arg1:${arg1[@]}";
331 #yell "DEBUG:arg2:${arg2[@]}";
332
333 string_test="$arg1";
334 array_test=("${arg2[@]}");
335
336 #yell "DEBUG:string_test:$string_test";
337 #yell "DEBUG:$(declare -p array_test)";
338 for element in "${array_test[@]}"; do
339 #yell "DEBUG:element:$element";
340 if [[ "$element" =~ ^"$string_test" ]]; then
341 return_state="true";
342 continue;
343 fi;
344 done;
345
346 # Report exit code
347 if [[ $return_state == "true" ]]; then
348 return 0;
349 else
350 return 1;
351 fi;
352 } # Check if string is element in array
353 main() {
354 # Desc: Main program
355 # Input: arg1: path to source tree
356 # arg2: path to destination tree
357 # arg3: cumulative duration (seconds) of audio files in destination tree
358 # assoc arrays: appRollCall, fileRollCall, dirRollCall
359 # Output: [none]
360 # Depends: yell(), checkdir() 0.1.2, displayMissing() 1.0.0, GNU Coreutils 8.30 (shuf)
361 local arg1 arg2 arg3 dur_dest dir_source dir_dest list_all
362 declare -a list_files # array for files to be considered
363 declare -A list_copy # assoc array for files to be copied (key=path; value=duration)
364
365 # Parse args
366 arg1="$1";
367 arg2="$2";
368 arg3="$3";
369 if [[ $# -ne 3 ]]; then showUsage; die "ERROR:Invalid number of args."; fi;
370
371 ## Check duration
372 if checkInt "$arg3"; then
373 dur_dest="$arg3";
374 else
375 yell "ERROR:Duration (seconds) not an int:$arg3"
376 fi;
377
378 ## Check directories
379 if checkdir "$arg1" "$arg2"; then
380 dir_source="$arg1";
381 dir_dest="$arg2";
382 else
383 yell "ERROR:Directory error";
384 fi;
385
386 ## Check apps
387 checkapp ffprobe;
388
389 if ! displayMissing; then
390 showUsage;
391 die "ERROR:Check missing resources.";
392 fi;
393
394 yell "STATUS:Working...";
395
396 # Generate file path list
397 list_all="$(find -L "$dir_source")";
398 #yell "DEBUG:list_files_rel:$list_files_rel";
399
400 # Prune list_all of non-files and save as array list_files
401 while read -r line; do
402 #yell "DEBUG:line:$line";
403 if ! [[ -f $line ]]; then
404 #yell "DEBUG:Not a file:$line";
405 #yell ""; # debug
406 continue;
407 fi;
408 list_files+=("$line");
409 done < <(echo "$list_all");
410
411 # Randomly test and add elements of list_files array to list_copy
412 dur=0; # Initialize duration
413 n=0; # Initialize loop counter
414 ## Get element count of list_files array
415 list_files_count="${#list_files[@]}";
416 while [[ $dur -le $dur_dest ]]; do
417 #yell "DEBUG:list_copy building loop:$n";
418 ### Select random element of list_files array
419 list_files_index="$(shuf -i 1-"$list_files_count" -n1)";
420 list_files_index="$((list_files_index - 1))"; # bash arrays are zero-indexed
421 path_candfile="${list_files[$list_files_index]}"; # path of candidate file
422
423 ### Check if has valid codec
424 if ! check_parsable_audio_ffprobe "$path_candfile"; then continue; fi; # reject
425
426 ### Check if desired codec
427 file_format="$(get_audio_format "$path_candfile")";
428 if ! checkIsInArray "$file_format" "${music_codecs[@]}"; then continue; fi; # reject
429
430 ### Check and save duration
431 dur_cand="$(get_media_length "$path_candfile")";
432 dur_cand="${dur_cand%%.*}"; # convert float to int
433 if ! checkInt "$dur_cand"; then continue; fi; # reject
434
435 ### Add/update candfile to list_copy assoc. array (key=path; value=duration)
436 #yell "DEBUG:Adding $path_candfile";
437 list_copy["$path_candfile"]="$dur_cand";
438
439 ### Update total duration $dur by summing all list_copy assoc. array values
440 dur=0;
441 for value in "${list_copy[@]}"; do
442 dur="$((dur + value))";
443 done;
444 #yell "DEBUG:dur:$dur";
445
446 ### Sanity check
447 ((n++));
448 if [[ $n -gt $max_loops ]]; then die "ERROR:Too many loops:$n"; fi;
449 done;
450
451 # Copy files in list_copy to dir_dest;
452 for key in "${!list_copy[@]}"; do
453 value="${list_copy[$key]}";
454 ## Get basename of path
455 file_basename="$(basename "$key")";
456
457 ## Get 16-character b2sum fingerprint (for different files that share basename)
458 fingerprint="$(b2sum -l64 "$key" | cut -d' ' -f1)";
459
460 ## Form output path
461 path_output="$dir_dest"/"$fingerprint".."$file_basename";
462
463 ## Copy
464 try cp "$key" "$path_output" && yell "NOTICE:Copied ($value seconds): $key ";
465 #yell "DEBUG:Copied $file_basename to $dur_dest.";
466
467 unset file_basename path_output
468 done;
469
470 # Report total duration
471 yell "NOTICE:Total duration (seconds):$dur";
472
473 } # Main program
474
475 main "$@";
476
477 # Author: Steven Baltakatei Sandoval
478 # License: GPLv3+