#lang racket/base (require racket/cmdline racket/file racket/list racket/path "private/manager-ini.rkt" "private/flac2opus-state.rkt" "private/fingerprint.rkt" "private/hash.rkt" "private/log.rkt" "private/mail.rkt" "private/opus-convert-place.rkt" "private/report.rkt" "private/util.rkt") (provide manage-flac2opus-tree summary->lines) (define manager-admin-relpaths '(".music-info.db" ".flac-48khz-manager.ini" ".flac2opus-manager.log")) (define (quick-file-info path) (list (cons 'size (file-size path)) (cons 'mtime (file-or-directory-modify-seconds path)))) (define (manager-admin-relpath? relpath) (member relpath manager-admin-relpaths)) (define (source-file-signature path ini) (define mode (string-downcase (ini-ref/string ini 'manager 'change-detection "flac-taglib"))) (define hash-algorithm (string-downcase (ini-ref/string ini 'manager 'hash-algorithm "sha256"))) (cond [(member mode '("hash" "sha256" "full-hash" "full-sha256")) (string-append "file:" hash-algorithm ":" (file-digest path hash-algorithm))] [(flac-path? path) (cond [(member mode '("flac" "flac-streaminfo")) (string-append "flac-streaminfo:" (flac-streaminfo-fingerprint path))] [(or (member mode '("flac-taglib" "taglib+flac" "taglib-flac")) (not mode)) (string-append "flac-taglib:" (flac-taglib-fingerprint path))] [else (format "mtime-size:~a:~a" (file-size path) (file-or-directory-modify-seconds path))])] [else (format "mtime-size:~a:~a" (file-size path) (file-or-directory-modify-seconds path))])) (define (state-target-relpath old) (define v (and old (alist-ref/default old 'target-relpath #f))) (and v (normalize-relpath-string v))) (define (same-quick-state? old path target-path target-relpath kbps) (and old (file-exists? target-path) (equal? (alist-ref/default old 'size #f) (file-size path)) (equal? (alist-ref/default old 'mtime #f) (file-or-directory-modify-seconds path)) (equal? (state-target-relpath old) target-relpath) (equal? (alist-ref/default old 'kbps #f) kbps) (not (equal? (alist-ref/default old 'status #f) 'error)))) (define (same-signature-state? old signature target-path target-relpath kbps) (and old (file-exists? target-path) (equal? (alist-ref/default old 'signature #f) signature) (equal? (state-target-relpath old) target-relpath) (equal? (alist-ref/default old 'kbps #f) kbps) (not (equal? (alist-ref/default old 'status #f) 'error)))) (define (source-relpath-string base-dir path) (normalized-relpath-string base-dir path)) (define (flac-extension-relpath? relpath) (let-values ([(base name dir?) (split-path (string->path relpath))]) (and (path? name) (let ([ext (path-get-extension name)]) (and ext (string-ci=? (bytes->string/utf-8 ext) ".flac")))))) (define (target-relpath-for relpath) (if (flac-extension-relpath? relpath) (normalize-relpath-string (path->string (replace-path-extension (string->path relpath) #".opus"))) relpath)) (define (target-path-for target-dir target-relpath) (build-path target-dir (normalized-relpath->path target-relpath))) (define (delete-file/quiet path) (with-handlers ([exn:fail? (lambda (_) #f)]) (and (file-exists? path) (delete-file path) #t))) (define (delete-old-target-if-needed! target-dir old new-target-relpath) (define old-target-relpath (state-target-relpath old)) (when (and old-target-relpath (not (equal? old-target-relpath new-target-relpath))) (define old-target (target-path-for target-dir old-target-relpath)) (when (delete-file/quiet old-target) (info-alm "removed obsolete target: ~a" old-target-relpath)))) (define (copy-file/preserve-mtime! source target) (ensure-parent-directory! target) (copy-file source target #t) (file-or-directory-modify-seconds target (file-or-directory-modify-seconds source)) (void)) (define (state-info source target-relpath signature status kbps extra) (append (quick-file-info source) (list (cons 'signature signature) (cons 'status status) (cons 'target-relpath target-relpath) (cons 'kbps kbps)) extra)) (define (process-one-source-file ks ini target-dir path relpath target-relpath kbps convert-proc summary errors) (define target-path (target-path-for target-dir target-relpath)) (define old (flac2opus-state-get-file ks relpath #f)) (cond [(same-quick-state? old path target-path target-relpath kbps) (info-alm "unchanged: ~a" relpath) (values (summary-inc (summary-inc summary 'seen) 'unchanged) errors)] [else (define summary1 (summary-inc (summary-inc summary 'seen) 'processed)) (define summary2 (if old (summary-inc summary1 'changed) (summary-inc summary1 'new))) (with-handlers ([exn:fail? (lambda (e) (err-alm "error for ~a: ~a" relpath (exn-message e)) (flac2opus-state-set-file! ks relpath (append (quick-file-info path) (list (cons 'status 'error) (cons 'target-relpath target-relpath) (cons 'kbps kbps) (cons 'message (exn-message e))))) (values (summary-inc summary2 'errors) (cons (list (cons 'file relpath) (cons 'message (exn-message e))) errors)))]) (define signature (source-file-signature path ini)) (cond [(same-signature-state? old signature target-path target-relpath kbps) (info-alm "unchanged fingerprint: ~a" relpath) (flac2opus-state-set-file! ks relpath (state-info path target-relpath signature 'unchanged kbps '())) (values (summary-inc summary2 'unchanged) errors)] [(ini-ref/bool ini 'manager 'dry-run #f) (warn-alm "dry-run: would mirror ~a -> ~a" relpath target-relpath) (flac2opus-state-set-file! ks relpath (state-info path target-relpath signature 'dry-run kbps '())) (values (summary-inc (summary-inc summary2 'skipped) 'dry-run) errors)] [(flac-path? path) (info-alm "converting ~a -> ~a at ~a kbps" relpath target-relpath kbps) (delete-old-target-if-needed! target-dir old target-relpath) (define result (convert-proc path target-path kbps)) (flac2opus-state-set-file! ks relpath (state-info path target-relpath signature 'converted kbps (list (cons 'encoder-result result)))) (values (summary-inc summary2 'converted) errors)] [else (info-alm "copying ~a -> ~a" relpath target-relpath) (delete-old-target-if-needed! target-dir old target-relpath) (copy-file/preserve-mtime! path target-path) (flac2opus-state-set-file! ks relpath (state-info path target-relpath signature 'copied kbps '())) (values (summary-inc summary2 'copied) errors)]))])) (define (drop-removed! ks target-dir current-relpaths summary) (define current (for/hash ([r (in-list current-relpaths)]) (values r #t))) (for/fold ([s summary]) ([old-rel (in-list (flac2opus-state-known-relpaths ks))]) (if (hash-ref current old-rel #f) s (let* ([old (flac2opus-state-get-file ks old-rel #f)] [target-relpath (state-target-relpath old)]) (when target-relpath (define target-path (target-path-for target-dir target-relpath)) (when (delete-file/quiet target-path) (info-alm "removed target for deleted source: ~a" target-relpath))) (info-alm "removed from flac2opus state: ~a" old-rel) (flac2opus-state-drop-file! ks old-rel) (summary-inc s 'removed))))) (define (find-source-files source-dir) (filter (lambda (p) (not (manager-admin-relpath? (source-relpath-string source-dir p)))) (directory-file-paths source-dir))) (define (manage-flac2opus-tree source-directory target-directory #:kbps [kbps 224] #:convert-proc [convert-proc convert-flac-to-opus]) (unless (and (integer? kbps) (positive? kbps)) (raise-argument-error 'manage-flac2opus-tree "positive integer kbps" kbps)) (define source-dir (filesystem-path source-directory)) (define target-dir (filesystem-path target-directory)) (unless (directory-exists? source-dir) (raise-argument-error 'manage-flac2opus-tree "existing source directory" source-directory)) (make-directory* target-dir) (define ini-file (manager-ini-file source-dir)) (define state-file (manager-state-file source-dir)) (define ini (load-manager-ini source-dir)) (setup-logging! (ini-ref/path ini source-dir 'opus-manager 'log-file ".flac2opus-manager.log") (ini-ref/bool ini 'manager 'display-log #t)) (info-alm "flac2opus source directory: ~a" source-dir) (info-alm "flac2opus target directory: ~a" target-dir) (info-alm "state file: ~a" state-file) (info-alm "ini file: ~a" ini-file) (info-alm "change detection: ~a" (ini-ref/string ini 'manager 'change-detection "flac-taglib")) (info-alm "opus bitrate: ~a kbps" kbps) (define ks (open-flac2opus-state state-file)) (define files (find-source-files source-dir)) (define relpaths (map (lambda (p) (source-relpath-string source-dir p)) files)) (define target-relpaths (map target-relpath-for relpaths)) (define summary0 (drop-removed! ks target-dir relpaths (make-empty-summary))) (define-values (summary errors) (for/fold ([summary summary0] [errors '()]) ([p (in-list files)] [relpath (in-list relpaths)] [target-relpath (in-list target-relpaths)]) (process-one-source-file ks ini target-dir p relpath target-relpath kbps convert-proc summary errors))) (define errors* (reverse errors)) (with-handlers ([exn:fail? (lambda (e) (err-alm "mail report failed: ~a" (exn-message e)) (void))]) (maybe-send-report-mail ini summary errors* #:manager-name "FLAC to Opus manager" #:result-label "converted/copied")) (for ([line (in-list (summary->lines summary))]) (info-alm "summary: ~a" line)) summary) (module+ main (define kbps 224) (define source-dir #f) (define target-dir #f) (command-line #:program "flac2opus-manager.rkt" #:once-each [("--kbps") k "Opus bitrate in kbps; default 224" (define n (string->number k)) (unless (and (integer? n) (positive? n)) (raise-argument-error 'flac2opus-manager "positive integer kbps" k)) (set! kbps n)] #:args (source-directory target-directory) (set! source-dir source-directory) (set! target-dir target-directory)) (define summary (manage-flac2opus-tree source-dir target-dir #:kbps kbps)) (for ([line (in-list (summary->lines summary))]) (displayln line)))