Compare commits

..

No commits in common. "1a714ac77afe71222bb432e733bbc9dc6d9baefb" and "117325d1efaee6acd1327f44e933df9065af1e3b" have entirely different histories.

8 changed files with 25 additions and 169 deletions

View file

@ -25,11 +25,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1730785428, "lastModified": 1730531603,
"narHash": "sha256-Zwl8YgTVJTEum+L+0zVAWvXAGbWAuXHax3KzuejaDyo=", "narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4aa36568d413aca0ea84a1684d2d46f55dbabad7", "rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -2,8 +2,7 @@
sbcl, sbcl,
lib, lib,
writeText, writeText,
cuetools, ffmpeg,
ffmpeg-headless,
makeWrapper, makeWrapper,
}: let }: let
sync-music = sbcl.buildASDFSystem rec { sync-music = sbcl.buildASDFSystem rec {
@ -14,7 +13,6 @@
lispLibs = with sbcl.pkgs; [ lispLibs = with sbcl.pkgs; [
alexandria alexandria
babel babel
cl-ppcre
fset fset
lparallel lparallel
pathname-utils pathname-utils
@ -28,8 +26,7 @@
]; ];
runtimeInputs = [ runtimeInputs = [
cuetools ffmpeg
ffmpeg-headless
]; ];
buildScript = writeText "build-sync-music.lisp" '' buildScript = writeText "build-sync-music.lisp" ''

View file

@ -6,7 +6,6 @@
(: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

View file

@ -4,10 +4,7 @@
(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)))
@ -34,22 +31,14 @@ 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 (uiop:run-program (list "ffmpeg" "-n"
`("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))
@ -90,6 +79,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))

View file

@ -29,10 +29,6 @@
: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
@ -89,7 +85,6 @@
*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))))

View file

@ -6,7 +6,6 @@
#:*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*

View file

@ -1,114 +1,23 @@
(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 subclass of COPY-ACTION based on the filetype of ORIGIN and TEST for equality. "Create the correct subclass of COPY-ACTION based on the file extension of ORIGIN.
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") ((funcall test type "flac") (setf type "opus") #'make-copy-flac-action)
(setf type "opus") ((member type *meta-extensions* :test test) #'make-copy-meta-action)
#'make-copy-flac-action) ((member type *general-extensions* :test test) #'make-copy-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 :name (pathname-name origin) (make-pathname :directory (pathname-directory target)
: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.
@ -118,13 +27,9 @@ 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)
@ -186,7 +91,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))

View file

@ -10,17 +10,12 @@
"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))
@ -32,29 +27,6 @@
: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.