Compare commits
3 commits
117325d1ef
...
1a714ac77a
Author | SHA1 | Date | |
---|---|---|---|
1a714ac77a | |||
157ed28b77 | |||
394224eadd |
|
@ -25,11 +25,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1730531603,
|
"lastModified": 1730785428,
|
||||||
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
|
"narHash": "sha256-Zwl8YgTVJTEum+L+0zVAWvXAGbWAuXHax3KzuejaDyo=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d",
|
"rev": "4aa36568d413aca0ea84a1684d2d46f55dbabad7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
@ -2,7 +2,8 @@
|
||||||
sbcl,
|
sbcl,
|
||||||
lib,
|
lib,
|
||||||
writeText,
|
writeText,
|
||||||
ffmpeg,
|
cuetools,
|
||||||
|
ffmpeg-headless,
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
}: let
|
}: let
|
||||||
sync-music = sbcl.buildASDFSystem rec {
|
sync-music = sbcl.buildASDFSystem rec {
|
||||||
|
@ -13,6 +14,7 @@
|
||||||
lispLibs = with sbcl.pkgs; [
|
lispLibs = with sbcl.pkgs; [
|
||||||
alexandria
|
alexandria
|
||||||
babel
|
babel
|
||||||
|
cl-ppcre
|
||||||
fset
|
fset
|
||||||
lparallel
|
lparallel
|
||||||
pathname-utils
|
pathname-utils
|
||||||
|
@ -26,7 +28,8 @@
|
||||||
];
|
];
|
||||||
|
|
||||||
runtimeInputs = [
|
runtimeInputs = [
|
||||||
ffmpeg
|
cuetools
|
||||||
|
ffmpeg-headless
|
||||||
];
|
];
|
||||||
|
|
||||||
buildScript = writeText "build-sync-music.lisp" ''
|
buildScript = writeText "build-sync-music.lisp" ''
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
(:file "sync-music" :depends-on ("package" "utils")))
|
(:file "sync-music" :depends-on ("package" "utils")))
|
||||||
:depends-on (#:alexandria
|
:depends-on (#:alexandria
|
||||||
#:babel
|
#:babel
|
||||||
|
#:cl-ppcre
|
||||||
#:fset
|
#:fset
|
||||||
#:lparallel
|
#:lparallel
|
||||||
#:pathname-utils
|
#:pathname-utils
|
||||||
|
|
|
@ -4,7 +4,10 @@
|
||||||
(target nil :type pathname))
|
(target nil :type pathname))
|
||||||
(defstruct (copy-action (:include action))
|
(defstruct (copy-action (:include action))
|
||||||
(origin nil :type pathname))
|
(origin nil :type pathname))
|
||||||
(defstruct (copy-flac-action (:include copy-action)))
|
(defstruct (copy-flac-action (:include copy-action))
|
||||||
|
(start nil :type (or string null))
|
||||||
|
(end nil :type (or string null))
|
||||||
|
(metadata nil :type (soft-alist-of string string)))
|
||||||
(defstruct (copy-meta-action (:include copy-action)))
|
(defstruct (copy-meta-action (:include copy-action)))
|
||||||
(defstruct (delete-action (:include action)))
|
(defstruct (delete-action (:include action)))
|
||||||
(defstruct (directory-action (:include action)))
|
(defstruct (directory-action (:include action)))
|
||||||
|
@ -31,14 +34,22 @@ Pushes status message about this action to QUEUE if given."))
|
||||||
|
|
||||||
(defmethod action-perform ((a copy-flac-action) &optional queue)
|
(defmethod action-perform ((a copy-flac-action) &optional queue)
|
||||||
(declare (ignore queue))
|
(declare (ignore queue))
|
||||||
(uiop:run-program (list "ffmpeg" "-n"
|
(uiop:run-program
|
||||||
|
`("ffmpeg" "-n"
|
||||||
"-filter_threads" "1"
|
"-filter_threads" "1"
|
||||||
"-i" (uiop:native-namestring (copy-action-origin a))
|
"-i" ,(uiop:native-namestring (copy-action-origin a))
|
||||||
|
,@(when-let ((start (copy-flac-action-start a)))
|
||||||
|
`("-ss" ,(convert-timestamp start)))
|
||||||
|
,@(when-let ((end (copy-flac-action-end a)))
|
||||||
|
`("-to" ,(convert-timestamp end)))
|
||||||
|
,@(loop :for (tag . value) :in (copy-flac-action-metadata a)
|
||||||
|
:collect "-metadata"
|
||||||
|
:collect (format nil "~A=~A" tag value))
|
||||||
"-c:a" "libopus"
|
"-c:a" "libopus"
|
||||||
"-map" "0:a"
|
"-map" "0:a"
|
||||||
"-ac" "2"
|
"-ac" "2"
|
||||||
"-b:a" *opus-bitrate*
|
"-b:a" ,*opus-bitrate*
|
||||||
(uiop:native-namestring (action-target a)))))
|
,(uiop:native-namestring (action-target a)))))
|
||||||
|
|
||||||
(defmethod action-perform ((a copy-meta-action) &optional queue)
|
(defmethod action-perform ((a copy-meta-action) &optional queue)
|
||||||
(declare (ignore queue))
|
(declare (ignore queue))
|
||||||
|
@ -79,6 +90,6 @@ Pushes status message about this action to QUEUE if given."))
|
||||||
(defmethod action-perform-describe ((a directory-action))
|
(defmethod action-perform-describe ((a directory-action))
|
||||||
(format nil "Ensuring `~A` exists..." (action-target a)))
|
(format nil "Ensuring `~A` exists..." (action-target a)))
|
||||||
|
|
||||||
;; NOTE: delete actions are equal to copy actions to the same target
|
;; NOTE: Delete actions are equal to copy actions to the same target
|
||||||
(defmethod fset:compare ((a1 action) (a2 action))
|
(defmethod fset:compare ((a1 action) (a2 action))
|
||||||
(fset:compare-slots a1 a2 #'action-target))
|
(fset:compare-slots a1 a2 #'action-target))
|
||||||
|
|
|
@ -29,6 +29,10 @@
|
||||||
:long "depth"
|
:long "depth"
|
||||||
:arg-parser #'parse-integer
|
:arg-parser #'parse-integer
|
||||||
:meta-var "MAX-DEPTH")
|
:meta-var "MAX-DEPTH")
|
||||||
|
(:name :dumb-cue-copy-p
|
||||||
|
:description "blindly copy cue files instead of parsing them"
|
||||||
|
:short #\c
|
||||||
|
:long "dumb-cue-copy")
|
||||||
(:name :dry-run-p
|
(:name :dry-run-p
|
||||||
:description "print out actions and exit"
|
:description "print out actions and exit"
|
||||||
:short #\n
|
:short #\n
|
||||||
|
@ -85,6 +89,7 @@
|
||||||
*opus-bitrate*))
|
*opus-bitrate*))
|
||||||
(*max-depth* (or (getf options :max-depth)
|
(*max-depth* (or (getf options :max-depth)
|
||||||
*max-depth*))
|
*max-depth*))
|
||||||
|
(*dumb-cue-copy-p* (getf options :dumb-cue-copy-p))
|
||||||
(*dry-run-p* (getf options :dry-run-p))
|
(*dry-run-p* (getf options :dry-run-p))
|
||||||
(*cleanupp* (not (getf options :no-cleanup-p)))
|
(*cleanupp* (not (getf options :no-cleanup-p)))
|
||||||
(*ignore-toplevel-p* (not (getf options :no-ignore-toplevel-p))))
|
(*ignore-toplevel-p* (not (getf options :no-ignore-toplevel-p))))
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
#:*max-depth*
|
#:*max-depth*
|
||||||
#:*cleanupp*
|
#:*cleanupp*
|
||||||
#:*ignore-toplevel-p*
|
#:*ignore-toplevel-p*
|
||||||
|
#:*dumb-cue-copy-p*
|
||||||
#:*general-extensions*
|
#:*general-extensions*
|
||||||
#:*meta-extensions*
|
#:*meta-extensions*
|
||||||
#:*action-perform-output*
|
#:*action-perform-output*
|
||||||
|
|
|
@ -1,23 +1,114 @@
|
||||||
(in-package #:sync-music)
|
(in-package #:sync-music)
|
||||||
|
|
||||||
(defun make-file-copy-action (origin target &optional (test #'string-equal))
|
(defun make-file-copy-action (origin target &optional (test #'string-equal))
|
||||||
"Create the correct subclass of COPY-ACTION based on the file extension of ORIGIN.
|
"Create subclass of COPY-ACTION based on the filetype of ORIGIN and TEST for equality.
|
||||||
|
|
||||||
Return the action if it exists, or NIL if the file should not be copied."
|
Return the action if it exists, or NIL if the file should not be copied."
|
||||||
(when-let* ((type (pathname-type origin))
|
(when-let* ((type (pathname-type origin))
|
||||||
(make-action-fn
|
(make-action-fn
|
||||||
(cond
|
(cond
|
||||||
((funcall test type "flac") (setf type "opus") #'make-copy-flac-action)
|
((funcall test type "flac")
|
||||||
((member type *meta-extensions* :test test) #'make-copy-meta-action)
|
(setf type "opus")
|
||||||
((member type *general-extensions* :test test) #'make-copy-action))))
|
#'make-copy-flac-action)
|
||||||
|
((and *dumb-cue-copy-p*
|
||||||
|
(member type *meta-extensions* :test test))
|
||||||
|
#'make-copy-meta-action)
|
||||||
|
((member type *general-extensions* :test test)
|
||||||
|
#'make-copy-action))))
|
||||||
(funcall make-action-fn
|
(funcall make-action-fn
|
||||||
:origin origin
|
:origin origin
|
||||||
:target (pathname-normalize-unicode
|
:target (pathname-normalize-unicode
|
||||||
(make-pathname :directory (pathname-directory target)
|
(make-pathname :name (pathname-name origin)
|
||||||
:name (pathname-name origin)
|
|
||||||
:type type
|
:type type
|
||||||
:defaults target)))))
|
:defaults target)))))
|
||||||
|
|
||||||
|
(defun get-cue-copy-actions (origin origin-files target &optional (test #'string-equal))
|
||||||
|
(when-let* ((cue-file (uiop:native-namestring
|
||||||
|
(find-if (lambda (f)
|
||||||
|
(funcall test (pathname-type f) "cue"))
|
||||||
|
origin-files)))
|
||||||
|
(cue-track-count (parse-integer
|
||||||
|
(uiop:run-program (list "cueprint"
|
||||||
|
"-d" "%N"
|
||||||
|
cue-file)
|
||||||
|
:output :string)
|
||||||
|
:junk-allowed t)))
|
||||||
|
(when (> cue-track-count (count-if (lambda (f)
|
||||||
|
(funcall test (pathname-type f) "flac"))
|
||||||
|
origin-files))
|
||||||
|
(loop :repeat cue-track-count
|
||||||
|
:with actions := (fset:empty-set)
|
||||||
|
:with album-data-raw := (uiop:run-program
|
||||||
|
(list "cueprint"
|
||||||
|
"-d" "%T\\n%02N\\n%P"
|
||||||
|
cue-file)
|
||||||
|
:output :lines)
|
||||||
|
:with track-data-raw := (uiop:run-program
|
||||||
|
(list "cueprint"
|
||||||
|
"-t" "%f\\n%t\\n%02n\\n%p\\n%c\\n%g\\n"
|
||||||
|
cue-file)
|
||||||
|
:output :lines)
|
||||||
|
:with break-start-data-raw := (uiop:run-program
|
||||||
|
(list "cuebreakpoints"
|
||||||
|
"--append-gaps"
|
||||||
|
cue-file)
|
||||||
|
:output :lines)
|
||||||
|
:with break-end-data-raw := (uiop:run-program
|
||||||
|
(list "cuebreakpoints"
|
||||||
|
"--prepend-gaps"
|
||||||
|
cue-file)
|
||||||
|
:output :lines)
|
||||||
|
;; HACK: Determine if track 1 has pregap based on breakpoint count
|
||||||
|
:with track-1-pregap-p := (< (length break-start-data-raw)
|
||||||
|
(length break-end-data-raw))
|
||||||
|
:for origin-file-name := (let* ((raw-file (pop track-data-raw))
|
||||||
|
(dot (position #\. raw-file :from-end t)))
|
||||||
|
(subseq raw-file 0 dot))
|
||||||
|
:for origin-file := (make-pathname :name origin-file-name
|
||||||
|
:type "flac"
|
||||||
|
:defaults origin)
|
||||||
|
:for track-title := (first track-data-raw)
|
||||||
|
:for track-number := (second track-data-raw)
|
||||||
|
:for data-raw := (append album-data-raw
|
||||||
|
(loop :repeat 5
|
||||||
|
:collect (pop track-data-raw)))
|
||||||
|
:for metadata := (mapcar #'cons
|
||||||
|
'("ALBUM"
|
||||||
|
"TRACKTOTAL"
|
||||||
|
"ALBUMARTIST"
|
||||||
|
"TITLE"
|
||||||
|
"TRACKNUMBER"
|
||||||
|
"ARTIST"
|
||||||
|
"COMPOSER"
|
||||||
|
"GENRE")
|
||||||
|
data-raw)
|
||||||
|
:for metadata-filtered := (delete-if (compose #'emptyp #'cdr) metadata)
|
||||||
|
:for previous-action := nil :then current-action
|
||||||
|
:for output-file-name := (substitute #\! #\/ (format nil
|
||||||
|
"~A - ~A"
|
||||||
|
track-number
|
||||||
|
track-title))
|
||||||
|
:for current-action := (make-copy-flac-action
|
||||||
|
:origin origin-file
|
||||||
|
:target (pathname-normalize-unicode
|
||||||
|
(make-pathname :name output-file-name
|
||||||
|
:type "opus"
|
||||||
|
:defaults target))
|
||||||
|
:metadata metadata-filtered)
|
||||||
|
:do (fset:adjoinf actions current-action)
|
||||||
|
:with previous-file
|
||||||
|
:do (cond
|
||||||
|
((equal previous-file origin-file-name)
|
||||||
|
(setf (copy-flac-action-start current-action) (pop break-start-data-raw))
|
||||||
|
(setf (copy-flac-action-end previous-action) (pop break-end-data-raw)))
|
||||||
|
(t
|
||||||
|
(unless (uiop:file-exists-p origin-file)
|
||||||
|
(return))
|
||||||
|
(when track-1-pregap-p
|
||||||
|
(setf (copy-flac-action-start current-action) (pop break-end-data-raw)))
|
||||||
|
(setf previous-file origin-file-name)))
|
||||||
|
:finally (return actions)))))
|
||||||
|
|
||||||
(defun get-all-copy-actions (origin target &optional (depth 0))
|
(defun get-all-copy-actions (origin target &optional (depth 0))
|
||||||
"Recursively iterate ORIGIN for files to copy.
|
"Recursively iterate ORIGIN for files to copy.
|
||||||
|
|
||||||
|
@ -27,9 +118,13 @@ actions that need performing in order to store the targets as second value."
|
||||||
(let ((files (fset:empty-set))
|
(let ((files (fset:empty-set))
|
||||||
(directories (fset:empty-set)))
|
(directories (fset:empty-set)))
|
||||||
(unless (and *ignore-toplevel-p* (= depth 1))
|
(unless (and *ignore-toplevel-p* (= depth 1))
|
||||||
|
(let ((origin-files (uiop:directory-files origin)))
|
||||||
|
(if-let ((cue-copy-actions (and (not *dumb-cue-copy-p*)
|
||||||
|
(get-cue-copy-actions origin origin-files target))))
|
||||||
|
(fset:unionf files cue-copy-actions)
|
||||||
(dolist (file (uiop:directory-files origin))
|
(dolist (file (uiop:directory-files origin))
|
||||||
(when-let ((action (make-file-copy-action file target)))
|
(when-let ((action (make-file-copy-action file target)))
|
||||||
(fset:adjoinf files action))))
|
(fset:adjoinf files action))))))
|
||||||
(dolist (directory (uiop:subdirectories origin))
|
(dolist (directory (uiop:subdirectories origin))
|
||||||
(let ((target-directory
|
(let ((target-directory
|
||||||
(make-pathname :directory (append (pathname-directory target)
|
(make-pathname :directory (append (pathname-directory target)
|
||||||
|
@ -91,7 +186,7 @@ total task count as arguments."
|
||||||
|
|
||||||
(defun clean-directory (target)
|
(defun clean-directory (target)
|
||||||
"Delete all empty folders and folders containing only empty folders in TARGET."
|
"Delete all empty folders and folders containing only empty folders in TARGET."
|
||||||
;; NOTE: want (every #'clean-directory ...) but without short-circuiting
|
;; NOTE: Want (every #'clean-directory ...) but without short-circuiting
|
||||||
(and (every #'identity
|
(and (every #'identity
|
||||||
(mapcar #'clean-directory (uiop:subdirectories target)))
|
(mapcar #'clean-directory (uiop:subdirectories target)))
|
||||||
(null (uiop:directory-files target))
|
(null (uiop:directory-files target))
|
||||||
|
|
|
@ -10,12 +10,17 @@
|
||||||
"Whether to perform remove actions and delete empty folders.")
|
"Whether to perform remove actions and delete empty folders.")
|
||||||
(defvar *ignore-toplevel-p* t
|
(defvar *ignore-toplevel-p* t
|
||||||
"Whether to ignore files at the top level.")
|
"Whether to ignore files at the top level.")
|
||||||
|
(defvar *dumb-cue-copy-p* nil
|
||||||
|
"Whether to blindly copy cue files rather than parse them.")
|
||||||
|
|
||||||
(defparameter *general-extensions* '("mp3" "ogg" "opus")
|
(defparameter *general-extensions* '("mp3" "ogg" "opus")
|
||||||
"General file extensions that should be copied over.")
|
"General file extensions that should be copied over.")
|
||||||
(defparameter *meta-extensions* '("cue" "m3u" "m3u8")
|
(defparameter *meta-extensions* '("cue" "m3u" "m3u8")
|
||||||
"File extensions where *.flac text needs to be replaced.")
|
"File extensions where *.flac text needs to be replaced.")
|
||||||
|
|
||||||
|
(defparameter *timestamp-scanner* (ppcre:create-scanner "^(\\d+):(\\d{2})\\.(\\d{2})$")
|
||||||
|
"Scan for a cuetools position.")
|
||||||
|
|
||||||
(defun maybe-normalize-unicode (s)
|
(defun maybe-normalize-unicode (s)
|
||||||
(if (stringp s) (uax-15:normalize s :nfc) s))
|
(if (stringp s) (uax-15:normalize s :nfc) s))
|
||||||
|
|
||||||
|
@ -27,6 +32,29 @@
|
||||||
:type (maybe-normalize-unicode (pathname-type p))
|
:type (maybe-normalize-unicode (pathname-type p))
|
||||||
:defaults p))
|
:defaults p))
|
||||||
|
|
||||||
|
(defmacro handle-overflow ((unit unit-max) (next-unit &optional next-max) &rest args)
|
||||||
|
"Handle integer overflow from UNIT with maximum UNIT-MAX into NEXT-UNIT."
|
||||||
|
(with-gensyms (div rem)
|
||||||
|
`(when (>= ,unit ,unit-max)
|
||||||
|
(multiple-value-bind (,div ,rem)
|
||||||
|
(floor ,unit ,unit-max)
|
||||||
|
(setf ,unit ,rem)
|
||||||
|
(incf ,next-unit ,div))
|
||||||
|
,(when next-max
|
||||||
|
`(handle-overflow (,next-unit ,next-max) ,@args)))))
|
||||||
|
|
||||||
|
(defun convert-timestamp (s)
|
||||||
|
"Convert cuetools position MM:SS.FF to ffmpeg duration MM:SS.m."
|
||||||
|
(let* ((all-times (nth-value 1 (ppcre:scan-to-strings *timestamp-scanner* s)))
|
||||||
|
(hours 0)
|
||||||
|
(minutes (parse-integer (aref all-times 0)))
|
||||||
|
(seconds (parse-integer (aref all-times 1)))
|
||||||
|
(frames (parse-integer (aref all-times 2)))
|
||||||
|
(millis (round (* frames 1000) 75)))
|
||||||
|
(handle-overflow (millis 1000) (seconds 60) (minutes))
|
||||||
|
(handle-overflow (minutes 60) (hours))
|
||||||
|
(format nil "~D:~2,'0D:~2,'0D.~3,'0D" hours minutes seconds millis)))
|
||||||
|
|
||||||
(defun vector-nconc-extend (v1 v2 &key (start 0) end)
|
(defun vector-nconc-extend (v1 v2 &key (start 0) end)
|
||||||
"Like vector-append-extend, but using another vector.
|
"Like vector-append-extend, but using another vector.
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue