#!/usr/local/bin/hy (def *version* "0.0.2") (require hy.contrib.anaphoric) (import eyeD3 os re sys getopt string [collections [defaultdict]] [django.utils.encoding [smart_str]]) ; 0: Short opt, 1: long opt, 2: takes argument, 3: help text (def optlist [["g" "genre" true "Set the genre"] ["a" "album" true "Set the album"] ["r" "artist" true "Set the artist"] ["n" "usedir" false "Use the directory as the album name, even if it's set in ID3"] ["t" "usefilename" false "Use the filename as the title, even if it's set in ID3"] ["h" "help" false "This help message"] ["v" "version" false "Version information"]]) (defn print-version [] (print (.format "mp_suggest (hy version {})" *version*)) (print "Copyright (c) 2008, 2014 Elf M. Sternberg ") (sys.exit)) (defn print-help [] (print "Usage:") (ap-each optlist (print (.format " -{} --{} {}" (get it 0) (get it 1) (get it 3)))) (sys.exit)) ; Given a set of command-line arguments, compare that to a mapped ; version of the optlist and return a canonicalized dictionary of all ; the arguments that have been set. For example "-g" and "--genre" ; will both be mapped to "genre". (defn make-opt-assoc [prefix pos] (fn [acc it] (assoc acc (+ prefix (get it pos)) (get it 1)) acc)) (defn make-options-rationalizer [optlist] (let [ [short-opt-assoc (make-opt-assoc "-" 0)] [long-opt-assoc (make-opt-assoc "--" 1)] [fullset (ap-reduce (-> (short-opt-assoc acc it) (long-opt-assoc it)) optlist {})]] (fn [acc it] (do (assoc acc (get fullset (get it 0)) (get it 1)) acc)))) ; Auto-capitalize "found" entries like album name and title. Will not ; affect manually set entries. (defn sfix [s] (let [[seq (.split (.strip s))]] (smart_str (string.join (ap-map (.capitalize it) seq) " ")))) ; A long list of substitutions intended to turn a filename into a ; human-readable strategy. This operation is the result of weeks ; of experimentation. Doubt it at your peril! :-) (defn title-strategy [orig] (->> (.strip orig) (.sub re "\.[Mm][Pp]3$" "") (.sub re "_" " ") (.strip) (.sub re "^.* - " "") (.sub re "^[\d\. ]+" "") (.sub re ".* \d{2} " "") (.sub re "^\W+" "") (sfix))) ; For removing subgenre parentheses. This is why there's the -g ; option. Parenthetical sub-genre "commentary" are a pre ID3v2 ; abomination. (defn clean-paren [s] (if (not (= (.find s "(") -1)) (.sub re "\(.*?\)" "" s) s)) ; My FAT-32 based file store via Samba isn't happy with unicode, so ; this is here. I had the weirdest time with non-unicode album names, ; especially when trying to load them onto my old iPod classic. (defn is-ascii [s] (= (.decode (.encode s "ascii" "ignore") "ascii") s)) (defn ascii-or-nothing [s] (if (is-ascii s) s "")) ; Assuming the directory name looked like "Artist - Album", return the ; two names separately. If only one name is here, assume a compilation ; or mixtape album and default to "VA" (Various Artists). (defn artist-album [] (let [[aa (-> (.getcwd os) (.split "/") (get -1) (.split " - "))]] (if (= (len aa) 1) (, "VA" (sfix (get aa 0))) (, (sfix (.strip (get aa 0))) (sfix (.strip (get aa 1))))))) ; Priorities: ; ; Artist: ; (1) Command Line ; (2) Command Line Force-Use-Dir ; (3) In Existing Tag ; (4) Found Dir ; ; Album: ; (1) Command line ; (2) Command Line Force-Use-Dir ; (3) Found-Likely ; (4) Found Dir ; ; Genre: ; (1) Command Line ; (2) Found-Likely ; ; Title: ; (1) Command Line Force-Use-Filename ; (2) In Existing Tag ; (3) Derived From Filename ; ; The "found-likely" algorithm is straightforward: for a given ; directory, find the album name or genre most commonly expressed in ; the ID3 tags, and assign that to all the ID3 tags in the directory. ; This will really mess you up if you accidentally have two albums in ; the same directory; use with caution. (defn make-artist-deriver [opts found likely] (cond [(.has_key opts "artist") (fn [tag file] (get opts "artist"))] [(.has_key opts "usedir") (fn [tag file] found)] [true (fn [tag file] (or tag (sfix found) (sfix likely)))])) (defn make-album-deriver [opts found likely] (cond [(.has_key opts "album") (fn [tag file] (get opts "album"))] [(.has_key opts "usedir") (fn [tag file] found)] [true (fn [tag file] (or (ascii-or-nothing likely) (sfix found)))])) (defn make-genre-deriver [opts found likely] (cond [(.has_key opts "genre") (fn [tag file] (get opts "genre"))] [true (fn [tag file] likely)])) (defn make-title-deriver [opts found likely] (cond [(.has_key opts "usefilename") (fn [tag file] (title-strategy file))] [true (fn [tag file] (or tag (title-strategy file)))])) ; Given a list of mp3s, derive the list of ID3 tags. Obviously, ; filesystem access is a point of failure, but this is mostly ; reliable. (defn fetch-tags [filenames] (defn fetch-tag [pos filename] (try (let [[tag (.Tag eyeD3)]] (tag.link filename) (, (+ pos 1) (str (.getArtist tag)) (str (.getAlbum tag)) (str (.getGenre tag)) (str (.getTitle tag)) filename)) (catch [err Exception] (, filename "" "" "" "" 1)))) (ap-map (apply fetch-tag it) filenames)) (defn derive-tags [mp3s artist-deriver album-deriver genre-deriver title-deriver] (defn derive-tag [mp3] (let [[file (get mp3 5)]] (, (get mp3 0) (artist-deriver (get mp3 1) file) (album-deriver (get mp3 2) file) (genre-deriver (get mp3 3) file) (title-deriver (get mp3 4) file) (get mp3 5)))) (ap-map (derive-tag it) mp3s)) ; For all the songs, analyze a consist entry (usually genre and album ; names), and return the one with the most votes. (defn find-likely [l] (let [[cts (->> (map (fn [i] (, (get i 1) (get i 0))) (.items (ap-reduce (do (assoc acc it (+ 1 (get acc it))) acc) l (defaultdict int)))) (sorted) (reversed) (list))]] (if (zero? (len cts)) "" (get (get cts 0) 1)))) (defn tap [a] (print a) a) (defn suggest [opts] (let [[(, local-artist local-album) (artist-album)] [mp3s (->> (os.listdir ".") (ap-filter (and (> (len it) 4) (= (slice (.lower it) -4) ".mp3"))) (sorted) (enumerate) (fetch-tags) (list) )] [likely-genre (sfix (clean-paren (find-likely (map (fn [m] (get m 3)) mp3s))))] [likely-album (find-likely (map (fn [m] (get m 2)) mp3s))] [likely-artist (find-likely (map (fn [m] (get m 1)) mp3s))] [artist-deriver (make-artist-deriver opts local-artist likely-artist)] [album-deriver (make-album-deriver opts local-album likely-album)] [genre-deriver (make-genre-deriver opts "" likely-genre)] [title-deriver (make-title-deriver opts "" "")] [newmp3s (derive-tags mp3s artist-deriver album-deriver genre-deriver title-deriver)] [format-string (string.join ["id3v2 -T \"{}\"" "--artist \"{}\"" "--album \"{}\"" "--genre \"{}\"" "--song \"{}\"" "\"{}\""] " ")] [finalizer (fn (seq) (+ [format-string] (list (map (fn [i] (get seq i)) (xrange 0 6)))))]] (ap-each newmp3s (print (apply .format (finalizer it)))))) (defmain [&rest args] (try (let [[optstringsshort (string.join (ap-map (+ (. it [0]) (cond [(. it [2]) ":"] [true ""])) optlist) "")] [optstringslong (list (ap-map (+ (. it [1]) (cond [(. it [2]) "="] [true ""])) optlist))] [(, opt arg) (getopt.getopt (slice args 1) optstringsshort optstringslong)] [rationalize-options (make-options-rationalizer optlist)] [options (ap-reduce (rationalize-options acc it) opt {})]] (cond [(.has_key options "help") (print-help)] [(.has_key options "version") (print-version)] [true (suggest options)])) (catch [err getopt.GetoptError] (print (str err)) (print-help))))