Initial import
This commit is contained in:
@@ -1,3 +1,22 @@
|
|||||||
# audio-library-manager
|
# audio-library-manager
|
||||||
|
|
||||||
If you have a directory with, e.g. /music with a library containing flac music tracks, this will resample the files to at most 48.000Hz (get rid of the 96Khz/192Khz files that one will never hear the difference to 48Khz). Also there's a flac -> opus conversion path (to e.g. /music-opus) to make a portable library. It will keep the information about the files in /music/.music-info.db.
|
First setup for `flac-48khz-manager.rkt`.
|
||||||
|
|
||||||
|
The command keeps a FLAC directory tree at a maximum sample rate of 48 kHz.
|
||||||
|
Files above the configured threshold are converted in place through a Racket place.
|
||||||
|
The conversion path uses `racket-audio/audio-encoder` dynamically, so the package can still compile on systems where the native audio libraries are not installed yet.
|
||||||
|
|
||||||
|
Default files below the FLAC root:
|
||||||
|
|
||||||
|
- `.music-info.db`: keystore state database
|
||||||
|
- `.flac-48khz-manager.ini`: configuration
|
||||||
|
- `.flac-48khz-manager.log`: log file
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
racket flac-48khz-manager.rkt /path/to/flac-tree
|
||||||
|
```
|
||||||
|
|
||||||
|
Important configuration keys are created automatically in `.flac-48khz-manager.ini`.
|
||||||
|
SMTP reports are HTML and only summarize counters and errors.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/cmdline
|
||||||
|
racket/file
|
||||||
|
racket/list
|
||||||
|
racket/path
|
||||||
|
"private/audio.rkt"
|
||||||
|
"private/config.rkt"
|
||||||
|
"private/convert-place.rkt"
|
||||||
|
"private/hash.rkt"
|
||||||
|
"private/log.rkt"
|
||||||
|
"private/mail.rkt"
|
||||||
|
"private/report.rkt"
|
||||||
|
"private/scan.rkt"
|
||||||
|
"private/state.rkt"
|
||||||
|
"private/util.rkt")
|
||||||
|
|
||||||
|
(provide manage-flac-tree
|
||||||
|
summary->lines)
|
||||||
|
|
||||||
|
(define (file-info path digest)
|
||||||
|
(list (cons 'digest digest)
|
||||||
|
(cons 'size (file-size path))
|
||||||
|
(cons 'mtime (file-or-directory-modify-seconds path))))
|
||||||
|
|
||||||
|
(define (same-file-state? old digest)
|
||||||
|
(and old
|
||||||
|
(equal? (alist-ref/default old 'digest #f) digest)
|
||||||
|
(not (equal? (alist-ref/default old 'status #f) 'error))))
|
||||||
|
|
||||||
|
(define (process-one-file ks config path relpath inspect-flac-proc convert-proc summary errors)
|
||||||
|
(define digest (file-digest path (manager-config-hash-algorithm config)))
|
||||||
|
(define old (state-get-file ks relpath #f))
|
||||||
|
(cond [(same-file-state? old digest)
|
||||||
|
(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))
|
||||||
|
(state-set-file! ks relpath
|
||||||
|
(append (file-info path digest)
|
||||||
|
(list (cons 'status 'error)
|
||||||
|
(cons 'message (exn-message e)))))
|
||||||
|
(values (summary-inc summary2 'errors)
|
||||||
|
(cons (list (cons 'file relpath)
|
||||||
|
(cons 'message (exn-message e)))
|
||||||
|
errors)))])
|
||||||
|
(define sample-rate (inspect-flac-proc path))
|
||||||
|
(cond [(> sample-rate (manager-config-max-sample-rate config))
|
||||||
|
(if (manager-config-dry-run? config)
|
||||||
|
(begin
|
||||||
|
(warn-alm "dry-run: would convert ~a from ~a Hz to ~a Hz" relpath sample-rate (manager-config-max-sample-rate config))
|
||||||
|
(state-set-file! ks relpath
|
||||||
|
(append (file-info path digest)
|
||||||
|
(list (cons 'status 'dry-run)
|
||||||
|
(cons 'sample-rate sample-rate))))
|
||||||
|
(values (summary-inc (summary-inc summary2 'skipped) 'dry-run) errors))
|
||||||
|
(begin
|
||||||
|
(info-alm "converting ~a from ~a Hz to ~a Hz" relpath sample-rate (manager-config-max-sample-rate config))
|
||||||
|
(let* ((result (convert-proc path
|
||||||
|
(manager-config-max-sample-rate config)
|
||||||
|
(manager-config-compression-level config)))
|
||||||
|
(new-digest (file-digest path (manager-config-hash-algorithm config)))
|
||||||
|
(new-sample-rate (inspect-flac-proc path)))
|
||||||
|
(state-set-file! ks relpath
|
||||||
|
(append (file-info path new-digest)
|
||||||
|
(list (cons 'status 'converted)
|
||||||
|
(cons 'old-sample-rate sample-rate)
|
||||||
|
(cons 'sample-rate new-sample-rate)
|
||||||
|
(cons 'encoder-result result))))
|
||||||
|
(values (summary-inc summary2 'converted) errors))))]
|
||||||
|
[else
|
||||||
|
(info-alm "ok: ~a (~a Hz)" relpath sample-rate)
|
||||||
|
(state-set-file! ks relpath
|
||||||
|
(append (file-info path digest)
|
||||||
|
(list (cons 'status 'ok)
|
||||||
|
(cons 'sample-rate sample-rate))))
|
||||||
|
(values (summary-inc summary2 'ok) errors)]))]))
|
||||||
|
|
||||||
|
(define (drop-removed! ks current-relpaths summary)
|
||||||
|
(define current (for/hash ([r (in-list current-relpaths)]) (values r #t)))
|
||||||
|
(for/fold ([s summary]) ([old-rel (in-list (state-known-relpaths ks))])
|
||||||
|
(if (hash-ref current old-rel #f)
|
||||||
|
s
|
||||||
|
(begin
|
||||||
|
(info-alm "removed from state: ~a" old-rel)
|
||||||
|
(state-drop-file! ks old-rel)
|
||||||
|
(summary-inc s 'removed)))))
|
||||||
|
|
||||||
|
(define (manage-flac-tree base-directory
|
||||||
|
#:inspect-flac-proc [inspect-flac-proc inspect-flac-sample-rate]
|
||||||
|
#:convert-proc [convert-proc convert-flac-to-target-in-place])
|
||||||
|
(define base-dir (simple-form-path base-directory))
|
||||||
|
(unless (directory-exists? base-dir)
|
||||||
|
(raise-argument-error 'manage-flac-tree "existing directory" base-directory))
|
||||||
|
(define config (load-manager-config base-dir))
|
||||||
|
(setup-logging! (manager-config-log-file config) (manager-config-display-log? config))
|
||||||
|
(info-alm "base directory: ~a" base-dir)
|
||||||
|
(info-alm "state file: ~a" (manager-config-state-file config))
|
||||||
|
(info-alm "ini file: ~a" (manager-config-ini-file config))
|
||||||
|
(define ks (open-manager-state (manager-config-state-file config)))
|
||||||
|
(define files (find-flac-files base-dir))
|
||||||
|
(define relpaths (map (lambda (p) (relpath-string base-dir p)) files))
|
||||||
|
(define summary0 (drop-removed! ks relpaths (make-empty-summary)))
|
||||||
|
(define-values (summary errors)
|
||||||
|
(for/fold ([summary summary0] [errors '()]) ([p (in-list files)] [rel (in-list relpaths)])
|
||||||
|
(process-one-file ks config p rel inspect-flac-proc 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 config summary errors*))
|
||||||
|
(for ([line (in-list (summary->lines summary))]) (info-alm "summary: ~a" line))
|
||||||
|
summary)
|
||||||
|
|
||||||
|
(module+ main
|
||||||
|
(define base-dir #f)
|
||||||
|
(command-line
|
||||||
|
#:program "flac-48khz-manager.rkt"
|
||||||
|
#:args (base-directory)
|
||||||
|
(set! base-dir base-directory))
|
||||||
|
(define summary (manage-flac-tree base-dir))
|
||||||
|
(for ([line (in-list (summary->lines summary))])
|
||||||
|
(displayln line)))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#lang info
|
||||||
|
|
||||||
|
(define collection "audio-library-manager")
|
||||||
|
(define deps '("base"
|
||||||
|
"db-lib"
|
||||||
|
"openssl"
|
||||||
|
"keystore"
|
||||||
|
"simple-ini"
|
||||||
|
"simple-log"
|
||||||
|
"smtp"
|
||||||
|
"racket-sprintf"))
|
||||||
|
(define scribblings '(("scribblings/audio-library-manager.scrbl" ())))
|
||||||
|
(define build-deps '("rackunit-lib" "scribble-lib" "racket-doc"))
|
||||||
|
(define pkg-desc "Audio library maintenance tools for FLAC and Opus trees")
|
||||||
|
(define version "0.1")
|
||||||
|
(define pkg-authors '(hans-dijkema))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require "flac-48khz-manager.rkt")
|
||||||
|
|
||||||
|
(provide manage-flac-tree
|
||||||
|
summary->lines)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(provide inspect-flac-sample-rate)
|
||||||
|
|
||||||
|
(define (inspect-flac-sample-rate path)
|
||||||
|
(define call-with-id3-tags (dynamic-require 'racket-audio/taglib 'call-with-id3-tags))
|
||||||
|
(define tags-valid? (dynamic-require 'racket-audio/taglib 'tags-valid?))
|
||||||
|
(define tags-sample-rate (dynamic-require 'racket-audio/taglib 'tags-sample-rate))
|
||||||
|
(define sr
|
||||||
|
(call-with-id3-tags path
|
||||||
|
(lambda (tags)
|
||||||
|
(and (tags-valid? tags) (tags-sample-rate tags)))
|
||||||
|
#:mode 'read))
|
||||||
|
(unless (and (integer? sr) (positive? sr))
|
||||||
|
(error 'inspect-flac-sample-rate "cannot determine sample rate for ~a" path))
|
||||||
|
sr)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
racket/path
|
||||||
|
simple-ini
|
||||||
|
"util.rkt")
|
||||||
|
|
||||||
|
(provide manager-config?
|
||||||
|
manager-config-base-dir
|
||||||
|
manager-config-ini-file
|
||||||
|
manager-config-state-file
|
||||||
|
manager-config-log-file
|
||||||
|
manager-config-max-sample-rate
|
||||||
|
manager-config-hash-algorithm
|
||||||
|
manager-config-dry-run?
|
||||||
|
manager-config-display-log?
|
||||||
|
manager-config-compression-level
|
||||||
|
manager-config-mail-enabled?
|
||||||
|
manager-config-mail-send-on-success?
|
||||||
|
manager-config-mail-send-on-error?
|
||||||
|
manager-config-mail-host
|
||||||
|
manager-config-mail-port
|
||||||
|
manager-config-mail-tls?
|
||||||
|
manager-config-mail-username
|
||||||
|
manager-config-mail-password
|
||||||
|
manager-config-mail-from
|
||||||
|
manager-config-mail-to
|
||||||
|
manager-config-mail-cc
|
||||||
|
manager-config-mail-bcc
|
||||||
|
manager-config-mail-subject-prefix
|
||||||
|
load-manager-config
|
||||||
|
ensure-default-config!)
|
||||||
|
|
||||||
|
(struct manager-config
|
||||||
|
(base-dir ini-file state-file log-file max-sample-rate hash-algorithm dry-run?
|
||||||
|
display-log? compression-level mail-enabled? mail-send-on-success?
|
||||||
|
mail-send-on-error? mail-host mail-port mail-tls? mail-username
|
||||||
|
mail-password mail-from mail-to mail-cc mail-bcc mail-subject-prefix)
|
||||||
|
#:transparent)
|
||||||
|
|
||||||
|
(define (default-log-file base-dir)
|
||||||
|
(build-path base-dir ".flac-48khz-manager.log"))
|
||||||
|
|
||||||
|
(define (default-ini base-dir)
|
||||||
|
(build-path base-dir ".flac-48khz-manager.ini"))
|
||||||
|
|
||||||
|
(define (default-state base-dir)
|
||||||
|
(build-path base-dir ".music-info.db"))
|
||||||
|
|
||||||
|
(define (ensure-default-config! ini-file)
|
||||||
|
(unless (file-exists? ini-file)
|
||||||
|
(define ini (make-ini))
|
||||||
|
(ini-set! ini 'manager 'max-sample-rate 48000)
|
||||||
|
(ini-set! ini 'manager 'hash-algorithm "sha256")
|
||||||
|
(ini-set! ini 'manager 'dry-run #f)
|
||||||
|
(ini-set! ini 'manager 'display-log #t)
|
||||||
|
(ini-set! ini 'manager 'log-file ".flac-48khz-manager.log")
|
||||||
|
(ini-set! ini 'manager 'compression-level 5)
|
||||||
|
(ini-set! ini 'mail 'enabled #f)
|
||||||
|
(ini-set! ini 'mail 'send-on-success #f)
|
||||||
|
(ini-set! ini 'mail 'send-on-error #t)
|
||||||
|
(ini-set! ini 'mail 'host "")
|
||||||
|
(ini-set! ini 'mail 'port 25)
|
||||||
|
(ini-set! ini 'mail 'tls #f)
|
||||||
|
(ini-set! ini 'mail 'username "")
|
||||||
|
(ini-set! ini 'mail 'password "")
|
||||||
|
(ini-set! ini 'mail 'from "")
|
||||||
|
(ini-set! ini 'mail 'to "")
|
||||||
|
(ini-set! ini 'mail 'cc "")
|
||||||
|
(ini-set! ini 'mail 'bcc "")
|
||||||
|
(ini-set! ini 'mail 'subject-prefix "[flac-48khz-manager]")
|
||||||
|
(ini->file ini ini-file)))
|
||||||
|
|
||||||
|
(define (resolve-log-file base-dir v)
|
||||||
|
(define p (string-value v ".flac-48khz-manager.log"))
|
||||||
|
(cond [(path-string? p)
|
||||||
|
(define bp (string->path p))
|
||||||
|
(if (absolute-path? bp) bp (build-path base-dir bp))]
|
||||||
|
[else (default-log-file base-dir)]))
|
||||||
|
|
||||||
|
(define (load-manager-config base-dir*)
|
||||||
|
(define base-dir (simple-form-path base-dir*))
|
||||||
|
(define ini-file (default-ini base-dir))
|
||||||
|
(ensure-default-config! ini-file)
|
||||||
|
(define ini (file->ini ini-file))
|
||||||
|
(define log-file (resolve-log-file base-dir (ini-get ini 'manager 'log-file ".flac-48khz-manager.log")))
|
||||||
|
(manager-config base-dir ini-file (default-state base-dir) log-file
|
||||||
|
(int-value (ini-get ini 'manager 'max-sample-rate 48000) 48000)
|
||||||
|
(string-downcase (string-value (ini-get ini 'manager 'hash-algorithm "sha256") "sha256"))
|
||||||
|
(bool-value (ini-get ini 'manager 'dry-run #f) #f)
|
||||||
|
(bool-value (ini-get ini 'manager 'display-log #t) #t)
|
||||||
|
(int-value (ini-get ini 'manager 'compression-level 5) 5)
|
||||||
|
(bool-value (ini-get ini 'mail 'enabled #f) #f)
|
||||||
|
(bool-value (ini-get ini 'mail 'send-on-success #f) #f)
|
||||||
|
(bool-value (ini-get ini 'mail 'send-on-error #t) #t)
|
||||||
|
(string-value (ini-get ini 'mail 'host "") "")
|
||||||
|
(int-value (ini-get ini 'mail 'port 25) 25)
|
||||||
|
(bool-value (ini-get ini 'mail 'tls #f) #f)
|
||||||
|
(string-value (ini-get ini 'mail 'username "") "")
|
||||||
|
(string-value (ini-get ini 'mail 'password "") "")
|
||||||
|
(string-value (ini-get ini 'mail 'from "") "")
|
||||||
|
(split-addresses (ini-get ini 'mail 'to ""))
|
||||||
|
(split-addresses (ini-get ini 'mail 'cc ""))
|
||||||
|
(split-addresses (ini-get ini 'mail 'bcc ""))
|
||||||
|
(string-value (ini-get ini 'mail 'subject-prefix "[flac-48khz-manager]") "[flac-48khz-manager]")))
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
racket/path
|
||||||
|
racket/place
|
||||||
|
"util.rkt")
|
||||||
|
|
||||||
|
(provide convert-flac-to-target-in-place)
|
||||||
|
|
||||||
|
(define (temp-output-path input-path)
|
||||||
|
(define-values (base name dir?) (split-path input-path))
|
||||||
|
(define name-str (path->string name))
|
||||||
|
(build-path base (format ".~a.tmp-~a.flac" name-str (current-inexact-milliseconds))))
|
||||||
|
|
||||||
|
(define (settings->alist max-sample-rate compression-level)
|
||||||
|
(list (cons 'target-sample-rate max-sample-rate)
|
||||||
|
(cons 'compression-level compression-level)))
|
||||||
|
|
||||||
|
(define (convert-flac-to-target-in-place input-path max-sample-rate compression-level)
|
||||||
|
(define tmp-path (temp-output-path input-path))
|
||||||
|
(define worker
|
||||||
|
(place ch
|
||||||
|
(define msg (place-channel-get ch))
|
||||||
|
(define in-file (list-ref msg 0))
|
||||||
|
(define out-file (list-ref msg 1))
|
||||||
|
(define settings (list-ref msg 2))
|
||||||
|
(with-handlers ([exn:fail?
|
||||||
|
(lambda (e)
|
||||||
|
(place-channel-put ch (list 'error (exn-message e))))])
|
||||||
|
(define audio-encode (dynamic-require 'racket-audio/audio-encoder 'audio-encode))
|
||||||
|
(define result (audio-encode in-file out-file (make-immutable-hash settings)
|
||||||
|
#:encoder 'flac
|
||||||
|
#:copy-tags? #t))
|
||||||
|
(place-channel-put ch (list 'ok result)))))
|
||||||
|
(place-channel-put worker (list (path->string input-path)
|
||||||
|
(path->string tmp-path)
|
||||||
|
(settings->alist max-sample-rate compression-level)))
|
||||||
|
(define response (place-channel-get worker))
|
||||||
|
(cond [(and (pair? response) (eq? (car response) 'ok))
|
||||||
|
(rename-file-or-directory tmp-path input-path #t)
|
||||||
|
(cadr response)]
|
||||||
|
[else
|
||||||
|
(when (file-exists? tmp-path) (delete-file tmp-path))
|
||||||
|
(error 'convert-flac-to-target-in-place "conversion failed for ~a: ~a"
|
||||||
|
input-path
|
||||||
|
(if (and (pair? response) (pair? (cdr response))) (cadr response) response))]))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require openssl/sha1
|
||||||
|
openssl/md5)
|
||||||
|
|
||||||
|
(provide file-digest)
|
||||||
|
|
||||||
|
(define (digest->string v)
|
||||||
|
(cond [(bytes? v) (bytes->hex-string v)]
|
||||||
|
[(string? v) v]
|
||||||
|
[else (format "~a" v)]))
|
||||||
|
|
||||||
|
(define (file-digest path [algorithm "sha256"])
|
||||||
|
(define alg (string-downcase (format "~a" algorithm)))
|
||||||
|
(call-with-input-file path
|
||||||
|
(lambda (in)
|
||||||
|
(cond [(member alg '("sha256" "sha-256" "sha2")) (digest->string (sha256-bytes in))]
|
||||||
|
[(member alg '("md5")) (digest->string (md5 in))]
|
||||||
|
[else (error 'file-digest "unsupported digest algorithm: ~a" algorithm)]))
|
||||||
|
#:mode 'binary))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
simple-log)
|
||||||
|
|
||||||
|
(provide setup-logging!
|
||||||
|
dbg-alm
|
||||||
|
info-alm
|
||||||
|
warn-alm
|
||||||
|
err-alm
|
||||||
|
fatal-alm
|
||||||
|
sync-log-alm)
|
||||||
|
|
||||||
|
(sl-def-log audio-library-manager alm)
|
||||||
|
|
||||||
|
(define (setup-logging! log-file display-log?)
|
||||||
|
(make-directory* (let-values ([(base name dir?) (split-path log-file)]) base))
|
||||||
|
(if display-log?
|
||||||
|
(sl-log-to-file&display log-file)
|
||||||
|
(sl-log-to-file log-file))
|
||||||
|
(sl-set-log-level 'debug)
|
||||||
|
(info-alm "logging initialized: ~a" log-file))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require smtp
|
||||||
|
"config.rkt"
|
||||||
|
"report.rkt")
|
||||||
|
|
||||||
|
(provide maybe-send-report-mail)
|
||||||
|
|
||||||
|
(define (maybe-send-report-mail config summary errors)
|
||||||
|
(define has-errors? (positive? (summary-ref summary 'errors 0)))
|
||||||
|
(define should-send?
|
||||||
|
(and (manager-config-mail-enabled? config)
|
||||||
|
(not (null? (manager-config-mail-to config)))
|
||||||
|
(or (and has-errors? (manager-config-mail-send-on-error? config))
|
||||||
|
(and (not has-errors?) (manager-config-mail-send-on-success? config)))))
|
||||||
|
(when should-send?
|
||||||
|
(define subject (format "~a FLAC 48 kHz manager: ~a error(s), ~a converted"
|
||||||
|
(manager-config-mail-subject-prefix config)
|
||||||
|
(summary-ref summary 'errors 0)
|
||||||
|
(summary-ref summary 'converted 0)))
|
||||||
|
(define body (html-report subject summary errors))
|
||||||
|
(define mail (make-mail subject body
|
||||||
|
#:from (manager-config-mail-from config)
|
||||||
|
#:to (manager-config-mail-to config)
|
||||||
|
#:cc (manager-config-mail-cc config)
|
||||||
|
#:bcc (manager-config-mail-bcc config)
|
||||||
|
#:body-content-type "text/html"))
|
||||||
|
(send-smtp-mail mail
|
||||||
|
#:host (manager-config-mail-host config)
|
||||||
|
#:port (manager-config-mail-port config)
|
||||||
|
#:tls-encode (manager-config-mail-tls? config)
|
||||||
|
#:username (manager-config-mail-username config)
|
||||||
|
#:password (manager-config-mail-password config))))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/list
|
||||||
|
racket/string
|
||||||
|
"util.rkt")
|
||||||
|
|
||||||
|
(provide make-empty-summary
|
||||||
|
summary-inc
|
||||||
|
summary-set
|
||||||
|
summary-ref
|
||||||
|
summary->lines
|
||||||
|
html-report)
|
||||||
|
|
||||||
|
(define summary-keys '(seen processed new changed unchanged ok converted removed skipped errors dry-run))
|
||||||
|
|
||||||
|
(define (make-empty-summary)
|
||||||
|
(append (for/list ([k (in-list summary-keys)]) (cons k 0))
|
||||||
|
(list (cons 'error-list '()))))
|
||||||
|
|
||||||
|
(define (summary-ref summary key [default 0])
|
||||||
|
(alist-ref/default summary key default))
|
||||||
|
|
||||||
|
(define (summary-set summary key value)
|
||||||
|
(alist-set summary key value))
|
||||||
|
|
||||||
|
(define (summary-inc summary key [amount 1])
|
||||||
|
(summary-set summary key (+ (summary-ref summary key 0) amount)))
|
||||||
|
|
||||||
|
(define (summary->lines summary)
|
||||||
|
(for/list ([k (in-list summary-keys)])
|
||||||
|
(format "~a: ~a" k (summary-ref summary k 0))))
|
||||||
|
|
||||||
|
(define (html-escape s)
|
||||||
|
(define x (format "~a" s))
|
||||||
|
(define y (regexp-replace* #rx"&" x "&"))
|
||||||
|
(define z (regexp-replace* #rx"<" y "<"))
|
||||||
|
(regexp-replace* #rx">" z ">"))
|
||||||
|
|
||||||
|
(define (html-report title summary errors)
|
||||||
|
(define rows
|
||||||
|
(apply string-append
|
||||||
|
(for/list ([k (in-list summary-keys)])
|
||||||
|
(format "<tr><th style=\"text-align:left;padding:4px 10px 4px 0\">~a</th><td style=\"text-align:right;padding:4px\">~a</td></tr>"
|
||||||
|
(html-escape k) (summary-ref summary k 0)))))
|
||||||
|
(define error-html
|
||||||
|
(if (null? errors)
|
||||||
|
"<p>No errors were reported.</p>"
|
||||||
|
(string-append
|
||||||
|
"<h2>Errors</h2><table border=\"1\" cellspacing=\"0\" cellpadding=\"4\"><tr><th>File</th><th>Error</th></tr>"
|
||||||
|
(apply string-append
|
||||||
|
(for/list ([e (in-list errors)])
|
||||||
|
(format "<tr><td>~a</td><td><pre style=\"white-space:pre-wrap\">~a</pre></td></tr>"
|
||||||
|
(html-escape (alist-ref/default e 'file ""))
|
||||||
|
(html-escape (alist-ref/default e 'message "")))))
|
||||||
|
"</table>")))
|
||||||
|
(format "<!doctype html><html><head><meta charset=\"utf-8\"><title>~a</title></head><body><h1>~a</h1><h2>Summary</h2><table>~a</table>~a</body></html>"
|
||||||
|
(html-escape title) (html-escape title) rows error-html))
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
racket/list
|
||||||
|
"util.rkt")
|
||||||
|
|
||||||
|
(provide find-flac-files)
|
||||||
|
|
||||||
|
(define (find-flac-files base-dir)
|
||||||
|
(sort (find-files flac-path? base-dir)
|
||||||
|
string<?
|
||||||
|
#:key path->string))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/string
|
||||||
|
keystore)
|
||||||
|
|
||||||
|
(provide open-manager-state
|
||||||
|
file-state-key
|
||||||
|
state-get-file
|
||||||
|
state-set-file!
|
||||||
|
state-drop-file!
|
||||||
|
state-known-relpaths)
|
||||||
|
|
||||||
|
(define prefix "flac-48khz:file:")
|
||||||
|
|
||||||
|
(define (open-manager-state state-file)
|
||||||
|
(ks-open state-file))
|
||||||
|
|
||||||
|
(define (file-state-key relpath)
|
||||||
|
(string-append prefix relpath))
|
||||||
|
|
||||||
|
(define (state-get-file ks relpath [default #f])
|
||||||
|
(ks-get ks (file-state-key relpath) default))
|
||||||
|
|
||||||
|
(define (state-set-file! ks relpath value)
|
||||||
|
(ks-set! ks (file-state-key relpath) value))
|
||||||
|
|
||||||
|
(define (state-drop-file! ks relpath)
|
||||||
|
(ks-drop! ks (file-state-key relpath)))
|
||||||
|
|
||||||
|
(define (state-known-relpaths ks)
|
||||||
|
(map (lambda (k)
|
||||||
|
(substring k (string-length prefix)))
|
||||||
|
(ks-keys-glob ks (string-append prefix "*"))))
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require racket/file
|
||||||
|
racket/list
|
||||||
|
racket/path
|
||||||
|
racket/string)
|
||||||
|
|
||||||
|
(provide bool-value
|
||||||
|
int-value
|
||||||
|
string-value
|
||||||
|
split-addresses
|
||||||
|
relpath-string
|
||||||
|
flac-path?
|
||||||
|
ensure-parent-directory!
|
||||||
|
alist-ref/default
|
||||||
|
alist-set)
|
||||||
|
|
||||||
|
(define (bool-value v [default #f])
|
||||||
|
(cond [(boolean? v) v]
|
||||||
|
[(number? v) (not (zero? v))]
|
||||||
|
[(string? v)
|
||||||
|
(define s (string-downcase (string-trim v)))
|
||||||
|
(cond [(member s '("#t" "true" "yes" "y" "1" "on")) #t]
|
||||||
|
[(member s '("#f" "false" "no" "n" "0" "off" "")) #f]
|
||||||
|
[else default])]
|
||||||
|
[else default]))
|
||||||
|
|
||||||
|
(define (int-value v [default 0])
|
||||||
|
(cond [(integer? v) v]
|
||||||
|
[(number? v) (inexact->exact (round v))]
|
||||||
|
[(string? v) (or (string->number (string-trim v)) default)]
|
||||||
|
[else default]))
|
||||||
|
|
||||||
|
(define (string-value v [default ""])
|
||||||
|
(cond [(string? v) v]
|
||||||
|
[(symbol? v) (symbol->string v)]
|
||||||
|
[(number? v) (number->string v)]
|
||||||
|
[(boolean? v) (if v "#t" "#f")]
|
||||||
|
[(not v) default]
|
||||||
|
[else (format "~a" v)]))
|
||||||
|
|
||||||
|
(define (split-addresses v)
|
||||||
|
(define s (string-value v ""))
|
||||||
|
(filter (lambda (x) (not (string=? x "")))
|
||||||
|
(map string-trim (regexp-split #px"[,;]" s))))
|
||||||
|
|
||||||
|
(define (relpath-string base p)
|
||||||
|
(path->string (find-relative-path (simple-form-path base) (simple-form-path p))))
|
||||||
|
|
||||||
|
(define (flac-path? p)
|
||||||
|
(and (file-exists? p)
|
||||||
|
(let-values ([(base name dir?) (split-path p)])
|
||||||
|
(and (path? name)
|
||||||
|
(string-ci=? (bytes->string/utf-8 (or (path-get-extension name) #"")) ".flac")))))
|
||||||
|
|
||||||
|
(define (ensure-parent-directory! p)
|
||||||
|
(define-values (base name dir?) (split-path p))
|
||||||
|
(when (path? base) (make-directory* base)))
|
||||||
|
|
||||||
|
(define (alist-ref/default a k [default #f])
|
||||||
|
(define e (assoc k a))
|
||||||
|
(if e (cdr e) default))
|
||||||
|
|
||||||
|
(define (alist-set a k v)
|
||||||
|
(cons (cons k v) (filter (lambda (e) (not (equal? (car e) k))) a)))
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#lang scribble/manual
|
||||||
|
|
||||||
|
@(require (for-label racket/base
|
||||||
|
(file "../main.rkt")))
|
||||||
|
|
||||||
|
@title{audio-library-manager}
|
||||||
|
@author{Hans Dijkema}
|
||||||
|
|
||||||
|
The @racketmodname[audio-library-manager] package contains command-line tools for
|
||||||
|
maintaining audio library trees.
|
||||||
|
|
||||||
|
@section{FLAC 48 kHz manager}
|
||||||
|
|
||||||
|
The first tool is @filepath{flac-48khz-manager.rkt}. It keeps a FLAC tree at a
|
||||||
|
maximum sample rate of 48 kHz. Files with a higher sample rate are converted in
|
||||||
|
place through a worker place. The conversion path uses
|
||||||
|
@racketmodname[racket-audio/audio-encoder] dynamically, so the manager module can
|
||||||
|
still be compiled on systems where the native audio libraries are not available.
|
||||||
|
|
||||||
|
Run the manager as:
|
||||||
|
|
||||||
|
@verbatim{racket flac-48khz-manager.rkt <base-directory>}
|
||||||
|
|
||||||
|
The manager creates these files below @filepath{<base-directory>}:
|
||||||
|
|
||||||
|
@itemlist[
|
||||||
|
@item{@filepath{.music-info.db}: keystore database with file state.}
|
||||||
|
@item{@filepath{.flac-48khz-manager.ini}: configuration.}
|
||||||
|
@item{@filepath{.flac-48khz-manager.log}: log file.}]
|
||||||
|
|
||||||
|
@section{Configuration}
|
||||||
|
|
||||||
|
The configuration file is created automatically when it does not exist. The main
|
||||||
|
settings are:
|
||||||
|
|
||||||
|
@verbatim{
|
||||||
|
[manager]
|
||||||
|
max-sample-rate=48000
|
||||||
|
hash-algorithm="sha256"
|
||||||
|
dry-run=#f
|
||||||
|
display-log=#t
|
||||||
|
log-file=".flac-48khz-manager.log"
|
||||||
|
compression-level=5
|
||||||
|
|
||||||
|
[mail]
|
||||||
|
enabled=#f
|
||||||
|
send-on-success=#f
|
||||||
|
send-on-error=#t
|
||||||
|
host=""
|
||||||
|
port=25
|
||||||
|
tls=#f
|
||||||
|
username=""
|
||||||
|
password=""
|
||||||
|
from=""
|
||||||
|
to=""
|
||||||
|
cc=""
|
||||||
|
bcc=""
|
||||||
|
subject-prefix="[flac-48khz-manager]"
|
||||||
|
}
|
||||||
|
|
||||||
|
When mail is enabled, the report is sent as HTML. The message contains the
|
||||||
|
summary counters and the error table; it does not dump the full log by default.
|
||||||
|
|
||||||
|
@section{Library API}
|
||||||
|
|
||||||
|
@defproc[(manage-flac-tree
|
||||||
|
[base-directory path-string?]
|
||||||
|
[#:inspect-flac-proc inspect-flac-proc procedure? inspect-flac-sample-rate]
|
||||||
|
[#:convert-proc convert-proc procedure? convert-flac-to-target-in-place])
|
||||||
|
list?]{
|
||||||
|
Scans @racket[base-directory], updates the keystore state, converts FLAC files
|
||||||
|
above the configured maximum sample rate, sends the optional HTML mail report,
|
||||||
|
and returns a summary association list.
|
||||||
|
|
||||||
|
The keyword arguments are intended for tests and dry integration work. In normal
|
||||||
|
use, the default inspector and converter are used.}
|
||||||
|
|
||||||
|
@defproc[(summary->lines [summary list?]) (listof string?)]{
|
||||||
|
Formats the summary association list as display lines.}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require rackunit
|
||||||
|
racket/file
|
||||||
|
racket/path
|
||||||
|
"../flac-48khz-manager.rkt")
|
||||||
|
|
||||||
|
(define tmp (make-temporary-file "alm-test-~a" 'directory))
|
||||||
|
(define hi (build-path tmp "hires.flac"))
|
||||||
|
(define cd (build-path tmp "cd.flac"))
|
||||||
|
(call-with-output-file hi #:exists 'replace (lambda (out) (display "hires" out)))
|
||||||
|
(call-with-output-file cd #:exists 'replace (lambda (out) (display "cd" out)))
|
||||||
|
|
||||||
|
(define convert-count 0)
|
||||||
|
|
||||||
|
(define (mock-inspect p)
|
||||||
|
(define s (file->string p))
|
||||||
|
(cond [(regexp-match? #rx"converted" s) 48000]
|
||||||
|
[(regexp-match? #rx"hires" s) 96000]
|
||||||
|
[else 44100]))
|
||||||
|
|
||||||
|
(define (mock-convert p rate compression)
|
||||||
|
(set! convert-count (add1 convert-count))
|
||||||
|
(call-with-output-file p #:exists 'replace
|
||||||
|
(lambda (out) (fprintf out "converted to ~a compression ~a" rate compression)))
|
||||||
|
(list (cons 'mock #t) (cons 'target-sample-rate rate)))
|
||||||
|
|
||||||
|
(define first-summary
|
||||||
|
(manage-flac-tree tmp #:inspect-flac-proc mock-inspect #:convert-proc mock-convert))
|
||||||
|
(check-equal? (assoc 'seen first-summary) '(seen . 2))
|
||||||
|
(check-equal? (assoc 'new first-summary) '(new . 2))
|
||||||
|
(check-equal? (assoc 'converted first-summary) '(converted . 1))
|
||||||
|
(check-equal? convert-count 1)
|
||||||
|
|
||||||
|
(define second-summary
|
||||||
|
(manage-flac-tree tmp #:inspect-flac-proc mock-inspect #:convert-proc mock-convert))
|
||||||
|
(check-equal? (assoc 'seen second-summary) '(seen . 2))
|
||||||
|
(check-equal? (assoc 'unchanged second-summary) '(unchanged . 2))
|
||||||
|
(check-equal? convert-count 1)
|
||||||
|
|
||||||
|
(delete-file cd)
|
||||||
|
(define third-summary
|
||||||
|
(manage-flac-tree tmp #:inspect-flac-proc mock-inspect #:convert-proc mock-convert))
|
||||||
|
(check-equal? (assoc 'removed third-summary) '(removed . 1))
|
||||||
|
(check-equal? (assoc 'unchanged third-summary) '(unchanged . 1))
|
||||||
|
|
||||||
|
(delete-directory/files tmp)
|
||||||
Reference in New Issue
Block a user