music-sync.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. #!/bin/bash
  2. #
  3. #/
  4. #/ Usage:
  5. #/ music-sync <options> -s|--source <source> -d|--dest <destination>
  6. #/ music-sync <options> <source> <destination>
  7. #/
  8. #/ Syncronises music from one folder to another.
  9. #/
  10. #/ Options:
  11. #/ -s, --source=<source> The source folder of the music
  12. #/ -d, --dest=<destination> The destination folder of the music
  13. #/ -t, --temp=<folder> The temporary cache for converted files (default: /tmp/converted)
  14. #/ -c, --convert=<bitrate> Convert files to a given bitrate in kbps before syncing (default: 192)
  15. #/ -a, --resize-art=<width> Resize album-art before syncing (default width: 200)
  16. #/ -j, --jobs=<nproc> Number of processes to use in multi-threading (default: nproc - 2)
  17. #/ -v, --verbose=<0-6> Set log level (default: 2)
  18. #/ -h, --help Display this help text
  19. #/ -p, --playlists Enable playlist sync
  20. #/ -m, --mapping=DIR1,DIR2 Add mappings for playlists from DIR1 to DIR2
  21. #/ -w, --windows-format Use crlf and backslashes for playlists
  22. #/
  23. #/ Log levels:
  24. #/ 0 | Verbose
  25. #/ 1 | Debug
  26. #/ 2 | Info
  27. #/ 3 | Warning
  28. #/ 4 | Error
  29. #/ 5 | Fatal
  30. #/ 6 | Silence
  31. #/
  32. #/ Exit Codes:
  33. #/ 1 Dependencies not met
  34. #/ 2 Invalid Argument
  35. #/ 3 Source Unreachable
  36. #/ 4 Destination Unreachable
  37. #/ 5 Command failed
  38. #/
  39. begin=$(date +"%s")
  40. # saner programming env: these switches turn some bugs into errors
  41. set -o errexit -o pipefail -o noclobber -o nounset
  42. source="-"
  43. dest="-"
  44. convert=false
  45. verbose=2
  46. bitrate=192
  47. help=false
  48. temp="/tmp/converted"
  49. convertart=false
  50. coverartsize=200
  51. jobcount=$(expr $(nproc) - 2)
  52. multithread=false
  53. script_name=$(basename "${0}")
  54. script_dir=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
  55. mappingdest=''
  56. mappingsrc=''
  57. windowspaths=false
  58. playlists=false
  59. CheckDeps() {
  60. if [[ $1 == 2 ]]; then
  61. # Check getopt
  62. ! getopt --test > /dev/null
  63. if [[ ${PIPESTATUS[0]} -ne 4 ]]; then
  64. VerboseOutput 5 "\`getopt --test\` failed"
  65. VerboseOutput 5 "Sorry, It seems that your shell is not supported"
  66. VerboseOutput 5 "If you're using MacOS or another unix-like system, please install GNU getopt"
  67. ExecTime
  68. exit 1
  69. fi
  70. VerboseOutput 0 "\`getopt --test\` succeeded"
  71. fi
  72. if [[ $1 == 1 ]]; then
  73. # Check ffmpeg
  74. if [[ $convert == true ]]; then
  75. if [[ ! $(ffmpeg -h 2>/dev/null) ]]; then
  76. VerboseOutput 5 "\`ffmpeg -h\` failed"
  77. VerboseOutput 5 "Sorry, It seems that ffmpeg is not installed on your system"
  78. VerboseOutput 5 "Please install ffmpeg from your repositories and make sure it is available in your \$PATH"
  79. VerboseOutput 5 "Otherwise disable conversion"
  80. ExecTime
  81. exit 1
  82. fi
  83. VerboseOutput 0 "\`ffmpeg -h\` succeeded"
  84. fi
  85. # Check EyeD3
  86. if [[ $convertart == true ]]; then
  87. if [[ ! $(eyeD3 --version 2>/dev/null) ]]; then
  88. VerboseOutput 5 "\`eyeD3 --version\` failed"
  89. VerboseOutput 5 "Sorry, It seems that eyeD3 is not installed on your system"
  90. VerboseOutput 5 "Please install eyeD3 from your repositories and make sure it is available in your \$PATH"
  91. VerboseOutput 5 "Otherwise disable albumart conversion"
  92. ExecTime
  93. exit 1
  94. fi
  95. VerboseOutput 0 "\`eyeD3 --version\` succeeded"
  96. fi
  97. # Check ImageMagick
  98. if [[ $convertart == true ]]; then
  99. if [[ ! $(convert --version 2>/dev/null) ]]; then
  100. VerboseOutput 5 "\`convert --version\` failed"
  101. VerboseOutput 5 "Sorry, It seems that ImageMagick is not installed on your system"
  102. VerboseOutput 5 "Please install ImageMagick from your repositories and make sure it is available in your \$PATH"
  103. VerboseOutput 5 "Otherwise disable albumart conversion"
  104. ExecTime
  105. exit 1
  106. fi
  107. VerboseOutput 0 "\`convert --version\` succeeded"
  108. fi
  109. # Check Gnu parallel
  110. if [[ $multithread == true ]]; then
  111. if [[ ! $(parallel -h 2>/dev/null) ]]; then
  112. VerboseOutput 5 "\`parallel -h\` failed"
  113. VerboseOutput 5 "Sorry, It seems that parallel is not installed on your system"
  114. VerboseOutput 5 "Please install gnu-parallel from your repositories and make sure it is available in your \$PATH"
  115. VerboseOutput 5 "Otherwise disable multithreading"
  116. ExecTime
  117. exit 1
  118. fi
  119. VerboseOutput 0 "\`parallel -h\` succeeded"
  120. fi
  121. # Check playlist requirement
  122. if [[ $playlists == true ]]; then
  123. if [[ $windowspaths == true ]]; then
  124. if [[ ! $(unix2dos --version 2>/dev/null) ]]; then
  125. VerboseOutput 5 "\`unix2dos --version\` failed"
  126. VerboseOutput 5 "Sorry, It seems that unix2dos is not installed on your system"
  127. VerboseOutput 5 "Please install unix2dos from your repositories and make sure it is available in your \$PATH"
  128. VerboseOutput 5 "Otherwise disable windows-format playlists"
  129. ExecTime
  130. exit 1
  131. fi
  132. fi
  133. VerboseOutput 0 "\`unix2dos --version\` succeeded"
  134. fi
  135. fi
  136. VerboseOutput 1 "Dependency test OK"
  137. }
  138. GetOptions() {
  139. # https://stackoverflow.com/a/29754866
  140. OPTIONS=s:d:t:c::a::v::hj::pwm:
  141. LONGOPTS=source:,dest:,temp:,convert::,resize-art::,verbose::,help,jobs::,playlists,windows-format,mapping:
  142. # -use ! and PIPESTATUS to get exit code with errexit set
  143. # -temporarily store output to be able to check for errors
  144. # -activate quoting/enhanced mode (e.g. by writing out “--options”)
  145. # -pass arguments only via -- "$@" to separate them correctly
  146. ! PARSED=$(getopt --options=$OPTIONS --longoptions=$LONGOPTS --name "$0" -- "$@")
  147. if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
  148. # e.g. return value is 1
  149. # then getopt has complained about wrong arguments to stdout
  150. Usage
  151. exit 2
  152. fi
  153. # read getopt’s output this way to handle the quoting right:
  154. eval set -- "$PARSED"
  155. # now enjoy the options in order and nicely split until we see --
  156. while true; do
  157. case "$1" in
  158. -v|--verbose)
  159. if [[ $2 != "" ]]; then
  160. verbose=${2}
  161. fi
  162. shift 2
  163. ;;
  164. -h|--help)
  165. help=true
  166. shift
  167. ;;
  168. -s|--source)
  169. VerboseOutput 0 "--source given."
  170. VerboseOutput 1 "Source is ${2}"
  171. source="$2"
  172. shift 2
  173. ;;
  174. -d|--dest)
  175. VerboseOutput 0 "--dest given."
  176. VerboseOutput 1 "Destination is ${2}"
  177. dest="$2"
  178. shift 2
  179. ;;
  180. -t|--temp)
  181. VerboseOutput 0 "--temp given"
  182. VerboseOutput 1 "Tempfolder is ${2}"
  183. temp="$2"
  184. shift 2
  185. ;;
  186. -c|--convert)
  187. VerboseOutput 0 "--convert given"
  188. convert=true
  189. if [[ $2 != "" ]]; then
  190. bitrate=${2}
  191. fi
  192. VerboseOutput 1 "Converted bitrate is ${bitrate}"
  193. shift 2
  194. ;;
  195. -a|--resize-art)
  196. VerboseOutput 0 "--resize-art given"
  197. convertart=true
  198. if [[ $2 != "" ]]; then
  199. coverartsize=${2}
  200. fi
  201. shift 2
  202. VerboseOutput 1 "Album art will ${coverartsize}px wide"
  203. ;;
  204. -j|--jobs)
  205. VerboseOutput 0 "--jobs given"
  206. multithread=true
  207. if [[ $2 != "" ]]; then
  208. jobcount=${2}
  209. fi
  210. shift 2
  211. VerboseOutput 1 "Multithreading will use ${jobcount} threads"
  212. ;;
  213. -p|--playlists)
  214. VerboseOutput 0 "--playlists given"
  215. playlists=true
  216. shift
  217. VerboseOutput 1 "Playlist sync enabled"
  218. ;;
  219. -w|--windows-format)
  220. VerboseOutput 0 "--windows given"
  221. windowspaths=true
  222. shift
  223. VerboseOutput 1 "Windows format playlists enabled"
  224. ;;
  225. -m|--mapping)
  226. VerboseOutput 0 "--mapping given"
  227. listmappingparam=(${2//,/ })
  228. mappingsrc=${listmappingparam[0]:-""}
  229. mappingdest=${listmappingparam[1]:-""}
  230. shift 2
  231. VerboseOutput 1 "Playlist mappings enabled source=${mappingsrc} dest=${mappingdest}"
  232. ;;
  233. --)
  234. shift
  235. break
  236. ;;
  237. *)
  238. VerboseOutput 5 "Programming error"
  239. return 3
  240. ;;
  241. esac
  242. done
  243. if [[ ! -z "${1+x}" ]]; then
  244. if [[ ${source} != "-" ]]; then
  245. VerboseOutput 4 "Source provided twice. Continueing with ${1}"
  246. fi
  247. source="$1"
  248. VerboseOutput 1 "Source is ${1}"
  249. fi
  250. if [[ ! -z "${2+x}" ]]; then
  251. if [[ ${dest} != "-" ]]; then
  252. VerboseOutput 4 "Destination provided twice. Continueing with ${2}"
  253. fi
  254. dest="$2"
  255. VerboseOutput 1 "Destination is ${2}"
  256. fi
  257. if [[ $dest == "-" ]] || [[ $source == "-" ]]; then
  258. help=true
  259. fi
  260. VerboseOutput 1 "Checks OK. Going on"
  261. }
  262. Usage() {
  263. grep '^#/' "${script_dir}/${script_name}" | sed 's/^#\/\w*//'
  264. }
  265. VerboseOutput() {
  266. level=""
  267. if [[ $verbose -le $1 ]]; then
  268. case "$1" in
  269. 0)
  270. level="\033[1;36mVerbose\033[0m"
  271. ;;
  272. 1)
  273. level="\033[1;34m Debug \033[0m"
  274. ;;
  275. 2)
  276. level="\033[1;37m Info \033[0m"
  277. ;;
  278. 3)
  279. level="\033[1;33mWarning\033[0m"
  280. ;;
  281. 4)
  282. level="\033[1;31m Error \033[0m"
  283. ;;
  284. 5)
  285. level="\033[1;30m Fatal \033[0m"
  286. ;;
  287. esac
  288. echo -e "[$level] $2" >&2
  289. fi
  290. }
  291. CreateFileList() {
  292. # ${1} /mnt/hdd/Example-Artist/Example-Album
  293. # ${2} /mnt/mtp/Example-Artist/Example-Album
  294. # ${3} Example-Artist/Example-Album
  295. IFS=""
  296. sourcepath="${1/\[/\\\[}/*"
  297. sourcepath="${sourcepath/\]/\\\]}"
  298. for file in $sourcepath; do
  299. origfile="${file#"$1/"}"
  300. relfile=$(echo ${origfile} | sed -e 's/\(\.\)*$//g' | tr -s ' ')
  301. mp3file=$(echo "${relfile}" | sed -e 's/.flac/.mp3/')
  302. VerboseOutput 0 "Checking ${3}${origfile}"
  303. if [[ -d "${1}/$origfile" ]]; then
  304. VerboseOutput 0 "${origfile} is folder"
  305. newdir="${3}$origfile"
  306. newdir=${newdir#"/"}
  307. VerboseOutput 1 "Entering $newdir"
  308. CreateFileList "${1}/$origfile/" "${2}/$relfile/" "$newdir/"
  309. elif [[ "${1}/$origfile" != *".m3u" ]] && [[ "${1}/$origfile" -nt "${2}/$mp3file" ]]; then
  310. echo ${3}$origfile >> /tmp/music-sync-filelist
  311. VerboseOutput 0 "${origfile} is newer in source"
  312. VerboseOutput 2 "Added: ${3}${origfile}"
  313. elif [[ $playlists == true ]] && [[ "${1}/$origfile" == *".m3u" ]] && [[ "${1}/$origfile" -nt "${2}/$mp3file" ]]; then
  314. echo ${3}$origfile >> /tmp/music-sync-filelist
  315. VerboseOutput 0 "${origfile} is newer in source"
  316. VerboseOutput 2 "Added playlist: ${3}${origfile}"
  317. fi
  318. done
  319. }
  320. Execute() {
  321. curline=0
  322. percentage=0
  323. IFS="
  324. ";
  325. if [[ $multithread == true ]]; then
  326. VerboseOutput 3 "Running in parallel. Mind your load"
  327. export -f ProcessFile
  328. export -f ConvertFile
  329. export -f ConvertPlaylist
  330. export -f CopyFile
  331. export -f VerboseOutput
  332. export -f ExecTime
  333. export dest
  334. export source
  335. export bitrate
  336. export convert
  337. export temp
  338. export verbose
  339. export coverartsize
  340. export begin
  341. export windowspaths
  342. export mappingsrc
  343. export mappingdest
  344. parallel --jobs ${jobcount} --will-cite --line-buffer --arg-file "/tmp/music-sync-filelist" ProcessFile
  345. else
  346. for line in $(cat "/tmp/music-sync-filelist")
  347. do
  348. ProcessFile $line
  349. done
  350. fi
  351. VerboseOutput 2 "Done!"
  352. }
  353. ProcessFile() {
  354. line=${1}
  355. curline="$(grep -n "$line" /tmp/music-sync-filelist | head -n 1 | cut -d: -f1)"
  356. total=$(cat /tmp/music-sync-filelist | wc -l)
  357. percentage=$(echo "scale=4;${curline}/${total}" | bc)
  358. percentage=$(echo "scale=2;${percentage}*100" | bc)
  359. VerboseOutput 2 "Current File: $line"
  360. VerboseOutput 2 "Progress: $curline / $total (${percentage%00}%)"
  361. if [[ $convert == true ]]; then
  362. if [[ $line == *".m3u" ]]; then
  363. ConvertPlaylist "$line"
  364. else
  365. ConvertFile "$line"
  366. fi
  367. fi
  368. CopyFile "$line"
  369. }
  370. ConvertPlaylist() {
  371. line=${1}
  372. if [[ ! -f "${source}/$line" ]]; then
  373. VerboseOutput 5 "Source-file ${source}/$line Unreachable"
  374. ExecTime
  375. exit 3
  376. fi
  377. if [[ ! -d $(dirname "$temp/$line") ]]; then
  378. VerboseOutput 0 "Creating folder $temp/${line%/*}"
  379. mkdir -p "$temp/${line%/*}";
  380. fi;
  381. if [[ -f "$temp/$line" ]]; then
  382. rm "$temp/$line"
  383. fi
  384. VerboseOutput 0 "Creating playlist $temp/$line"
  385. IFS="
  386. "
  387. for item in $(cat "$source/$line")
  388. do
  389. mp3item=$(echo $item | sed -e 's/\.*\//\//g'| tr -s ' ')
  390. if [[ $convert == true ]]; then
  391. mp3item=$(echo "$item" | sed -e 's/.flac/.mp3/')
  392. else
  393. mp3item=$(echo "$item")
  394. fi
  395. playline=$(echo "${mp3item}" | sed -e "s,$mappingsrc,$mappingdest,g")
  396. if [[ $windowspaths == true ]]; then
  397. playline=$(echo "${playline}" | sed -e 's,\/,\\,g')
  398. fi
  399. echo $playline >> $temp/$line
  400. done
  401. if [[ $windowspaths == true ]]; then
  402. VerboseOutput 0 "Converting lf to crlf"
  403. unix2dos $temp/$line 1>/dev/null 2>/dev/null
  404. fi
  405. VerboseOutput 1 "Converted: $line"
  406. }
  407. ConvertFile() {
  408. line=${1}
  409. if [[ ! -f "${source}/$line" ]]; then
  410. VerboseOutput 5 "Source-file ${source}/$line Unreachable"
  411. ExecTime
  412. exit 3
  413. fi
  414. if [[ ! -d $(dirname "$temp/$line") ]]; then
  415. VerboseOutput 0 "Creating folder $temp/${line%/*}"
  416. mkdir -p "$temp/${line%/*}";
  417. fi;
  418. mp3line=$(echo "$line" | sed -e 's/.flac/.mp3/')
  419. if [[ "${source}/$line" -nt "$temp/$mp3line" ]]; then
  420. if [[ $line != *".mp3" ]]; then
  421. VerboseOutput 3 "${line} will be converted to mp3-file ${mp3line}"
  422. fi
  423. VerboseOutput 0 "Converting MP3-file $line"
  424. ffmpeg -y -i "$source/$line" -acodec libmp3lame -map_metadata 0 -id3v2_version 3 -b:a ${bitrate}k "$temp/${mp3line}" 1>/dev/null 2>/dev/null
  425. if [[ $convertart == true ]]; then
  426. VerboseOutput 0 "Creating folder $temp/${mp3line}-images/"
  427. mkdir -p "$temp/${mp3line}-images/"
  428. VerboseOutput 0 "Extracted albumart"
  429. eyeD3 --write-images "$temp/${mp3line}-images/" "$temp/${mp3line}" 1>/dev/null 2>/dev/null
  430. frontcovers=(${temp}/${mp3line}-images/FRONT_COVER.*)
  431. if [ -e "$frontcovers" ]; then
  432. VerboseOutput 0 "Converting albumart"
  433. convert "$temp/${mp3line}-images/FRONT_COVER.*" -resize ${coverartsize}x${coverartsize} "$temp/${mp3line}-images/FRONT_COVER.jpg" 1>/dev/null 2>/dev/null
  434. eyeD3 --remove-all-images "$temp/${mp3line}" 1>/dev/null 2>/dev/null
  435. VerboseOutput 0 "Embedding albumart"
  436. eyeD3 --add-image "$temp/${mp3line}-images/FRONT_COVER.jpg:FRONT_COVER" "$temp/${mp3line}" 1>/dev/null 2>/dev/null
  437. VerboseOutput 1 "Converted cover art: ${mp3line}"
  438. else
  439. VerboseOutput 4 "No front cover art found for ${mp3line}"
  440. fi
  441. fi
  442. VerboseOutput 1 "Converted: $line"
  443. else
  444. VerboseOutput 3 "$line already converted"
  445. fi;
  446. }
  447. CopyFile() {
  448. line=${1}
  449. if [[ ! -d "$dest" ]]; then
  450. VerboseOutput 5 "Destination unreachable"
  451. ExecTime
  452. exit 4
  453. fi
  454. if [[ $convert == true ]]; then
  455. mp3line=$(echo "$line" | sed -e 's/.flac/.mp3/')
  456. else
  457. mp3line=$(echo "$line")
  458. fi
  459. if [[ ! -f "${temp}/$mp3line" ]]; then
  460. VerboseOutput 5 "Source-file ${temp}/$mp3line Unreachable"
  461. ExecTime
  462. exit 3
  463. fi
  464. destline=$(echo $mp3line | sed -e 's/\.*\//\//g'| tr -s ' ')
  465. VerboseOutput 1 "Copying: $line"
  466. if [[ ! -d $(dirname "$dest/$line") ]]; then
  467. VerboseOutput 0 "Creating folder $dest/${line%/*}"
  468. mkdir -p "$dest/${destline%/*}";
  469. fi;
  470. cp -fv "$temp/$mp3line" "$dest/${destline}" 1>/dev/null 2>/dev/null
  471. VerboseOutput 1 "Copied: $line"
  472. }
  473. CleanUp() {
  474. VerboseOutput 1 "Cleaning Up"
  475. if [[ -f /tmp/music-sync-filelist ]]; then
  476. VerboseOutput 1 "Removing filelist"
  477. rm "/tmp/music-sync-filelist"
  478. fi
  479. VerboseOutput 1 "Done"
  480. }
  481. ExecTime() {
  482. termin=$(date +"%s")
  483. difftimelps=$(($termin-$begin))
  484. VerboseOutput 1 "$(($difftimelps / 60)) minutes and $(($difftimelps % 60)) seconds elapsed for Script Execution."
  485. }
  486. ErrorHandler() {
  487. VerboseOutput 5 "Error while executing $1"
  488. CleanUp
  489. exit 5
  490. }
  491. ExitHandler() {
  492. VerboseOutput 5 "Aborted"
  493. CleanUp
  494. exit 0
  495. }
  496. trap 'ExitHandler' SIGINT
  497. trap 'ErrorHandler $BASH_COMMAND' ERR
  498. CheckDeps 2
  499. GetOptions $@
  500. CheckDeps 1
  501. if [[ $help == true ]]; then
  502. Usage
  503. exit
  504. fi
  505. if [[ -f /tmp/music-sync-filelist ]]; then
  506. rm /tmp/music-sync-filelist
  507. fi
  508. VerboseOutput 2 "Scanning for new or updated files"
  509. CreateFileList $source $dest ""
  510. if [[ ! -f /tmp/music-sync-filelist ]]; then
  511. VerboseOutput 2 "Nothing to do!"
  512. CleanUp
  513. exit 0
  514. fi
  515. if [[ $convert == true ]]; then
  516. temp=${temp}/bitrate-${bitrate}
  517. if [[ $convertart == true ]]; then
  518. temp="${temp}/coverart-${coverartsize}"
  519. else
  520. temp="${temp}/coverart-original"
  521. fi
  522. VerboseOutput 2 "Conversion enabled. Using $temp as temp-folder"
  523. else
  524. if [[ $temp != "/tmp/converted" ]]; then
  525. VerboseOutput 2 "Conversion not enabled. Ignoring temp folder"
  526. fi
  527. temp=$source
  528. fi
  529. Execute
  530. CleanUp
  531. ExecTime