lisp-scripts/sync-music/utils.lisp
eriedaberrie 1a714ac77a feat!: sync-music: properly utilize cuesheets
Instead of blindly copying over .cue files, attempt to parse them with
cuetools and use the data to split up the associated flac files.  Old behavior
is available under --dumb-cue-copy.
2024-11-07 15:19:59 -08:00

124 lines
5 KiB
Common Lisp

(in-package #:sync-music)
(defvar *worker-threads* nil
"Number of worker threads to launch.")
(defvar *opus-bitrate* "96K"
"Opus bitrate to pass to ffmpeg's -b:a flag.")
(defvar *max-depth* 8
"Max depth to recur into folders before signalling.")
(defvar *cleanupp* t
"Whether to perform remove actions and delete empty folders.")
(defvar *ignore-toplevel-p* t
"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")
"General file extensions that should be copied over.")
(defparameter *meta-extensions* '("cue" "m3u" "m3u8")
"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)
(if (stringp s) (uax-15:normalize s :nfc) s))
(defun pathname-normalize-unicode (p)
"Normalize the unicode for all components of PATHNAME."
(make-pathname :directory (mapcar #'maybe-normalize-unicode
(pathname-directory p))
:name (maybe-normalize-unicode (pathname-name p))
:type (maybe-normalize-unicode (pathname-type 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)
"Like vector-append-extend, but using another vector.
START and END are the same as in REPLACE for V2."
(let* ((fill-pointer (fill-pointer v1))
(new-fill-pointer (+ fill-pointer (- (or end (length v2)) start))))
(loop :for size := (array-total-size v1)
:while (< size new-fill-pointer)
:do (adjust-array v1 (+ size (max (floor size 2) 10))))
(setf (fill-pointer v1) new-fill-pointer)
(replace v1 v2 :start1 fill-pointer :start2 start :end2 end)))
(defun replace-all-subseqs (seq subseq1 subseq2)
"Replace all instances of SUBSEQ1 with SUBSEQ in SEQ.
Returns a new array if replacements were made and NIL otherwise."
(loop :with seqlen := (length seq)
:and sublen1 := (length subseq1)
:and sublen2 := (length subseq2)
:for prev-index := 0 :then (+ index sublen1)
:for index := (search subseq1 seq :start2 prev-index)
:while index
;; NOTE: Avoid WITH to not create an array on immediate returns
:for out-buf := (make-array (+ seqlen
(if (> sublen2 sublen1)
(max (floor seqlen 2) 10)
0))
:element-type (array-element-type seq)
:adjustable t
:fill-pointer 0)
:then out-buf
:do (vector-nconc-extend out-buf seq :start prev-index :end index)
(vector-nconc-extend out-buf subseq2)
:finally (when out-buf
(return
(vector-nconc-extend out-buf seq :start prev-index)))))
(defun replace-text-buffer (buf from to)
"Given octet array BUF, converts all instances of text FROM to TO.
Tries to take into account different character sizes."
(loop :repeat 3
:for from-buf := (babel:string-to-octets from :encoding :ascii)
:then (intersperse 0 from-buf)
:for to-buf := (babel:string-to-octets to :encoding :ascii)
:then (intersperse 0 to-buf)
:for replaced := (replace-all-subseqs buf from-buf to-buf)
:when replaced
:return replaced
:finally (return buf)))
(define-condition exceeded-max-depth (error)
()
(:report (lambda (c s)
(declare (ignore c))
(format s "exceeded the max directory depth"))))
(defun check-depth (depth)
(when (= depth *max-depth*)
(restart-case
(error 'exceeded-max-depth)
(ignore-max-depth ()
:report "Continue and ignore max depth for future checks."
(setf *max-depth* 0))
(ignore-once ()
:report "Ignore max depth this one time."))))