diff --git a/otus-10/project.clj b/otus-10/project.clj index b9a8d94..c7aeebc 100644 --- a/otus-10/project.clj +++ b/otus-10/project.clj @@ -1,10 +1,10 @@ (defproject otus-10 "0.1.0-SNAPSHOT" :description "https://github.com/Clojure-Developer/Clojure-Developer-2023-10" - :dependencies [[org.clojure/clojure "1.11.1"]] + :dependencies [[org.clojure/clojure "1.11.1"] + [org.clojure/tools.cli "1.1.230"]] :repl-options {:init-ns otus-10.core} :main ^:skip-aot otus-10.homework :target-path "target/%s" - :profiles {:dev {} - :uberjar {:aot :all + :profiles {:dev {:aot [otus-10.homework]}, + :uberjar {:aot [otus-10.homework], :jvm-opts ["-Dclojure.compiler.direct-linking=true"]}}) - diff --git a/otus-10/resources/file-12926-ed090b.mp3 b/otus-10/resources/file-12926-ed090b.mp3 new file mode 100644 index 0000000..7420e50 Binary files /dev/null and b/otus-10/resources/file-12926-ed090b.mp3 differ diff --git a/otus-10/resources/sample.mp3 b/otus-10/resources/sample.mp3 new file mode 100644 index 0000000..8b8a0e2 Binary files /dev/null and b/otus-10/resources/sample.mp3 differ diff --git a/otus-10/src/otus_10/README-homework.md b/otus-10/src/otus_10/README-homework.md new file mode 100644 index 0000000..c858765 --- /dev/null +++ b/otus-10/src/otus_10/README-homework.md @@ -0,0 +1,38 @@ +# Домашнее задание + +Получение информации из ID3v2-тегов в mp3-файлах +Цель: + +Попрактиковаться использовать полиморфизма для обобщения кода алгоритма и предоставления возможностей для расширения возможностей. + +Описание/Пошаговая инструкция выполнения домашнего задания: + + Реализовать поиск тега ID3v2 (v2.4), получение размера тега, чтение его фреймов. + + Реализовать с помощью case декодирование текстовых данных (v2.4 поддерживает 4 кодировки) + + Реализовать в виде мультиметода декодирование фреймов: + + TALB — Album + TPE1 — Artist + TIT2 — Title + TYER — Year + TCON — Genre + +Вспомогательные материалы + + https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-structure.html + https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html + https://clojure.org/reference/multimethods + https://clojure.org/reference/protocols + https://aphyr.com/posts/352-clojure-from-the-ground-up-polymorphism + + +Критерии оценки: + + Написан обобщенный код для вычитывания тегов с учётом формата кодирования; + Использован полиморфизм для обработки тегов разных типов и для декодирования текста в разных представлениях; + Написаны тесты; + Отправлен PR с решением на ревью. + + diff --git a/otus-10/src/otus_10/frames.clj b/otus-10/src/otus_10/frames.clj new file mode 100644 index 0000000..f4760bc --- /dev/null +++ b/otus-10/src/otus_10/frames.clj @@ -0,0 +1,89 @@ +(ns otus-10.frames + (:require [otus-10.utils :as u])) + +(defn decode-text + "$00 ISO-8859-1 [ISO-8859-1]. Terminated with $00. + $01 UTF-16 [UTF-16] encoded Unicode [UNICODE] with BOM. All + strings in the same frame SHALL have the same byteorder. + Terminated with $00 00. + $02 UTF-16BE [UTF-16] encoded Unicode [UNICODE] without BOM. + Terminated with $00 00. + $03 UTF-8 [UTF-8] encoded Unicode [UNICODE]. Terminated with $00." + [id text] + (case id + 0 (String. (byte-array text) "ISO-8859-1") + 1 (String. (byte-array text) "UTF-16") + 2 (String. (byte-array text) "UTF-16BE") + 3 (String. (byte-array text) "UTF-8") + (throw (Exception. "Unknown encoding")))) + +(defn valid-frame? + "Checks first 4 symbols if they are fit frame ID pattern." + [id] + (and (string? id) + (= 4 (count id)) + (some? (re-matches #"[A-Z]{3}[A-Z0-9]" id)))) + +(comment + (valid-frame? "asdf") + (valid-frame? "TALB") + (valid-frame? "1ASD") + (valid-frame? "1AS") + (valid-frame? "RVA2")) + +(defmulti decode-frame :id) + +; "TALB — Album" +(defmethod decode-frame :TALB + [data] + (assoc data :title "Album" :body (decode-text (:enc-byte data) (:body data)))) + +; "TPE1 — Artist" +(defmethod decode-frame :TPE1 + [data] + (assoc data :title "Artist" :body (decode-text (:enc-byte data) (:body data)))) + +; ; "TIT2 — Title" +(defmethod decode-frame :TIT2 + [data] + (assoc data :title "Title" :body (decode-text (:enc-byte data) (:body data)))) + +; "TYER — Year" +(defmethod decode-frame :TYER + [data] + (assoc data :title "Year" :body (Integer/parseInt (decode-text (:enc-byte data) (:body data))))) + +; "TCON — Genre" +(defmethod decode-frame :TCON + [data] + (assoc data :title "Genre" :body (decode-text (:enc-byte data) (:body data)))) + +(defmethod decode-frame :APIC [data] (assoc data :title "Picture" :body "")) + +;; Other +(defmethod decode-frame :TRCK + [data] + (assoc data :title "Track" :body (Integer/parseInt (apply str (:body data))))) + +(defmethod decode-frame :default [data] data) + +(defn frame-reader + "Returns map with frames. + :id :size :enc-byte :body + + Uses this pattern to gather data: + Frame ID $xx xx xx xx (four characters) + Size 4 * %0xxxxxxx + Flags $xx xx " + ([data] + (if (and (not-empty data) (valid-frame? (apply str (take 4 data)))) + (let [id (take 4 data) + data-size (u/bytes->num (map int (take 4 (drop 4 data)))) + all-body (take data-size (drop 10 data)) + enc-byte (int (nth all-body 0)) + body (map int (drop 1 all-body)) + header-size (+ 10 data-size)] + (lazy-seq (cons (decode-frame (zipmap [:id :size :enc-byte :body] + [(keyword (apply str id)) header-size enc-byte body])) + (frame-reader (drop header-size data))))) + []))) diff --git a/otus-10/src/otus_10/homework.clj b/otus-10/src/otus_10/homework.clj index 3d3169d..5302efb 100644 --- a/otus-10/src/otus_10/homework.clj +++ b/otus-10/src/otus_10/homework.clj @@ -1,6 +1,41 @@ -(ns otus-10.homework) +(ns otus-10.homework + (:require [clojure.tools.cli :refer [parse-opts]] + [clojure.java.io :as io] + [clojure.string :as string] + [otus-10.id3 :as id3]) + (:gen-class)) +(def cli-options + [["-f" "--filename FILE" "Filename" :validate [#(.exists (io/as-file %))]] + ["-a" "--show-album" :id :TALB] + ["-A" "--show-artist" :id :TPE1] + ["-t" "--show-title" :id :TIT2] + ["-y" "--show-year" :id :TYER] + ["-g" "--show-genre" :id :TCON] + ["-h" "--help"]]) + +(defn usage [options-summary] + (->> ["Show mp3 info" + "" + "Usage: program-name [options]" + "" + "Options:" + options-summary + "" + "Please refer to the manual page for more information."] + (string/join \newline))) (defn -main "I don't do a whole lot ... yet." - [& args]) + [& args] + (let [{opts :options, errors :errors summary :summary} (parse-opts args cli-options)] + (if errors + (apply println errors) + (let [what-to-show (set (keys (dissoc opts :filename))) + frames (:frames (id3/get-id3-header (:filename opts)))] + (if (empty? what-to-show) + (print (usage summary)) + (dorun (map #(println (string/join " - " %)) + (for [frame frames + :when (contains? what-to-show (:id frame))] + [(:title frame "None") (:body frame)])))))))) diff --git a/otus-10/src/otus_10/id3.clj b/otus-10/src/otus_10/id3.clj new file mode 100644 index 0000000..711dfc2 --- /dev/null +++ b/otus-10/src/otus_10/id3.clj @@ -0,0 +1,41 @@ +(ns otus-10.id3 + (:require [clojure.java.io :as io] + [otus-10.utils :as u] + [otus-10.frames :as f])) + +(defn valid-id3? + "Checks if id3 is valid according to the pattern: + $49 44 33 yy yy xx zz zz zz zz" + [header] + (let [[h1 h2 h3 yy1 yy2 _ zz1 zz2 zz3 zz4] header] + (and (every? true? (map = [\I \D \3] [h1 h2 h3])) + (every? #(< (int %) 0xff) [yy1 yy2]) + (every? #(< (int %) 0x80) [zz1 zz2 zz3 zz4])))) + +(defn get-id3-flags + "Returns map of base header flags." + [header] + (let [flag-bit (int (nth header 5))] + (zipmap [:unsync :extended :exp :footer] + [(bit-test flag-bit 7) (bit-test flag-bit 6) (bit-test flag-bit 5) + (bit-test flag-bit 4)]))) + +(defn get-id3-header + "Returns map with set header flags and frames." + [filename] + (with-open [r (io/reader filename)] + (let [f (slurp r) + header (take 10 f) + size (u/bytes->num (map int (drop 6 header))) + full-header (take size f) + flags (get-id3-flags header) + ext-header-size (if (:extended flags) (u/bytes->num (map int (take 4 (drop 10 f)))) 0) + frames (drop (+ 10 ext-header-size) full-header)] + (when (valid-id3? header) + (assoc flags + :header-size size + :frames (f/frame-reader frames)))))) + +(comment + (get-id3-header "resources/file-12926-ed090b.mp3") + (get-id3-header "resources/sample.mp3")) diff --git a/otus-10/src/otus_10/utils.clj b/otus-10/src/otus_10/utils.clj new file mode 100644 index 0000000..ec1fae1 --- /dev/null +++ b/otus-10/src/otus_10/utils.clj @@ -0,0 +1,9 @@ +(ns otus-10.utils) + +;; Helpers +(defn bytes->num + [data] + (reduce bit-or + (map-indexed + (fn [i x] (bit-shift-left (bit-and x 0x0FF) (* 8 (- (count data) i 1)))) + data))) diff --git a/otus-10/test/otus_10/frames_test.clj b/otus-10/test/otus_10/frames_test.clj new file mode 100644 index 0000000..40b0722 --- /dev/null +++ b/otus-10/test/otus_10/frames_test.clj @@ -0,0 +1,29 @@ +(ns otus-10.frames-test + (:require [clojure.test :refer :all] + [otus-10.frames :refer :all])) + +(deftest test-valid-frame? + (let [test-cases [{:id "ABCD" :expected true} + {:id "A2C4" :expected false} + {:id "A2C" :expected false} + {:id "ABCDE" :expected false} + {:id "A2C!" :expected false} + {:id 1234 :expected false} + {:id "" :expected false} + {:id "1234" :expected false} + {:id nil :expected false}]] + (doseq [{:keys [id expected]} test-cases] + (is (= (valid-frame? id) expected) + (str "Test failed for id: " id))))) + +(deftest decode-text-test + (let [test-cases [{:id 0, :text [104 101 108 108 111], :expected "hello"} + {:id 1, :text [-2, -1, 0, 104, 0, 101, 0, 108, 0, 108, 0, 111], :expected "hello"} + {:id 2, :text [0, 104, 0, 101, 0, 108, 0, 108, 0, 111], :expected "hello"} + {:id 3, :text [104 101 108 108 111], :expected "hello"} + {:id 4, :text [104 101 108 108 111], :expected :exception}]] + + (doseq [{:keys [id text expected]} test-cases] + (if (= expected :exception) + (is (thrown? Exception (decode-text id text))) + (is (= expected (decode-text id text))))))) diff --git a/otus-10/test/otus_10/id3_test.clj b/otus-10/test/otus_10/id3_test.clj new file mode 100644 index 0000000..5aa1608 --- /dev/null +++ b/otus-10/test/otus_10/id3_test.clj @@ -0,0 +1,23 @@ +(ns otus-10.id3-test + (:require [clojure.test :refer :all] + [otus-10.id3 :refer :all])) + +(deftest test-valid-id3? + (let [test-cases [{:input [\I \D \3 0x00 0x00 nil 0x00 0x00 0x00 0x00] :expected true} + {:input [\I \D \3 0x7F 0x7F nil 0x7F 0x7F 0x7F 0x7F] :expected true} + {:input [\I \D \2 0x00 0x00 nil 0x00 0x00 0x00 0x00] :expected false} + {:input [\I \D \3 0x80 0x00 nil 0x00 0x00 0x00 0x00] :expected true} + {:input [\I \D \3 0x00 0x00 nil 0x79 0x00 0x00 0x00] :expected true} + {:input [nil \D \3 0x00 0x00 nil 0x00 0x00 0x00 0x00] :expected false}]] + (doseq [{:keys [input expected]} test-cases] + (is (= expected (valid-id3? input)))))) + +(deftest get-id3-flags-test + (let [test-cases [{:header [0 0 0 0 0 128] :expected {:unsync true :extended false :exp false :footer false}} + {:header [0 0 0 0 0 64] :expected {:unsync false :extended true :exp false :footer false}} + {:header [0 0 0 0 0 32] :expected {:unsync false :extended false :exp true :footer false}} + {:header [0 0 0 0 0 16] :expected {:unsync false :extended false :exp false :footer true}} + {:header [0 0 0 0 0 240] :expected {:unsync true :extended true :exp true :footer true}} + {:header [0 0 0 0 0 0] :expected {:unsync false :extended false :exp false :footer false}}]] + (doseq [{:keys [header expected]} test-cases] + (is (= expected (get-id3-flags header)))))) diff --git a/otus-10/test/otus_10/utils_test.clj b/otus-10/test/otus_10/utils_test.clj new file mode 100644 index 0000000..d9b6ef6 --- /dev/null +++ b/otus-10/test/otus_10/utils_test.clj @@ -0,0 +1,15 @@ +(ns otus-10.utils-test + (:require [clojure.test :refer :all] + [otus-10.utils :refer :all])) + +(deftest test-bytes->num + (are [expected input] (= expected (bytes->num input)) + ;; Test cases + 0 [0] + 255 [255] + 256 [1 0] + 65535 [255 255] + 16777215 [255 255 255] + 4294967295 [255 255 255 255] + 1 [0 0 0 1] + 258 [0 1 2]))