commit 24df1854f5ffbbd7dc8d7064096b0df8c2fa4b65 Author: Artur Meski Date: Fri Mar 20 20:00:10 2026 +0000 The beginning of something great diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..616eda4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2812 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mpris-scrobbler" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "ctrlc", + "image", + "libc", + "reqwest", + "rusqlite", + "serde", + "serde_json", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1e0bb92 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "mpris-scrobbler" +version = "0.1.0" +edition = "2024" + +[dependencies] +clap = { version = "4", features = ["derive"] } +rusqlite = { version = "0.31", features = ["bundled"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ctrlc = "3" +reqwest = { version = "0.12", features = ["blocking", "json"] } +libc = "0.2" +image = { version = "0.25", features = ["jpeg"] } diff --git a/FUTURE.md b/FUTURE.md new file mode 100644 index 0000000..f107770 --- /dev/null +++ b/FUTURE.md @@ -0,0 +1,9 @@ +# Future Ideas + +## Genre Enrichment Fallback + +- Add a Last.fm genre fallback when MusicBrainz returns no usable genre data. +- Keep MusicBrainz as primary source; use Last.fm only as a secondary source. +- Add API key configuration (environment variable + CLI/docs guidance). +- Cache Last.fm-derived genres in `album_cache` and mark source for transparency. +- Keep existing cooldown behaviour to avoid repeated API calls. diff --git a/README.md b/README.md new file mode 100644 index 0000000..17d5aa9 --- /dev/null +++ b/README.md @@ -0,0 +1,451 @@ +# mpris-scrobbler + +A local music scrobbler for MPRIS-compatible players, built in Rust. It tracks what you listen to via `playerctl`, stores scrobble data in a local SQLite database, and generates listening reports. + +## Features + +- **Accurate play time tracking** — monitors both metadata changes and play/pause status, so paused time doesn't count toward scrobble thresholds +- **Last.fm-style scrobble rules** — a track is scrobbled after 50% of its duration or 4 minutes of play, whichever is shorter +- **Local storage** — all data stays on your machine in a single SQLite file +- **Reports** — terminal tables or JSON output, filterable by time period (today, week, month, year, all time) +- **Adaptive terminal tables** — report columns shrink to fit narrower terminal widths +- **HTML reports** — generate a standalone dark-themed HTML file with album art cards +- **Terminal-style typography** — HTML uses JetBrains Mono with monospace fallbacks +- **Fair ranking tie-breaks** — top artists/albums/tracks are ranked by plays first, then total listen time +- **Album visuals + bars** — HTML shows both large album cover grids and top-album bar tables +- **Genre stats** — reports include top genres (plays + listen time) when metadata is available +- **Mood labels by period** — each report section highlights up to 6 dominant genres +- **Mobile jump menu** — sticky section links (Today/Week/Month/All Time) reduce scrolling on phones +- **Enrichment** — fetch album covers + genres from MusicBrainz/Cover Art Archive +- **Incremental publish helper** — query latest scrobble and publish only when new data exists +- **Configurable player** — defaults to `com.blitzfc.qbz`, configurable via `--player` + +## Requirements + +- [playerctl](https://github.com/altdesktop/playerctl) +- Rust toolchain (for building) + +## Installation + +### Quick install (interactive) + +The repo includes an interactive install script that builds the binary, installs it, +sets up the systemd service, and starts it — asking before each step: + +```bash +./install.sh +``` + +### Manual install + +Build and install the binary into `~/.local/bin`: + +```bash +cargo build --release +install -Dm755 target/release/mpris-scrobbler ~/.local/bin/mpris-scrobbler +``` + +Make sure `~/.local/bin` is in your `PATH`: + +```bash +command -v mpris-scrobbler +``` + +If that prints nothing, add this to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.): + +```bash +export PATH="$HOME/.local/bin:$PATH" +``` + +Then open a new shell and verify: + +```bash +mpris-scrobbler --help +``` + +## Autostart (recommended) + +Use a **systemd user service** so scrobbling starts automatically when you log in. + +### 1) Install the service unit + +Copy the provided unit file from the repo: + +```bash +mkdir -p ~/.config/systemd/user +cp contrib/systemd/user/mpris-scrobbler.service ~/.config/systemd/user/ +``` + +If needed, edit the player in the service (`--player com.blitzfc.qbz`). + +### 2) Enable and start + +```bash +systemctl --user daemon-reload +systemctl --user enable --now mpris-scrobbler.service +``` + +### 3) Verify + +```bash +systemctl --user status mpris-scrobbler.service +journalctl --user -u mpris-scrobbler.service -f +``` + +### Optional: keep running without active login + +If you want user services to keep running after logout/reboot (without an active shell login), enable lingering: + +```bash +loginctl enable-linger "$USER" +``` + +### Important session note + +`playerctl` and MPRIS are tied to the session D-Bus. The scrobbler must run in the **same user/session** as your player. If the player runs under another account/session, this service will not see it. + +## Report workflow + +Recommended workflow: + +- Keep only `watch` running as a service. +- Generate HTML reports manually whenever you want: + +```bash +mpris-scrobbler report --html --output ~/music-report +``` + +This creates a self-contained directory: + +``` +~/music-report/ +├── index.html # Open this in a browser +└── covers/ + ├── .jpg + ├── .jpg + └── ... +``` + +When `--html` is used, the tool automatically runs enrichment first (for missing albums) so covers/genres are available in the generated report. + +## Building + +```bash +cargo build --release +``` + +The binary will be at `target/release/mpris-scrobbler`. + +## Usage + +### Scrobbling + +Start the watcher to begin recording what you listen to: + +```bash +# Default player (com.blitzfc.qbz) +mpris-scrobbler watch + +# Specify a different player +mpris-scrobbler watch --player spotify +``` + +The watcher runs in the foreground and logs scrobbles to stderr: + +``` +Database: /home/user/.local/share/mpris-scrobbler/scrobbles.db +Watching player: com.blitzfc.qbz +[scrobbled] ††† (Crosses) - This Is a Trick (186s) +[scrobbled] ††† (Crosses) - Telepathy (200s) +``` + +Press `Ctrl+C` to stop. The last track will be evaluated before shutdown. + +### Reports + +All-time sections default to 2.5x the `--limit` value (rounded to nearest 5) +for a broader view; shorter periods use the limit as-is. Default limit is 10, +giving 25 for all-time. Use `--all-time-limit` to override explicitly. + +Top lists use this ordering logic: + +- Primary: number of plays (descending) +- Secondary: total listen time (descending) +- Final stable tie-break: name fields (artist/album/title) + +In HTML reports, album covers are shown in fixed full rows (6 columns on desktop, 3 on tablet, 2 on mobile). For cleaner layout, the cover grid rounds the visual cover count up to the next full desktop row when enough albums exist. + +The HTML head loads JetBrains Mono from Google Fonts and falls back to local monospace fonts when offline. + +```bash +# All-time summary with top artists, albums, genres, tracks +mpris-scrobbler report + +# Filter by period +mpris-scrobbler report --period today +mpris-scrobbler report --period week +mpris-scrobbler report --period month +mpris-scrobbler report --period year + +# JSON output +mpris-scrobbler report --json + +# HTML output to stdout (no covers) +mpris-scrobbler report --html + +# HTML output to directory (with covers) +mpris-scrobbler report --html --output ~/music-report + +# Change the number of entries in top-N lists (default: 20) +mpris-scrobbler report --limit 20 +``` + +Example terminal output: + +``` +=== Scrobble Report: This Week (2026-03-13 → 2026-03-19) === + ++-------------------+----------+ +| Metric | Value | ++-------------------+----------+ +| Total scrobbles | 142 | +| Total listen time | 8h 23m | +| Unique artists | 31 | +| Unique albums | 47 | +| Unique tracks | 98 | ++-------------------+----------+ + +Top Artists ++---+----------------+-------+-------------+ +| # | Artist | Plays | Listen Time | ++---+----------------+-------+-------------+ +| 1 | ††† (Crosses) | 23 | 1h 12m | +| 2 | Deftones | 18 | 1h 05m | ++---+----------------+-------+-------------+ +``` + +### Options + +``` +mpris-scrobbler watch [OPTIONS] + --player Player name for playerctl [default: com.blitzfc.qbz] + --db-path Path to the SQLite database + +mpris-scrobbler report [OPTIONS] + --period today, week, month, year, all [default: all] + --json Output as JSON + --html Output as standalone HTML + --output Write HTML report to this directory (index.html + covers/) + --limit Number of entries in top-N lists [default: 10] + --all-time-limit Override all-time top-N limit [default: 2.5x --limit] + --db-path Path to the SQLite database + +mpris-scrobbler enrich [OPTIONS] + --force Re-fetch metadata for all albums + --db-path Path to the SQLite database + +mpris-scrobbler last-scrobble [OPTIONS] + --db-path Path to the SQLite database +``` + +### Enrichment (covers + genres) + +Enrichment is automatic for `report --html`, but you can still run it manually if you want to prefetch metadata: + +```bash +mpris-scrobbler enrich +``` + +When MusicBrainz matching is tricky, enrichment now retries with normalised +album variants (e.g. strips parenthetical suffixes like `(Killing Eve)`, then +progressively shortens trailing words), tries artist aliases for symbol-heavy +names, and falls back to recording search before giving up. + +Genre extraction order: + +1. Release `genres` +2. Release `tags` +3. Release-group `genres` +4. Release-group `tags` + +Automatic enrichment (triggered by `report --html`) uses a retry cooldown for +incomplete cache entries (missing cover or missing genre): those entries are +re-tried after 7 days, not on every report run. + +Use force mode when you want immediate backfill/refresh for everything: + +```bash +mpris-scrobbler enrich --force +``` + +Genre normalisation notes: + +- Genre labels are currently passed through from MusicBrainz with light cleanup only. +- We split comma-separated values and trim spaces. +- For aggregation, hyphen/space variants are grouped (e.g. `post-rock` + `post rock`). +- When both forms exist, the spaced form is preferred for display. +- Album cards display at most 3 genre pills for readability. +- Top Genre and Mood sections aggregate using that normalised grouping. + +Downloaded covers are stored in: + +`~/.local/share/mpris-scrobbler/covers/` + +#### Manually pinning an album (`pin-album`) + +Sometimes automatic search fails — most commonly for classical recordings where +the scrobbled artist tag (e.g. a choir or soloist) doesn't match the release +credits on MusicBrainz, or where the album title wording differs significantly. +Enrich will print: + +``` +No match found on MusicBrainz. +``` + +When that happens you can pin the correct MusicBrainz release manually: + +1. Search for the release on [musicbrainz.org](https://musicbrainz.org). +2. Open the release page. The MBID is the UUID in the URL: + `https://musicbrainz.org/release/`**`f2ff907a-0355-451b-9c68-f0b7c09bb145`** +3. Copy the exact artist and album strings from the `enrich` output (the + `[N/M] Artist - Album` line) and run: + +```bash +mpris-scrobbler pin-album \ + --artist "Coro della Radiotelevisione Svizzera" \ + --album "Vivaldi: Gloria; Nisi Dominus; Nulla in mundo pax" \ + --mbid "f2ff907a-0355-451b-9c68-f0b7c09bb145" +``` + +This fetches genres and cover art for that specific release and stores them in +the local cache, overwriting any previous (failed) entry. Re-run `enrich` or +`report --html` afterwards to pick up the result. + +If the Cover Art Archive has no image for the release (the command prints +"No cover art available"), supply one with `--cover-url`: + +```bash +mpris-scrobbler pin-album \ + --artist "Coro della Radiotelevisione Svizzera" \ + --album "Vivaldi: Gloria; Nisi Dominus; Nulla in mundo pax" \ + --mbid "f2ff907a-0355-451b-9c68-f0b7c09bb145" \ + --cover-url "https://example.com/cover.jpg" +``` + +The image is downloaded, resized to 500 px, and stored locally just like a +Cover Art Archive image. Any HTTPS image URL works — Discogs, Wikipedia, +Bandcamp, etc. + +### Incremental publish script + +If you publish the report to a remote host, use the included helper script: + +```bash +./mpris-scrobbler-publish.sh +``` + +It runs `report --html` and `rsync` only when a newer scrobble exists. +The script tracks the last published scrobble timestamp in: + +`$XDG_STATE_HOME/mpris-scrobbler/last-published-scrobble.txt` + +Set defaults in a config file so you can run the script without passing flags: + +`~/.config/mpris-scrobbler/publish.conf` + +If you use `./install.sh`, an example config is installed there automatically +when the file does not already exist. + +Example: + +```bash +OUTPUT_DIR="$HOME/music-report" +REMOTE_TARGET="user@host:/var/www/music-report" +DB_PATH="" +``` + +Legacy fallback is also supported: + +`~/.mpris-scrobbler-publish.conf` + +Flags: + +```bash +./mpris-scrobbler-publish.sh --output ~/music-report --remote user@host:/var/www/music-report +./mpris-scrobbler-publish.sh --db-path /custom/path/scrobbles.db + +# Keep running and check every 5 minutes (default interval): +./mpris-scrobbler-publish.sh --watch + +# Custom interval (seconds): +./mpris-scrobbler-publish.sh --watch --interval 600 + +# Force regeneration even when no new scrobbles exist: +./mpris-scrobbler-publish.sh --force +``` + +The installer also places this helper in `~/.local/bin` as: + +`mpris-scrobbler-publish` + +## Data storage + +Scrobbles are stored in SQLite at `~/.local/share/mpris-scrobbler/scrobbles.db` (respects `$XDG_DATA_HOME`). + +Each scrobble records: + +| Field | Description | +|-------|-------------| +| artist | Artist name | +| album | Album name | +| title | Track title | +| track_duration_secs | Full track duration in seconds | +| played_duration_secs | Actual time spent listening | +| scrobbled_at | ISO 8601 timestamp | + +## How it works + +The watcher spawns two `playerctl --follow` processes: + +1. **Metadata follower** — emits a line each time the track changes +2. **Status follower** — emits `Playing`, `Paused`, or `Stopped` on state changes + +A state machine accumulates play time only while the player is in `Playing` state. When a new track starts (or the player stops), the previous track is evaluated against the scrobble threshold and recorded if it qualifies. + +## Troubleshooting + +### Service is running but nothing is scrobbled + +Check the player name: + +```bash +playerctl -l +``` + +If needed, edit `~/.config/systemd/user/mpris-scrobbler.service` and change `--player ...`, then reload/restart: + +```bash +systemctl --user daemon-reload +systemctl --user restart mpris-scrobbler.service +``` + +### Player is in another account/session + +MPRIS is session-scoped. The service must run in the same account/session as the player process. + +### Check logs + +```bash +journalctl --user -u mpris-scrobbler.service -n 200 +journalctl --user -u mpris-scrobbler.service -f +``` + +### Verify database is being written + +```bash +mpris-scrobbler report --period today +``` + +## Licence + +MIT diff --git a/contrib/examples/publish.conf.example b/contrib/examples/publish.conf.example new file mode 100644 index 0000000..ada3cd8 --- /dev/null +++ b/contrib/examples/publish.conf.example @@ -0,0 +1,15 @@ +# mpris-scrobbler publish helper configuration +# +# Copy to: +# ~/.config/mpris-scrobbler/publish.conf +# +# Or let install.sh install it automatically if missing. + +# Local output directory for generated report files. +OUTPUT_DIR="$HOME/music-report" + +# Remote rsync target. +REMOTE_TARGET="user@host:/var/www/music-report" + +# Optional custom database path (leave empty for default path). +DB_PATH="" diff --git a/contrib/systemd/user/mpris-scrobbler.service b/contrib/systemd/user/mpris-scrobbler.service new file mode 100644 index 0000000..590c16b --- /dev/null +++ b/contrib/systemd/user/mpris-scrobbler.service @@ -0,0 +1,13 @@ +[Unit] +Description=MPRIS Scrobbler watcher +Documentation=https://github.com/arturmeski/mpris_scrobbler +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/mpris-scrobbler watch --player com.blitzfc.qbz +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..20c0559 --- /dev/null +++ b/install.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# +# Interactive installer for mpris-scrobbler. +# +# Steps: +# 1. Build release binary +# 2. Install to ~/.local/bin/ +# 3. Install systemd user service +# 4. Enable and start the service +# 5. Check status and print logs +# +# Each step asks for confirmation before proceeding. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BIN_NAME="mpris-scrobbler" +PUBLISH_SCRIPT_NAME="mpris-scrobbler-publish.sh" +PUBLISH_INSTALLED_NAME="mpris-scrobbler-publish" +PUBLISH_CONFIG_EXAMPLE_SRC="$SCRIPT_DIR/contrib/examples/publish.conf.example" +PUBLISH_CONFIG_DIR="$HOME/.config/mpris-scrobbler" +PUBLISH_CONFIG_FILE="$PUBLISH_CONFIG_DIR/publish.conf" +INSTALL_DIR="$HOME/.local/bin" +SERVICE_SRC="$SCRIPT_DIR/contrib/systemd/user/mpris-scrobbler.service" +SERVICE_DIR="$HOME/.config/systemd/user" +SERVICE_NAME="mpris-scrobbler.service" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } + +# Ask the user for confirmation. Returns 0 if yes, 1 if no. +confirm() { + local prompt="$1" + echo "" + read -rp "$(echo -e "${YELLOW}$prompt [y/N]${NC} ")" answer + case "$answer" in + [yY]|[yY][eE][sS]) return 0 ;; + *) return 1 ;; + esac +} + +# ----------------------------------------------------------------------- +# Step 1: Build +# ----------------------------------------------------------------------- + +info "Source directory: $SCRIPT_DIR" +echo "" + +if confirm "Step 1/5: Build release binary? (cargo build --release)"; then + info "Building..." + (cd "$SCRIPT_DIR" && cargo build --release) + info "Build complete: $SCRIPT_DIR/target/release/$BIN_NAME" +else + warn "Skipping build. Make sure $SCRIPT_DIR/target/release/$BIN_NAME exists." +fi + +# Verify the binary exists before continuing. +if [[ ! -f "$SCRIPT_DIR/target/release/$BIN_NAME" ]]; then + error "Binary not found at $SCRIPT_DIR/target/release/$BIN_NAME" + error "Cannot continue without a built binary. Run 'cargo build --release' first." + exit 1 +fi + +# ----------------------------------------------------------------------- +# Step 2: Install binary and publish helper +# ----------------------------------------------------------------------- + +if confirm "Step 2/5: Install binary and publish helper to $INSTALL_DIR/?"; then + mkdir -p "$INSTALL_DIR" + install -Dm755 "$SCRIPT_DIR/target/release/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME" + info "Installed: $INSTALL_DIR/$BIN_NAME" + + if [[ -f "$SCRIPT_DIR/$PUBLISH_SCRIPT_NAME" ]]; then + install -Dm755 \ + "$SCRIPT_DIR/$PUBLISH_SCRIPT_NAME" \ + "$INSTALL_DIR/$PUBLISH_INSTALLED_NAME" + info "Installed: $INSTALL_DIR/$PUBLISH_INSTALLED_NAME" + + if [[ ! -f "$PUBLISH_CONFIG_FILE" ]]; then + if [[ -f "$PUBLISH_CONFIG_EXAMPLE_SRC" ]]; then + mkdir -p "$PUBLISH_CONFIG_DIR" + install -Dm644 "$PUBLISH_CONFIG_EXAMPLE_SRC" "$PUBLISH_CONFIG_FILE" + info "Installed example config: $PUBLISH_CONFIG_FILE" + else + warn "Publish config example not found: $PUBLISH_CONFIG_EXAMPLE_SRC" + fi + else + info "Keeping existing publish config: $PUBLISH_CONFIG_FILE" + fi + else + warn "Publish helper not found: $SCRIPT_DIR/$PUBLISH_SCRIPT_NAME" + fi + + # Check PATH + if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then + warn "$INSTALL_DIR is not in your PATH." + warn "Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):" + echo "" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + echo "" + else + info "$INSTALL_DIR is already in PATH." + fi +else + warn "Skipping install." +fi + +# ----------------------------------------------------------------------- +# Step 3: Install systemd user service +# ----------------------------------------------------------------------- + +if confirm "Step 3/5: Install systemd user service to $SERVICE_DIR/$SERVICE_NAME?"; then + if [[ ! -f "$SERVICE_SRC" ]]; then + error "Service file not found: $SERVICE_SRC" + exit 1 + fi + + mkdir -p "$SERVICE_DIR" + cp "$SERVICE_SRC" "$SERVICE_DIR/$SERVICE_NAME" + info "Installed: $SERVICE_DIR/$SERVICE_NAME" + + echo "" + info "Current service configuration:" + grep "ExecStart" "$SERVICE_DIR/$SERVICE_NAME" + echo "" + warn "If you need a different --player name, edit:" + warn " $SERVICE_DIR/$SERVICE_NAME" + warn "then run: systemctl --user daemon-reload" + + # Reload systemd to pick up the new/updated unit file. + systemctl --user daemon-reload + info "systemd user daemon reloaded." +else + warn "Skipping service installation." +fi + +# ----------------------------------------------------------------------- +# Step 4: Enable and start +# ----------------------------------------------------------------------- + +if confirm "Step 4/5: Enable and start $SERVICE_NAME?"; then + systemctl --user enable "$SERVICE_NAME" + info "Service enabled (will start on login)." + + systemctl --user restart "$SERVICE_NAME" + info "Service (re)started." +else + warn "Skipping enable/start." +fi + +# ----------------------------------------------------------------------- +# Step 5: Check status and logs +# ----------------------------------------------------------------------- + +if confirm "Step 5/5: Check service status and print recent logs?"; then + echo "" + info "Service status:" + echo "---" + systemctl --user status "$SERVICE_NAME" --no-pager || true + echo "---" + echo "" + info "Recent logs:" + echo "---" + journalctl --user -u "$SERVICE_NAME" -n 30 --no-pager || true + echo "---" +else + warn "Skipping status check." +fi + +echo "" +info "Done." +info "" +info "Useful commands:" +info " Check status: systemctl --user status $SERVICE_NAME" +info " View logs: journalctl --user -u $SERVICE_NAME -f" +info " Stop service: systemctl --user stop $SERVICE_NAME" +info " Restart: systemctl --user restart $SERVICE_NAME" +info " Generate report: $BIN_NAME report --html --output ~/music-report" +info " Publish report: $PUBLISH_INSTALLED_NAME" +info " Configure defaults in: ~/.config/mpris-scrobbler/publish.conf" diff --git a/mpris-scrobbler-publish.sh b/mpris-scrobbler-publish.sh new file mode 100755 index 0000000..29c14f3 --- /dev/null +++ b/mpris-scrobbler-publish.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Generate and publish the HTML report only when new scrobbles exist. +# +# Defaults can be overridden by config file and then by flags: +# +# Config file precedence: +# 1) $XDG_CONFIG_HOME/mpris-scrobbler/publish.conf +# 2) ~/.config/mpris-scrobbler/publish.conf +# 3) ~/.mpris-scrobbler-publish.conf (legacy fallback) +# +# Supported config variables: +# OUTPUT_DIR +# REMOTE_TARGET +# DB_PATH +# +# Flags: +# --output +# --remote +# --db-path +# --watch keep running, checking every --interval seconds +# --interval seconds between checks in --watch mode (default: 300) + +OUTPUT_DIR="${HOME}/music-report" +REMOTE_TARGET="user@host:/var/www/music-report" +DB_PATH="" +WATCH=0 +INTERVAL=300 +FORCE=0 + +XDG_CONFIG_BASE="${XDG_CONFIG_HOME:-${HOME}/.config}" +PRIMARY_CONFIG="${XDG_CONFIG_BASE}/mpris-scrobbler/publish.conf" +LEGACY_CONFIG="${HOME}/.mpris-scrobbler-publish.conf" + +if [[ -f "${PRIMARY_CONFIG}" ]]; then + # shellcheck disable=SC1090 + source "${PRIMARY_CONFIG}" +elif [[ -f "${LEGACY_CONFIG}" ]]; then + # shellcheck disable=SC1090 + source "${LEGACY_CONFIG}" +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --remote) + REMOTE_TARGET="$2" + shift 2 + ;; + --db-path) + DB_PATH="$2" + shift 2 + ;; + --watch) + WATCH=1 + shift + ;; + --interval) + INTERVAL="$2" + shift 2 + ;; + --force) + FORCE=1 + shift + ;; + *) + printf 'Unknown option: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +if [[ ! "${INTERVAL}" =~ ^[1-9][0-9]*$ ]]; then + printf 'Invalid --interval value: %s (must be a positive integer)\n' "${INTERVAL}" >&2 + exit 2 +fi + +if ! command -v mpris-scrobbler >/dev/null 2>&1; then + printf 'mpris-scrobbler not found in PATH\n' >&2 + exit 1 +fi + +if ! command -v rsync >/dev/null 2>&1; then + printf 'rsync not found in PATH\n' >&2 + exit 1 +fi + +STATE_HOME="${XDG_STATE_HOME:-${HOME}/.local/state}" +STATE_DIR="${STATE_HOME}/mpris-scrobbler" +MARKER_FILE="${STATE_DIR}/last-published-scrobble.txt" +mkdir -p "${STATE_DIR}" + +do_publish() { + local last_args=() + if [[ -n "${DB_PATH}" ]]; then + last_args+=(--db-path "${DB_PATH}") + fi + + local latest_scrobble + latest_scrobble="$(mpris-scrobbler last-scrobble "${last_args[@]}")" + if [[ -z "${latest_scrobble}" ]]; then + printf 'No scrobbles yet. Nothing to publish.\n' + return 0 + fi + + local last_published="" + if [[ -f "${MARKER_FILE}" ]]; then + last_published="$(<"${MARKER_FILE}")" + fi + + if [[ "${last_published}" == "${latest_scrobble}" ]]; then + if [[ "${FORCE}" -eq 1 ]]; then + printf 'No new scrobbles since %s, but --force set. Regenerating anyway...\n' "${latest_scrobble}" + else + printf 'No new scrobbles since %s. Skipping publish.\n' "${latest_scrobble}" + return 0 + fi + else + printf 'New scrobbles detected (latest: %s). Regenerating report...\n' "${latest_scrobble}" + fi + + local report_args=(report --html --output "${OUTPUT_DIR}") + if [[ -n "${DB_PATH}" ]]; then + report_args+=(--db-path "${DB_PATH}") + fi + mpris-scrobbler "${report_args[@]}" + + printf 'Publishing via rsync to %s...\n' "${REMOTE_TARGET}" + rsync -Pavz "${OUTPUT_DIR}" "${REMOTE_TARGET}" + + printf '%s\n' "${latest_scrobble}" > "${MARKER_FILE}" + printf 'Publish complete. Marker updated at %s\n' "${MARKER_FILE}" +} + +if [[ "${WATCH}" -eq 1 ]]; then + printf 'Watch mode: checking every %d seconds. Press Ctrl+C to stop.\n' "${INTERVAL}" + while true; do + do_publish + sleep "${INTERVAL}" + done +else + do_publish +fi diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..941ef64 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,1380 @@ +//! Database module for the MPRIS scrobbler. +//! +//! This module handles all SQLite interactions: schema creation, inserting +//! scrobble records, and querying data for reports. The database is stored +//! as a single SQLite file (by default at `~/.local/share/mpris-scrobbler/scrobbles.db`). +//! +//! ## Tables +//! +//! - **`scrobbles`** — one row per scrobbled track. Stores artist, album, title, +//! the track's full duration, the actual time spent listening (paused time +//! excluded), and an ISO 8601 timestamp. This is the core data that the +//! `watch` command writes and the `report` command reads. +//! +//! - **`album_cache`** — one row per unique (artist, album) pair. Populated by +//! the `enrich` command (or automatically when generating an HTML report). +//! Stores the MusicBrainz release ID, a local file path to the downloaded +//! cover art image, and genre/tag strings from MusicBrainz. Albums with +//! `cover_url = NULL` or `genre = NULL` are considered incomplete and can be +//! re-tried on enrichment runs after a cooldown period. +//! +//! ## Query patterns +//! +//! All report queries accept a period string (`"today"`, `"week"`, `"month"`, +//! `"year"`, `"all"`). The `period_range()` function converts this to an +//! ISO 8601 date range, and `where_clause()` generates the corresponding SQL +//! `WHERE` fragment. This keeps period logic in one place rather than +//! duplicating it across every query function. + +use chrono::{Datelike, NaiveDateTime}; +use rusqlite::{Connection, Result, params}; +use serde::Serialize; +use std::collections::HashMap; + +// --------------------------------------------------------------------------- +// Data structures +// --------------------------------------------------------------------------- + +/// A scrobble record as stored in the database. +/// This is the "full" version with the auto-generated `id`, used when reading +/// data back from the DB (e.g., for reports or JSON export). +#[derive(Debug, Clone, Serialize)] +pub struct Scrobble { + pub id: i64, + pub artist: String, + pub album: String, + pub title: String, + /// The track's full duration in seconds, as reported by MPRIS. + /// May be `None` if the player didn't provide duration info. + pub track_duration_secs: Option, + /// How long the user actually listened (in seconds). Only time spent + /// in the "Playing" state counts — paused time is excluded. + pub played_duration_secs: i64, + /// ISO 8601 timestamp of when the scrobble was recorded. + pub scrobbled_at: String, +} + +/// Data required to insert a new scrobble. Same fields as `Scrobble` but +/// without the `id`, which is auto-assigned by SQLite. +#[derive(Debug, Clone)] +pub struct NewScrobble { + pub artist: String, + pub album: String, + pub title: String, + pub track_duration_secs: Option, + pub played_duration_secs: i64, + pub scrobbled_at: String, +} + +/// Aggregate overview statistics for a given time period. +#[derive(Debug, Clone, Serialize)] +pub struct Overview { + pub total_scrobbles: i64, + /// Sum of `played_duration_secs` across all scrobbles in the period. + pub total_listen_time_secs: i64, + pub unique_artists: i64, + pub unique_albums: i64, + /// Unique tracks, identified by the (artist, title) pair. + pub unique_tracks: i64, +} + +/// A row in the "top artists" ranking. +#[derive(Debug, Clone, Serialize)] +pub struct TopArtist { + pub artist: String, + /// Number of scrobbles for this artist. + pub plays: i64, + /// Total seconds spent listening to this artist. + pub listen_time_secs: i64, +} + +/// A row in the "top albums" ranking, grouped by (artist, album). +#[derive(Debug, Clone, Serialize)] +pub struct TopAlbum { + pub artist: String, + pub album: String, + pub plays: i64, + pub listen_time_secs: i64, +} + +/// A row in the "top tracks" ranking, grouped by (artist, title). +/// Includes the album name so we can look up the cover image. +#[derive(Debug, Clone, Serialize)] +pub struct TopTrack { + pub artist: String, + pub album: String, + pub title: String, + pub plays: i64, + pub listen_time_secs: i64, +} + +/// A row in the "top genres" ranking. +/// +/// Note: a single scrobble may contribute to multiple genres if its album has +/// multiple comma-separated genres in `album_cache.genre`. +#[derive(Debug, Clone, Serialize)] +pub struct TopGenre { + pub genre: String, + pub plays: i64, + pub listen_time_secs: i64, +} + +// --------------------------------------------------------------------------- +// Database initialization +// --------------------------------------------------------------------------- + +/// Open (or create) the database at the given file path and ensure the +/// schema exists. This is safe to call on an already-initialized DB because +/// all CREATE statements use `IF NOT EXISTS`. +pub fn open_db(path: &str) -> Result { + let conn = Connection::open(path)?; + init_schema(&conn)?; + Ok(conn) +} + +/// Open an in-memory SQLite database with the schema applied. +/// Used exclusively by unit tests to avoid touching the filesystem. +#[cfg(test)] +pub fn open_memory_db() -> Result { + let conn = Connection::open_in_memory()?; + init_schema(&conn)?; + Ok(conn) +} + +/// Create all tables and indexes if they don't already exist. +/// +/// ## Tables +/// +/// ### `scrobbles` — one row per scrobbled track +/// - `id` — auto-incrementing primary key +/// - `artist` — artist name (required) +/// - `album` — album name (defaults to empty string) +/// - `title` — track title (required) +/// - `track_duration_secs` — full track length in seconds (nullable) +/// - `played_duration_secs` — actual listening time in seconds (required) +/// - `scrobbled_at` — ISO 8601 timestamp string (required) +/// +/// ### `album_cache` — cached metadata for album art and genres +/// +/// Populated by the `enrich` command (or automatically when `report --html` +/// is used). Stores MusicBrainz IDs, local cover art file paths, and genre +/// info to avoid repeated API lookups. Keyed by the (artist, album) pair. +/// Albums with `cover_url = NULL` are considered incomplete and will be +/// re-tried on the next enrichment run. +/// +/// - `id` — auto-incrementing primary key +/// - `artist` — artist name (matches scrobbles.artist) +/// - `album` — album name (matches scrobbles.album) +/// - `musicbrainz_id` — MusicBrainz release MBID (UUID string, nullable) +/// - `cover_url` — Cover Art Archive URL or local file path (nullable) +/// - `genre` — comma-separated genre tags from MusicBrainz (nullable) +/// - `fetched_at` — ISO 8601 timestamp of when the lookup was performed +/// - UNIQUE(artist, album) — prevents duplicate cache entries +/// +/// ## Indexes +/// - `idx_scrobbled_at` — for efficient time-range queries in reports +/// - `idx_artist` — for efficient GROUP BY artist queries +fn init_schema(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS scrobbles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist TEXT NOT NULL, + album TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + track_duration_secs INTEGER, + played_duration_secs INTEGER NOT NULL, + scrobbled_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_scrobbled_at ON scrobbles(scrobbled_at); + CREATE INDEX IF NOT EXISTS idx_artist ON scrobbles(artist); + + -- Cache table for album metadata (cover art, genres) from MusicBrainz. + -- Populated by the 'enrich' command or automatically when generating + -- HTML reports. Rows with cover_url = NULL are re-tried on next run. + CREATE TABLE IF NOT EXISTS album_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist TEXT NOT NULL, + album TEXT NOT NULL, + musicbrainz_id TEXT, + cover_url TEXT, + genre TEXT, + fetched_at TEXT, + UNIQUE(artist, album) + );", + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Insert +// --------------------------------------------------------------------------- + +/// Insert a new scrobble record and return its auto-generated row ID. +pub fn insert_scrobble(conn: &Connection, s: &NewScrobble) -> Result { + conn.execute( + "INSERT INTO scrobbles (artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + s.artist, + s.album, + s.title, + s.track_duration_secs, + s.played_duration_secs, + s.scrobbled_at, + ], + )?; + Ok(conn.last_insert_rowid()) +} + +/// Return the newest scrobble timestamp (`scrobbled_at`) in the database. +/// +/// Returns `Ok(None)` when there are no scrobbles yet. +pub fn latest_scrobble_at(conn: &Connection) -> Result> { + let mut stmt = + conn.prepare("SELECT scrobbled_at FROM scrobbles ORDER BY scrobbled_at DESC LIMIT 1")?; + let mut rows = stmt.query([])?; + if let Some(row) = rows.next()? { + let ts: String = row.get(0)?; + Ok(Some(ts)) + } else { + Ok(None) + } +} + +// --------------------------------------------------------------------------- +// Period helpers +// --------------------------------------------------------------------------- + +/// Compute the ISO 8601 date range `(from, to)` for a named period. +/// +/// Supported periods: +/// - `"today"` — from midnight today to now +/// - `"week"` — from Monday 00:00 of the current week to now +/// - `"month"` — from the 1st of the current month to now +/// - `"year"` — from January 1st of the current year to now +/// - `"all"` — returns `None` (no filtering) +/// +/// The `from` and `to` strings are formatted as `YYYY-MM-DDTHH:MM:SS` so +/// they can be compared directly against the `scrobbled_at` column using +/// SQLite's string comparison (ISO 8601 sorts lexicographically). +pub fn period_range(period: &str) -> Option<(String, String)> { + let now = chrono::Local::now().naive_local(); + let today_start = now.date().and_hms_opt(0, 0, 0).unwrap(); + + let from = match period { + "today" => today_start, + "week" => { + // Calculate how many days back from today to reach Monday (0 = Mon, 6 = Sun). + let weekday = now.date().weekday().num_days_from_monday(); + today_start - chrono::Duration::days(weekday as i64) + } + "month" => { + // First day of the current month at midnight. + let d = now.date(); + NaiveDateTime::new( + chrono::NaiveDate::from_ymd_opt(d.year(), d.month(), 1).unwrap(), + chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + ) + } + "year" => { + // January 1st of the current year at midnight. + let d = now.date(); + NaiveDateTime::new( + chrono::NaiveDate::from_ymd_opt(d.year(), 1, 1).unwrap(), + chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap(), + ) + } + // "all": no date filtering. + "all" => return None, + // Any unrecognized period: no date filtering (validated earlier in CLI). + _ => return None, + }; + + let from_str = from.format("%Y-%m-%dT%H:%M:%S").to_string(); + let to_str = now.format("%Y-%m-%dT%H:%M:%S").to_string(); + Some((from_str, to_str)) +} + +/// Build a SQL WHERE clause fragment for time-period filtering. +/// +/// Returns a tuple of: +/// - The clause string (empty if no filtering, or " WHERE scrobbled_at BETWEEN ?1 AND ?2") +/// - A vector of bind parameter values (empty if no filtering, or [from, to]) +/// +/// This is used by all query functions below to optionally restrict results +/// to a specific time period. +fn where_clause(period: &str) -> (String, Vec) { + match period_range(period) { + Some((from, to)) => ( + " WHERE scrobbled_at BETWEEN ?1 AND ?2".to_string(), + vec![from, to], + ), + None => (String::new(), vec![]), + } +} + +// --------------------------------------------------------------------------- +// Query functions +// +// Each function takes a period string and constructs a SQL query with an +// optional WHERE clause for time filtering. The `mapper` closure is defined +// once and passed to both branches of the if/else (with/without params) to +// avoid Rust's "each closure has a unique type" issue. +// --------------------------------------------------------------------------- + +/// Get aggregate overview statistics for the given period. +/// +/// Unique tracks are counted by concatenating artist + null byte + title, +/// so that "Artist A - Song X" and "Artist B - Song X" are counted separately. +pub fn overview(conn: &Connection, period: &str) -> Result { + let (whr, p) = where_clause(period); + let sql = format!( + "SELECT + COUNT(*), + COALESCE(SUM(played_duration_secs), 0), + COUNT(DISTINCT artist), + COUNT(DISTINCT album), + COUNT(DISTINCT artist || '\\0' || title) + FROM scrobbles{}", + whr + ); + + let mut stmt = conn.prepare(&sql)?; + let row = if p.is_empty() { + stmt.query_row([], |row| { + Ok(Overview { + total_scrobbles: row.get(0)?, + total_listen_time_secs: row.get(1)?, + unique_artists: row.get(2)?, + unique_albums: row.get(3)?, + unique_tracks: row.get(4)?, + }) + }) + } else { + stmt.query_row(params![p[0], p[1]], |row| { + Ok(Overview { + total_scrobbles: row.get(0)?, + total_listen_time_secs: row.get(1)?, + unique_artists: row.get(2)?, + unique_albums: row.get(3)?, + unique_tracks: row.get(4)?, + }) + }) + }?; + Ok(row) +} + +/// Get the top N artists by play count for the given period, +/// ordered by number of scrobbles descending. +/// +/// Ties are broken by total listened time (descending), then artist name for +/// stable output ordering. +pub fn top_artists(conn: &Connection, period: &str, limit: i64) -> Result> { + let (whr, p) = where_clause(period); + let sql = format!( + "SELECT artist, COUNT(*) as plays, COALESCE(SUM(played_duration_secs), 0) as listen_time + FROM scrobbles{} + GROUP BY artist + ORDER BY plays DESC, listen_time DESC, artist ASC + LIMIT {}", + whr, limit + ); + + let mut stmt = conn.prepare(&sql)?; + let mapper = |row: &rusqlite::Row| { + Ok(TopArtist { + artist: row.get(0)?, + plays: row.get(1)?, + listen_time_secs: row.get(2)?, + }) + }; + if p.is_empty() { + stmt.query_map([], mapper)?.collect() + } else { + stmt.query_map(params![p[0], p[1]], mapper)?.collect() + } +} + +/// Get the top N albums by play count for the given period, +/// grouped by MusicBrainz release ID when known (falling back to the +/// (artist, album) pair) and ordered by scrobble count descending. +/// +/// Using the MBID as the group key means tracks from the same physical +/// album that were scrobbled under different artist tags (common in +/// classical recordings where individual tracks credit a soloist or +/// ensemble while others credit the full orchestra or choir) are +/// correctly counted as a single album. The artist returned is the one +/// that has an album_cache entry (i.e. the one that was enriched), so +/// cover art lookups in the report continue to work. +/// +/// Ties are broken by total listened time (descending), then artist + album +/// names for stable output ordering. +pub fn top_albums(conn: &Connection, period: &str, limit: i64) -> Result> { + let (whr, p) = where_clause(period); + // Group by (album, MBID) rather than (artist, album) so that scrobbles + // tagged with different artist names for the same physical release are + // counted together. + // + // For each row the group key resolves as: + // 1. The MBID from the direct (artist, album) album_cache entry, or + // 2. The MBID from any album_cache entry sharing the same album name + // (catches variants tagged with a performer vs. composer), or + // 3. The composite "artist::album" string (no MBID found — treated as + // a unique album, same as before). + // + // The displayed artist is similarly resolved: the enriched (cached) + // artist takes priority so that album_cache_meta() can find the cover. + let sql = format!( + "SELECT + CASE WHEN c.artist IS NOT NULL THEN s.artist + ELSE COALESCE( + (SELECT ac.artist FROM album_cache ac + WHERE ac.album = s.album AND ac.musicbrainz_id IS NOT NULL + ORDER BY ac.artist LIMIT 1), + s.artist + ) + END AS artist, + s.album AS album, + COUNT(*) AS plays, + COALESCE(SUM(s.played_duration_secs), 0) AS listen_time + FROM scrobbles s + LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album{} + GROUP BY s.album, COALESCE( + c.musicbrainz_id, + (SELECT ac.musicbrainz_id FROM album_cache ac + WHERE ac.album = s.album AND ac.musicbrainz_id IS NOT NULL + ORDER BY ac.musicbrainz_id LIMIT 1), + s.artist || '::' || s.album + ) + ORDER BY plays DESC, listen_time DESC, artist ASC, album ASC + LIMIT {}", + whr, limit + ); + + let mut stmt = conn.prepare(&sql)?; + let mapper = |row: &rusqlite::Row| { + Ok(TopAlbum { + artist: row.get(0)?, + album: row.get(1)?, + plays: row.get(2)?, + listen_time_secs: row.get(3)?, + }) + }; + if p.is_empty() { + stmt.query_map([], mapper)?.collect() + } else { + stmt.query_map(params![p[0], p[1]], mapper)?.collect() + } +} + +/// Get the top N tracks by play count for the given period, +/// grouped by (artist, title) and ordered by scrobble count descending. +/// The album field is the most frequently scrobbled album for that track +/// (used for cover art lookup). +/// +/// Ties are broken by total listened time (descending), then artist + title +/// names for stable output ordering. +pub fn top_tracks(conn: &Connection, period: &str, limit: i64) -> Result> { + let (whr, p) = where_clause(period); + // Use a subquery to pick the most common album for each (artist, title). + // In SQLite, the non-aggregated `album` column in a GROUP BY returns an + // arbitrary row's value, but wrapping it in a subquery with its own + // GROUP BY and ORDER BY ensures we get the most frequent album. + let sql = format!( + "SELECT artist, album, title, COUNT(*) as plays, COALESCE(SUM(played_duration_secs), 0) as listen_time + FROM scrobbles{} + GROUP BY artist, title + ORDER BY plays DESC, listen_time DESC, artist ASC, title ASC + LIMIT {}", + whr, limit + ); + + let mut stmt = conn.prepare(&sql)?; + let mapper = |row: &rusqlite::Row| { + Ok(TopTrack { + artist: row.get(0)?, + album: row.get(1)?, + title: row.get(2)?, + plays: row.get(3)?, + listen_time_secs: row.get(4)?, + }) + }; + if p.is_empty() { + stmt.query_map([], mapper)?.collect() + } else { + stmt.query_map(params![p[0], p[1]], mapper)?.collect() + } +} + +/// Get the top N genres for the given period. +/// +/// Genre values come from `album_cache.genre` (comma-separated) and are +/// associated with scrobbles via `(artist, album)` joins. +/// +/// Ties are broken by total listened time (descending), then genre name. +pub fn top_genres(conn: &Connection, period: &str, limit: i64) -> Result> { + let rows: Vec<(String, i64)> = if let Some((from, to)) = period_range(period) { + let mut stmt = conn.prepare( + "SELECT c.genre, s.played_duration_secs + FROM scrobbles s + JOIN album_cache c ON s.artist = c.artist AND s.album = c.album + WHERE c.genre IS NOT NULL + AND c.genre != '' + AND s.scrobbled_at BETWEEN ?1 AND ?2", + )?; + let mapped = stmt.query_map(params![from, to], |row| { + let genre: String = row.get(0)?; + let played_duration_secs: i64 = row.get(1)?; + Ok((genre, played_duration_secs)) + })?; + mapped.collect::>>()? + } else { + let mut stmt = conn.prepare( + "SELECT c.genre, s.played_duration_secs + FROM scrobbles s + JOIN album_cache c ON s.artist = c.artist AND s.album = c.album + WHERE c.genre IS NOT NULL + AND c.genre != ''", + )?; + let mapped = stmt.query_map([], |row| { + let genre: String = row.get(0)?; + let played_duration_secs: i64 = row.get(1)?; + Ok((genre, played_duration_secs)) + })?; + mapped.collect::>>()? + }; + + // key -> (display_label, plays, listen_secs) + // The key normalises hyphen/space variants so labels like "post-rock" and + // "post rock" are grouped together. + let mut agg: HashMap = HashMap::new(); + for (genre_csv, played_secs) in rows { + for genre in split_genre_list(&genre_csv) { + let key = canonical_genre_key(&genre); + if key.is_empty() { + continue; + } + let entry = agg.entry(key).or_insert_with(|| (genre.clone(), 0, 0)); + if prefer_display_genre(&entry.0, &genre) { + entry.0 = genre; + } + entry.1 += 1; + entry.2 += played_secs; + } + } + + let mut out: Vec = agg + .into_iter() + .map(|(_, (genre, plays, listen_time_secs))| TopGenre { + genre: genre.trim().to_string(), + plays, + listen_time_secs, + }) + .collect(); + out.sort_by(|a, b| { + b.plays + .cmp(&a.plays) + .then_with(|| b.listen_time_secs.cmp(&a.listen_time_secs)) + .then_with(|| a.genre.cmp(&b.genre)) + }); + out.truncate(limit.max(0) as usize); + Ok(out) +} + +/// Get the most recent scrobbles for the given period, ordered by +/// timestamp descending (newest first), limited to `limit` entries. +pub fn recent_scrobbles(conn: &Connection, period: &str, limit: i64) -> Result> { + let (whr, p) = where_clause(period); + let sql = format!( + "SELECT id, artist, album, title, track_duration_secs, played_duration_secs, scrobbled_at + FROM scrobbles{} + ORDER BY scrobbled_at DESC + LIMIT {}", + whr, limit + ); + + let mut stmt = conn.prepare(&sql)?; + let mapper = |row: &rusqlite::Row| { + Ok(Scrobble { + id: row.get(0)?, + artist: row.get(1)?, + album: row.get(2)?, + title: row.get(3)?, + track_duration_secs: row.get(4)?, + played_duration_secs: row.get(5)?, + scrobbled_at: row.get(6)?, + }) + }; + if p.is_empty() { + stmt.query_map([], mapper)?.collect() + } else { + stmt.query_map(params![p[0], p[1]], mapper)?.collect() + } +} + +// --------------------------------------------------------------------------- +// Album cache queries (for the `enrich` command) +// --------------------------------------------------------------------------- + +/// An (artist, album) pair that has scrobbles but no entry in `album_cache`. +/// These are the albums that need enrichment (MusicBrainz lookup + cover fetch). +#[derive(Debug, Clone)] +pub struct UncachedAlbum { + pub artist: String, + pub album: String, +} + +/// Find all unique (artist, album) pairs in `scrobbles` that either: +/// - don't have a corresponding row in `album_cache` at all, or +/// - have a cached row but with `cover_url` still NULL, or +/// - have a cached row but with `genre` still NULL. +/// +/// This ensures albums are re-tried if a previous run failed to find cover art +/// or genre metadata, but only after a cooldown to avoid hitting MusicBrainz +/// on every single report generation. +pub fn uncached_albums(conn: &Connection) -> Result> { + // Retry incomplete cache entries at most once per 7 days. + let retry_before = (chrono::Local::now().naive_local() - chrono::Duration::days(7)) + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + + let mut stmt = conn.prepare( + "SELECT DISTINCT s.artist, s.album + FROM scrobbles s + LEFT JOIN album_cache c ON s.artist = c.artist AND s.album = c.album + WHERE s.album != '' + AND ( + c.id IS NULL + OR ( + (c.cover_url IS NULL OR c.genre IS NULL) + AND (c.fetched_at IS NULL OR c.fetched_at < ?1) + ) + ) + ORDER BY s.artist, s.album", + )?; + let rows = stmt.query_map(params![retry_before], |row| { + Ok(UncachedAlbum { + artist: row.get(0)?, + album: row.get(1)?, + }) + })?; + rows.collect() +} + +/// Split a comma-separated genre string into cleaned labels. +fn split_genre_list(csv: &str) -> Vec { + csv.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +/// Canonical key for grouping genre labels. +/// +/// Rules: +/// - case-insensitive +/// - `-` and `_` are treated as spaces +/// - repeated spaces are collapsed +fn canonical_genre_key(genre: &str) -> String { + let lowered = genre + .chars() + .map(|c| match c { + '-' | '_' => ' ', + _ => c.to_ascii_lowercase(), + }) + .collect::(); + lowered.split_whitespace().collect::>().join(" ") +} + +/// Decide whether `candidate` should replace `existing` as the display label. +/// +/// Preference: if we already have a hyphenated label and we later see a spaced +/// variant for the same canonical key, prefer the spaced variant. +fn prefer_display_genre(existing: &str, candidate: &str) -> bool { + existing.contains('-') && candidate.contains(' ') && !candidate.contains('-') +} + +/// Data for inserting a new album_cache entry. +#[derive(Debug, Clone)] +pub struct AlbumCacheEntry { + pub artist: String, + pub album: String, + pub musicbrainz_id: Option, + pub cover_url: Option, + pub genre: Option, + pub fetched_at: String, +} + +/// Insert or update an album_cache entry. Uses INSERT OR REPLACE so that +/// re-running `enrich` on an already-cached album updates the data. +pub fn upsert_album_cache(conn: &Connection, entry: &AlbumCacheEntry) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO album_cache (artist, album, musicbrainz_id, cover_url, genre, fetched_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + entry.artist, + entry.album, + entry.musicbrainz_id, + entry.cover_url, + entry.genre, + entry.fetched_at, + ], + )?; + Ok(()) +} + +/// Cached album metadata returned by [`album_cache_meta`]. +#[derive(Debug, Clone)] +pub struct AlbumCacheMeta { + pub cover_url: Option, + pub genre: Option, + pub mbid: Option, +} + +/// Fetch cached metadata (cover URL/path and genre string) for a specific +/// `(artist, album)` pair. +pub fn album_cache_meta( + conn: &Connection, + artist: &str, + album: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT cover_url, genre, musicbrainz_id + FROM album_cache + WHERE artist = ?1 AND album = ?2 + LIMIT 1", + )?; + + let mut rows = stmt.query(params![artist, album])?; + if let Some(row) = rows.next()? { + Ok(Some(AlbumCacheMeta { + cover_url: row.get(0)?, + genre: row.get(1)?, + mbid: row.get(2)?, + })) + } else { + Ok(None) + } +} + +/// Find a cover image for a given artist for the requested period. +/// +/// The selected image is the cover of the artist's most-played album in that +/// period (ties break by listen time, then album name). This keeps mini covers +/// in top-artist rows aligned with what the listener actually played most. +/// +/// Falls back to an album-name-only cache lookup when the direct +/// (artist, album) join finds no cover, so artists whose scrobbles carry +/// a performer tag (e.g. a soloist on a classical recording) still get +/// the cover that was pinned under a different artist tag for the same album. +pub fn artist_cover(conn: &Connection, artist: &str, period: &str) -> Option { + // The cover subquery first tries the direct (artist, album) cache entry; + // if that has no cover it falls back to any cache entry for the same + // album name that does have one (same MBID-merging logic as top_albums). + let cover_expr = "COALESCE( + c.cover_url, + (SELECT ac.cover_url FROM album_cache ac + WHERE ac.album = s.album AND ac.cover_url IS NOT NULL + ORDER BY ac.artist LIMIT 1) + )"; + + let time_filter = match period_range(period) { + Some((from, to)) => format!("AND s.scrobbled_at BETWEEN '{}' AND '{}'", from, to), + None => String::new(), + }; + + let sql = format!( + "SELECT {cover_expr} AS cover_url + FROM scrobbles s + LEFT JOIN album_cache c ON c.artist = s.artist AND c.album = s.album + WHERE s.artist = ?1 + {time_filter} + AND {cover_expr} IS NOT NULL + GROUP BY s.album + ORDER BY COUNT(*) DESC, + COALESCE(SUM(s.played_duration_secs), 0) DESC, + s.album ASC + LIMIT 1" + ); + + let mut stmt = conn.prepare(&sql).ok()?; + let mut rows = stmt.query(params![artist]).ok()?; + rows.next().ok()?.and_then(|row| row.get(0).ok()) +} + +/// Return the name of the most-played album for `artist` (any period). +/// Used to ensure the artist's cover album is included in targeted enrichment. +pub fn artist_top_album(conn: &Connection, artist: &str) -> Option { + let mut stmt = conn + .prepare( + "SELECT album FROM scrobbles + WHERE artist = ?1 AND album != '' + GROUP BY album + ORDER BY COUNT(*) DESC + LIMIT 1", + ) + .ok()?; + let mut rows = stmt.query(params![artist]).ok()?; + rows.next().ok()?.and_then(|row| row.get(0).ok()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Populate an in-memory database with a mix of test data: + /// - 3 scrobbles for ††† (Crosses) across two days + /// - 2 scrobbles for Deftones + /// + /// This gives us enough data to test rankings, listen time sums, etc. + fn seed_db(conn: &Connection) { + let scrobbles = vec![ + NewScrobble { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + title: "This Is a Trick".to_string(), + track_duration_secs: Some(186), + played_duration_secs: 186, + scrobbled_at: "2026-03-19T10:00:00".to_string(), + }, + NewScrobble { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + title: "Telepathy".to_string(), + track_duration_secs: Some(215), + played_duration_secs: 200, + scrobbled_at: "2026-03-19T10:05:00".to_string(), + }, + NewScrobble { + artist: "Deftones".to_string(), + album: "White Pony".to_string(), + title: "Digital Bath".to_string(), + track_duration_secs: Some(291), + played_duration_secs: 291, + scrobbled_at: "2026-03-19T10:10:00".to_string(), + }, + NewScrobble { + artist: "Deftones".to_string(), + album: "White Pony".to_string(), + title: "Knife Prty".to_string(), + track_duration_secs: Some(290), + played_duration_secs: 250, + scrobbled_at: "2026-03-18T14:00:00".to_string(), + }, + NewScrobble { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + title: "This Is a Trick".to_string(), + track_duration_secs: Some(186), + played_duration_secs: 180, + scrobbled_at: "2026-03-12T09:00:00".to_string(), + }, + ]; + for s in &scrobbles { + insert_scrobble(conn, s).unwrap(); + } + } + + #[test] + fn test_schema_creation() { + let conn = open_memory_db().unwrap(); + // A freshly created DB should have zero scrobbles. + let ov = overview(&conn, "all").unwrap(); + assert_eq!(ov.total_scrobbles, 0); + } + + #[test] + fn test_insert_and_query() { + let conn = open_memory_db().unwrap(); + let s = NewScrobble { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + title: "This Is a Trick".to_string(), + track_duration_secs: Some(186), + played_duration_secs: 186, + scrobbled_at: "2026-03-19T10:00:00".to_string(), + }; + // First insert should get row ID 1. + let id = insert_scrobble(&conn, &s).unwrap(); + assert_eq!(id, 1); + + // Verify the overview reflects the single scrobble. + let ov = overview(&conn, "all").unwrap(); + assert_eq!(ov.total_scrobbles, 1); + assert_eq!(ov.total_listen_time_secs, 186); + assert_eq!(ov.unique_artists, 1); + } + + #[test] + fn test_top_artists() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + let artists = top_artists(&conn, "all", 10).unwrap(); + assert_eq!(artists.len(), 2); + // ††† (Crosses) has 3 plays, Deftones has 2 — sorted by play count. + assert_eq!(artists[0].artist, "††† (Crosses)"); + assert_eq!(artists[0].plays, 3); + assert_eq!(artists[1].artist, "Deftones"); + assert_eq!(artists[1].plays, 2); + } + + #[test] + fn test_top_albums() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + let albums = top_albums(&conn, "all", 10).unwrap(); + assert_eq!(albums.len(), 2); + // ††† (Crosses) album has 3 plays, White Pony has 2. + assert_eq!(albums[0].album, "††† (Crosses)"); + assert_eq!(albums[0].plays, 3); + } + + #[test] + fn test_top_tracks() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + let tracks = top_tracks(&conn, "all", 10).unwrap(); + // "This Is a Trick" appears twice (two scrobbles), others appear once each. + assert_eq!(tracks[0].title, "This Is a Trick"); + assert_eq!(tracks[0].plays, 2); + } + + #[test] + fn test_top_genres() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + // Map seeded albums to genres. + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + musicbrainz_id: None, + cover_url: None, + genre: Some("darkwave, electronic".to_string()), + fetched_at: "2026-03-19T12:00:00".to_string(), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Deftones".to_string(), + album: "White Pony".to_string(), + musicbrainz_id: None, + cover_url: None, + genre: Some("alternative metal".to_string()), + fetched_at: "2026-03-19T12:00:00".to_string(), + }, + ) + .unwrap(); + + let genres = top_genres(&conn, "all", 10).unwrap(); + assert_eq!(genres[0].genre, "darkwave"); + assert_eq!(genres[0].plays, 3); + assert_eq!(genres[1].genre, "electronic"); + assert_eq!(genres[1].plays, 3); + assert_eq!(genres[2].genre, "alternative metal"); + assert_eq!(genres[2].plays, 2); + } + + #[test] + fn test_top_genres_merges_hyphen_and_space_variants() { + let conn = open_memory_db().unwrap(); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "A".to_string(), + album: "H".to_string(), + title: "T1".to_string(), + track_duration_secs: Some(200), + played_duration_secs: 200, + scrobbled_at: "2026-03-19T10:00:00".to_string(), + }, + ) + .unwrap(); + insert_scrobble( + &conn, + &NewScrobble { + artist: "A".to_string(), + album: "S".to_string(), + title: "T2".to_string(), + track_duration_secs: Some(180), + played_duration_secs: 180, + scrobbled_at: "2026-03-19T10:01:00".to_string(), + }, + ) + .unwrap(); + + // Insert hyphenated first, spaced second. We should group both and + // prefer the spaced display label. + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "A".to_string(), + album: "H".to_string(), + musicbrainz_id: None, + cover_url: None, + genre: Some("post-rock".to_string()), + fetched_at: "2026-03-19T12:00:00".to_string(), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "A".to_string(), + album: "S".to_string(), + musicbrainz_id: None, + cover_url: None, + genre: Some("post rock".to_string()), + fetched_at: "2026-03-19T12:00:01".to_string(), + }, + ) + .unwrap(); + + let genres = top_genres(&conn, "all", 10).unwrap(); + assert_eq!(genres.len(), 1); + assert_eq!(genres[0].genre, "post rock"); + assert_eq!(genres[0].plays, 2); + assert_eq!(genres[0].listen_time_secs, 380); + } + + #[test] + fn test_top_artists_tie_breaks_by_listen_time() { + let conn = open_memory_db().unwrap(); + + // Both artists have 1 play; SlowCore should rank above FastPop because + // it has higher total listened time. + insert_scrobble( + &conn, + &NewScrobble { + artist: "FastPop".to_string(), + album: "A".to_string(), + title: "Short".to_string(), + track_duration_secs: Some(120), + played_duration_secs: 120, + scrobbled_at: "2026-03-19T11:00:00".to_string(), + }, + ) + .unwrap(); + insert_scrobble( + &conn, + &NewScrobble { + artist: "SlowCore".to_string(), + album: "B".to_string(), + title: "Long".to_string(), + track_duration_secs: Some(320), + played_duration_secs: 320, + scrobbled_at: "2026-03-19T11:05:00".to_string(), + }, + ) + .unwrap(); + + let artists = top_artists(&conn, "all", 10).unwrap(); + assert_eq!(artists.len(), 2); + assert_eq!(artists[0].artist, "SlowCore"); + assert_eq!(artists[0].plays, 1); + assert_eq!(artists[1].artist, "FastPop"); + } + + #[test] + fn test_top_albums_tie_breaks_by_listen_time() { + let conn = open_memory_db().unwrap(); + + // Both albums have 1 play; Album B should rank above Album A because + // listened time is greater. + insert_scrobble( + &conn, + &NewScrobble { + artist: "Artist".to_string(), + album: "Album A".to_string(), + title: "One".to_string(), + track_duration_secs: Some(150), + played_duration_secs: 150, + scrobbled_at: "2026-03-19T12:00:00".to_string(), + }, + ) + .unwrap(); + insert_scrobble( + &conn, + &NewScrobble { + artist: "Artist".to_string(), + album: "Album B".to_string(), + title: "Two".to_string(), + track_duration_secs: Some(240), + played_duration_secs: 240, + scrobbled_at: "2026-03-19T12:05:00".to_string(), + }, + ) + .unwrap(); + + let albums = top_albums(&conn, "all", 10).unwrap(); + assert_eq!(albums.len(), 2); + assert_eq!(albums[0].album, "Album B"); + assert_eq!(albums[0].plays, 1); + assert_eq!(albums[1].album, "Album A"); + } + + #[test] + fn test_top_albums_merges_multi_artist_same_mbid() { + // Classical albums often have different MPRIS artist tags per track + // (soloist on one, ensemble on another). Verify that top_albums groups + // them under one entry when album_cache maps one of the artist variants + // to a MBID. + let conn = open_memory_db().unwrap(); + + let album = "Vivaldi: Gloria; Nisi Dominus"; + + // Three tracks: two with artist A, one with artist B. Same album name. + for (artist, ts) in &[ + ("Choir", "2026-03-19T10:00:00"), + ("Choir", "2026-03-19T10:05:00"), + ("Soloist", "2026-03-19T10:10:00"), + ] { + insert_scrobble( + &conn, + &NewScrobble { + artist: (*artist).to_string(), + album: album.to_string(), + title: "Track".to_string(), + track_duration_secs: Some(300), + played_duration_secs: 300, + scrobbled_at: (*ts).to_string(), + }, + ) + .unwrap(); + } + + // Without any album_cache entry, both (artist, album) pairs are + // separate groups — pre-fix behaviour reproduced here. + let albums_no_cache = top_albums(&conn, "all", 10).unwrap(); + assert_eq!(albums_no_cache.len(), 2, "without cache: two separate groups"); + + // Pin the album via album_cache for "Choir". The MBID lookup in + // top_albums will now resolve "Soloist"'s rows to the same MBID. + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Choir".to_string(), + album: album.to_string(), + musicbrainz_id: Some("test-mbid-vivaldi".to_string()), + cover_url: Some("covers/test.jpg".to_string()), + genre: Some("classical".to_string()), + fetched_at: "2026-03-19T12:00:00".to_string(), + }, + ) + .unwrap(); + + let albums = top_albums(&conn, "all", 10).unwrap(); + assert_eq!(albums.len(), 1, "with cache: merged into one group"); + assert_eq!(albums[0].plays, 3); + assert_eq!(albums[0].album, album); + // The returned artist should be the cached one so cover lookup works. + assert_eq!(albums[0].artist, "Choir"); + } + + #[test] + fn test_top_tracks_tie_breaks_by_listen_time() { + let conn = open_memory_db().unwrap(); + + // Both tracks have 1 play; "Epic" should rank above "Brief" because + // listened time is greater. + insert_scrobble( + &conn, + &NewScrobble { + artist: "Band".to_string(), + album: "LP".to_string(), + title: "Brief".to_string(), + track_duration_secs: Some(100), + played_duration_secs: 100, + scrobbled_at: "2026-03-19T13:00:00".to_string(), + }, + ) + .unwrap(); + insert_scrobble( + &conn, + &NewScrobble { + artist: "Band".to_string(), + album: "LP".to_string(), + title: "Epic".to_string(), + track_duration_secs: Some(360), + played_duration_secs: 360, + scrobbled_at: "2026-03-19T13:05:00".to_string(), + }, + ) + .unwrap(); + + let tracks = top_tracks(&conn, "all", 10).unwrap(); + assert_eq!(tracks.len(), 2); + assert_eq!(tracks[0].title, "Epic"); + assert_eq!(tracks[0].plays, 1); + assert_eq!(tracks[1].title, "Brief"); + } + + #[test] + fn test_recent_scrobbles() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + let recent = recent_scrobbles(&conn, "all", 3).unwrap(); + assert_eq!(recent.len(), 3); + // Results are ordered by scrobbled_at DESC — most recent first. + assert_eq!(recent[0].scrobbled_at, "2026-03-19T10:10:00"); + } + + #[test] + fn test_overview_listen_time() { + let conn = open_memory_db().unwrap(); + seed_db(&conn); + + let ov = overview(&conn, "all").unwrap(); + assert_eq!(ov.total_scrobbles, 5); + // Total listen time: 186 + 200 + 291 + 250 + 180 = 1107 seconds. + assert_eq!(ov.total_listen_time_secs, 1107); + assert_eq!(ov.unique_artists, 2); + } + + #[test] + fn test_insert_missing_duration() { + // Verify that tracks with unknown duration can be stored and retrieved. + let conn = open_memory_db().unwrap(); + let s = NewScrobble { + artist: "Unknown".to_string(), + album: "".to_string(), + title: "Mystery".to_string(), + track_duration_secs: None, + played_duration_secs: 240, + scrobbled_at: "2026-03-19T12:00:00".to_string(), + }; + insert_scrobble(&conn, &s).unwrap(); + let recent = recent_scrobbles(&conn, "all", 1).unwrap(); + assert!(recent[0].track_duration_secs.is_none()); + assert_eq!(recent[0].played_duration_secs, 240); + } + + #[test] + fn test_latest_scrobble_at() { + let conn = open_memory_db().unwrap(); + assert!(latest_scrobble_at(&conn).unwrap().is_none()); + + insert_scrobble( + &conn, + &NewScrobble { + artist: "A".to_string(), + album: "X".to_string(), + title: "T1".to_string(), + track_duration_secs: Some(100), + played_duration_secs: 100, + scrobbled_at: "2026-03-19T10:00:00".to_string(), + }, + ) + .unwrap(); + insert_scrobble( + &conn, + &NewScrobble { + artist: "A".to_string(), + album: "Y".to_string(), + title: "T2".to_string(), + track_duration_secs: Some(120), + played_duration_secs: 120, + scrobbled_at: "2026-03-19T12:34:56".to_string(), + }, + ) + .unwrap(); + + assert_eq!( + latest_scrobble_at(&conn).unwrap().as_deref(), + Some("2026-03-19T12:34:56") + ); + } + + #[test] + fn test_artist_cover_uses_most_played_album_and_period() { + let conn = open_memory_db().unwrap(); + + let now = chrono::Local::now().naive_local(); + let old_day = (now - chrono::Duration::days(10)) + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + let recent = (now - chrono::Duration::minutes(10)) + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + + // Older period: two plays on "Old Album". + for i in 0..2 { + insert_scrobble( + &conn, + &NewScrobble { + artist: "Artist X".to_string(), + album: "Old Album".to_string(), + title: format!("Old Song {}", i), + track_duration_secs: Some(180), + played_duration_secs: 180, + scrobbled_at: old_day.clone(), + }, + ) + .unwrap(); + } + + // Today: one play on "New Album". + insert_scrobble( + &conn, + &NewScrobble { + artist: "Artist X".to_string(), + album: "New Album".to_string(), + title: "New Song".to_string(), + track_duration_secs: Some(200), + played_duration_secs: 200, + scrobbled_at: recent, + }, + ) + .unwrap(); + + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Artist X".to_string(), + album: "Old Album".to_string(), + musicbrainz_id: None, + cover_url: Some("covers/old.jpg".to_string()), + genre: None, + fetched_at: now.format("%Y-%m-%dT%H:%M:%S").to_string(), + }, + ) + .unwrap(); + upsert_album_cache( + &conn, + &AlbumCacheEntry { + artist: "Artist X".to_string(), + album: "New Album".to_string(), + musicbrainz_id: None, + cover_url: Some("covers/new.jpg".to_string()), + genre: None, + fetched_at: now.format("%Y-%m-%dT%H:%M:%S").to_string(), + }, + ) + .unwrap(); + + // Across all time, Old Album wins with more plays. + assert_eq!( + artist_cover(&conn, "Artist X", "all").as_deref(), + Some("covers/old.jpg") + ); + // For today, only New Album is present. + assert_eq!( + artist_cover(&conn, "Artist X", "today").as_deref(), + Some("covers/new.jpg") + ); + } +} diff --git a/src/enrich.rs b/src/enrich.rs new file mode 100644 index 0000000..0804a34 --- /dev/null +++ b/src/enrich.rs @@ -0,0 +1,1283 @@ +//! Enrich module — fetches album metadata from MusicBrainz and cover art +//! from the Cover Art Archive. +//! +//! This module is used in two ways: +//! - Explicitly via the `enrich` CLI subcommand. +//! - Automatically when `report --html` is used (to ensure covers are +//! available before generating the HTML). +//! +//! The enrichment process: +//! +//! 1. Queries the database for all unique (artist, album) pairs that don't +//! yet have an entry in `album_cache`. +//! 2. For each, searches the MusicBrainz API for matching releases. +//! 3. If found, fetches genre/tag information from MusicBrainz. +//! 4. Downloads the front cover image from the Cover Art Archive, trying: +//! a. The specific release endpoint +//! b. The release-group endpoint (covers any edition of the album) +//! c. Other candidate releases from the search results +//! 5. Stores the metadata in `album_cache` and the cover image on disk. +//! +//! ## Rate limiting +//! +//! MusicBrainz requires a maximum of 1 request per second. We enforce this +//! by sleeping between API calls. The Cover Art Archive has no documented +//! rate limit, but we apply the same 1 req/sec policy to be a good citizen. +//! +//! ## API endpoints used +//! +//! - MusicBrainz release search: +//! `https://musicbrainz.org/ws/2/release/?query=artist:{}&release:{}&fmt=json` +//! +//! - MusicBrainz release details (for tags/genres and release-group ID): +//! `https://musicbrainz.org/ws/2/release/{mbid}?inc=genres+tags+release-groups&fmt=json` +//! +//! - Cover Art Archive (release-specific): +//! `https://coverartarchive.org/release/{mbid}/front` +//! +//! - Cover Art Archive (release-group, any edition): +//! `https://coverartarchive.org/release-group/{rgid}/front` +//! +//! ## User-Agent +//! +//! MusicBrainz requires a descriptive User-Agent header. We use: +//! `mpris-scrobbler/0.1.0 (https://github.com/arturmeski/mpris_scrobbler)` + +use crate::db::{self, AlbumCacheEntry}; +use image::codecs::jpeg::JpegEncoder; +use reqwest::blocking::Client; +use rusqlite::Connection; +use serde::Deserialize; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +/// User-Agent string sent with all HTTP requests to MusicBrainz. +/// Required by MusicBrainz API policy: +/// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting +const USER_AGENT: &str = "mpris-scrobbler/0.1.0 (https://github.com/arturmeski/mpris_scrobbler)"; + +/// Build the shared HTTP client used for all MusicBrainz and Cover Art Archive requests. +fn build_client() -> Client { + Client::builder() + .user_agent(USER_AGENT) + // Follow redirects (Cover Art Archive uses them). + .redirect(reqwest::redirect::Policy::limited(5)) + .timeout(Duration::from_secs(30)) + .build() + .expect("Failed to build HTTP client") +} + +/// Return the current local time formatted as an ISO 8601 datetime string. +fn now_str() -> String { + chrono::Local::now() + .naive_local() + .format("%Y-%m-%dT%H:%M:%S") + .to_string() +} + +/// Minimum delay between API requests to respect MusicBrainz rate limits. +const RATE_LIMIT_DELAY: Duration = Duration::from_millis(1100); + +/// Maximum dimension (width or height) for cached cover art in pixels. +/// Images larger than this are downscaled before saving. +const MAX_COVER_DIM: u32 = 500; + +/// JPEG quality used when re-encoding resized cover art (0–100). +const COVER_JPEG_QUALITY: u8 = 85; + +// --------------------------------------------------------------------------- +// MusicBrainz API response types (only the fields we need) +// --------------------------------------------------------------------------- + +/// Response from the MusicBrainz release search endpoint. +#[derive(Debug, Deserialize)] +struct MbReleaseSearchResponse { + releases: Vec, +} + +/// A single release from MusicBrainz search results. +#[derive(Debug, Deserialize)] +struct MbRelease { + /// The MusicBrainz release ID (UUID). + id: String, + /// Release title. + #[allow(dead_code)] + title: String, + /// Match score (0-100) indicating how well this result matches the query. + score: u32, +} + +/// Response from the MusicBrainz recording search endpoint. +#[derive(Debug, Deserialize)] +struct MbRecordingSearchResponse { + #[serde(default)] + recordings: Vec, +} + +/// A single recording result from MusicBrainz. +#[derive(Debug, Deserialize)] +struct MbRecording { + /// Match score (0-100) indicating how well this result matches the query. + score: u32, + /// Releases containing this recording. + #[serde(default)] + releases: Vec, +} + +/// A lightweight release reference from recording results. +#[derive(Debug, Deserialize)] +struct MbRecordingRelease { + id: String, +} + +/// Response from the MusicBrainz release details endpoint (with genres/tags +/// and release-group info). +#[derive(Debug, Deserialize)] +struct MbReleaseDetails { + /// Genre tags associated with the release. + #[serde(default)] + genres: Vec, + /// User-submitted tags (broader than genres, includes styles, moods, etc.). + #[serde(default)] + tags: Vec, + /// The release-group this release belongs to. All editions of the same + /// album share one release-group. + #[serde(rename = "release-group")] + release_group: Option, +} + +/// Response from the MusicBrainz release-group details endpoint. +#[derive(Debug, Deserialize)] +struct MbReleaseGroupDetails { + /// Genre tags associated with the release-group. + #[serde(default)] + genres: Vec, + /// User-submitted tags for the release-group. + #[serde(default)] + tags: Vec, +} + +/// A release-group from MusicBrainz. Groups all editions/pressings of +/// the same album under a single ID. +#[derive(Debug, Deserialize)] +struct MbReleaseGroup { + id: String, +} + +/// A genre entry from MusicBrainz. +#[derive(Debug, Deserialize)] +struct MbGenre { + name: String, +} + +/// A tag entry from MusicBrainz (user-submitted, with vote count). +#[derive(Debug, Deserialize)] +struct MbTag { + name: String, + /// Number of votes for this tag. Higher = more reliable. + #[serde(default)] + count: i32, +} + +// --------------------------------------------------------------------------- +// Cover art directory +// --------------------------------------------------------------------------- + +/// Get the directory where cover art images are stored, creating it if needed. +/// +/// Default: `~/.local/share/mpris-scrobbler/covers/` +/// (respects `$XDG_DATA_HOME`) +pub fn covers_dir() -> PathBuf { + let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { + let home = std::env::var("HOME").expect("HOME not set"); + format!("{}/.local/share", home) + }); + let dir = PathBuf::from(format!("{}/mpris-scrobbler/covers", data_dir)); + fs::create_dir_all(&dir).expect("Failed to create covers directory"); + dir +} + +// --------------------------------------------------------------------------- +// MusicBrainz API functions +// --------------------------------------------------------------------------- + +/// Search MusicBrainz for releases matching the given artist and album name. +/// +/// Returns a list of MusicBrainz release IDs (MBIDs) with score >= 80, +/// ordered by score descending. Returns up to 5 candidates so we can try +/// multiple releases for cover art. +/// +/// Uses progressively looser search strategies while keeping the artist field +/// exact to avoid false matches: +/// 1. Exact Lucene field search: `artist:"X" AND release:"Y"` +/// 2. Retry with parenthetical/bracketed suffixes removed +/// (e.g., "If (Killing Eve)" -> "If") +/// 3. Progressively shorten cleaned titles by dropping trailing words +/// 4. Retry with artist aliases (e.g. "††† (Crosses)" -> "Crosses") +/// 5. Fallback via recording search when release search yields nothing +fn search_releases(client: &Client, artist: &str, album: &str) -> Vec { + let artist_variants = artist_search_variants(artist); + let album_variants = album_search_variants(album); + if artist_variants.is_empty() || album_variants.is_empty() { + return vec![]; + } + + // Strategy 1/2/3: exact artist, then album variants. + for (idx, variant) in album_variants.iter().enumerate() { + if idx > 0 { + eprintln!(" Retrying with alternate album name: \"{}\"...", variant); + thread::sleep(RATE_LIMIT_DELAY); + } + let query = format!( + "artist:\"{}\" AND release:\"{}\"", + artist_variants[0], variant + ); + let results = run_mb_search(client, &query); + if !results.is_empty() { + return results; + } + } + + // Strategy 4: retry with alternate artist aliases and relaxed score. + for alt_artist in artist_variants.iter().skip(1) { + eprintln!( + " Retrying with alternate artist name: \"{}\"...", + alt_artist + ); + for (idx, variant) in album_variants.iter().enumerate() { + if idx > 0 { + eprintln!(" Retrying with alternate album name: \"{}\"...", variant); + } + thread::sleep(RATE_LIMIT_DELAY); + let query = format!("artist:\"{}\" AND release:\"{}\"", alt_artist, variant); + let results = run_mb_search_with_min_score(client, &query, 60); + if !results.is_empty() { + return results; + } + } + } + + // Strategy 5: fallback via recording search. This helps when metadata + // contains track-ish album names like "If (Killing Eve)". + let recording_query = album_variants + .first() + .cloned() + .unwrap_or_else(|| album.to_string()); + eprintln!( + " Retrying via recording search for \"{}\"...", + recording_query + ); + thread::sleep(RATE_LIMIT_DELAY); + let mut recording_results = run_mb_recording_search( + client, + &format!( + "artist:\"{}\" AND recording:\"{}\"", + artist_variants[0], recording_query + ), + 60, + ); + if !recording_results.is_empty() { + return recording_results; + } + if let Some(shortest) = album_variants.last() { + eprintln!( + " Retrying via recording search for shorter name \"{}\"...", + shortest + ); + thread::sleep(RATE_LIMIT_DELAY); + recording_results = run_mb_recording_search( + client, + &format!( + "artist:\"{}\" AND recording:\"{}\"", + artist_variants[0], shortest + ), + 50, + ); + if !recording_results.is_empty() { + return recording_results; + } + } + + // Strategy 6: title-only release search — no artist constraint. + // Helps when the scrobbled artist doesn't match MusicBrainz release credits + // (common for classical recordings credited to performers rather than composer). + // Only try the first two variants (original + bracket-stripped): single-word + // or two-word truncations are too broad without an artist anchor and risk + // returning wrong results at score 85. + for (idx, variant) in album_variants.iter().take(2).enumerate() { + if idx > 0 { + eprintln!(" Retrying title-only with stripped name: \"{}\"...", variant); + } else { + eprintln!(" Retrying with title-only search (no artist constraint)..."); + } + thread::sleep(RATE_LIMIT_DELAY); + let query = format!("release:\"{}\"", variant); + let results = run_mb_search_with_min_score(client, &query, 85); + if !results.is_empty() { + return results; + } + } + + vec![] +} + +/// Generate search variants for an album title, ordered from strict to loose. +/// +/// Examples: +/// - "If (Killing Eve)" -> ["If (Killing Eve)", "If"] +/// - "Purple Rain Deluxe Edition" -> +/// ["Purple Rain Deluxe Edition", "Purple Rain Deluxe", "Purple Rain", "Purple"] +fn album_search_variants(album: &str) -> Vec { + let original = trim_title_edges(&normalise_spaces(album)); + if original.is_empty() { + return vec![]; + } + + let mut variants: Vec = vec![original.clone()]; + + // Remove parenthetical/bracketed suffixes like "(Deluxe)" or "[Remastered]". + let stripped = trim_title_edges(&strip_bracketed_segments(&original)); + if !stripped.is_empty() && stripped != original { + variants.push(stripped.clone()); + } + + // Progressively shorten non-empty cleaned candidates down to a single word. + for base in [original, stripped] { + if base.is_empty() { + continue; + } + // Avoid generating malformed cut-offs like "If (Killing" by only + // shortening titles that don't contain bracketed segments. + if base.contains('(') || base.contains(')') || base.contains('[') || base.contains(']') { + continue; + } + let words: Vec<&str> = base.split_whitespace().collect(); + if words.len() > 1 { + for end in (1..words.len()).rev() { + variants.push(words[..end].join(" ")); + } + } + } + + // Deduplicate while keeping insertion order. + let mut unique: Vec = Vec::new(); + for v in variants { + let cleaned = trim_title_edges(&v); + if !cleaned.is_empty() && !unique.iter().any(|u| u == &cleaned) { + unique.push(cleaned); + } + } + unique +} + +/// Generate artist-name variants for better matching. +/// +/// Example: "††† (Crosses)" -> ["††† (Crosses)", "†††", "Crosses"] +fn artist_search_variants(artist: &str) -> Vec { + let original = normalise_spaces(artist); + if original.is_empty() { + return vec![]; + } + + let mut variants = vec![original.clone()]; + + // Prefer parenthetical alias if present. + if let (Some(start), Some(end)) = (original.find('('), original.rfind(')')) + && start < end + { + let before = trim_title_edges(&normalise_spaces(&original[..start])); + if !before.is_empty() { + variants.push(before); + } + let inside = normalise_spaces(&original[start + 1..end]); + if !inside.is_empty() { + variants.push(inside); + } + } + + // Keep alnum/space only as a final alias. + let cleaned: String = original + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace()) + .collect(); + let cleaned = normalise_spaces(&cleaned); + if !cleaned.is_empty() { + variants.push(cleaned); + } + + let mut unique = Vec::new(); + for v in variants { + if !unique.iter().any(|u| u == &v) { + unique.push(v); + } + } + unique +} + +/// Collapse all whitespace runs to single spaces and trim. +fn normalise_spaces(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Trim common punctuation/noise from both title ends. +fn trim_title_edges(s: &str) -> String { + s.trim_matches(|c: char| { + c.is_whitespace() + || matches!( + c, + ',' | '.' | ';' | ':' | '!' | '?' | '-' | '_' | '"' | '\'' + ) + }) + .to_string() +} + +/// Remove content in balanced `(...)` and `[...]` segments. +/// +/// Unbalanced brackets are preserved as-is. +fn strip_bracketed_segments(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut paren_depth = 0i32; + let mut square_depth = 0i32; + + for ch in s.chars() { + match ch { + '(' => { + paren_depth += 1; + } + ')' => { + if paren_depth > 0 { + paren_depth -= 1; + } else { + out.push(ch); + } + } + '[' => { + square_depth += 1; + } + ']' => { + if square_depth > 0 { + square_depth -= 1; + } else { + out.push(ch); + } + } + _ => { + if paren_depth == 0 && square_depth == 0 { + out.push(ch); + } + } + } + } + + normalise_spaces(&out) +} + +/// Execute a MusicBrainz release search with the given query string. +/// Returns release IDs with score >= 80. +fn run_mb_search(client: &Client, query: &str) -> Vec { + run_mb_search_with_min_score(client, query, 80) +} + +/// Execute a MusicBrainz release search with a configurable score floor. +fn run_mb_search_with_min_score(client: &Client, query: &str, min_score: u32) -> Vec { + let url = format!( + "https://musicbrainz.org/ws/2/release/?query={}&fmt=json&limit=5", + urlencoded(query) + ); + + let response = match client.get(&url).send() { + Ok(r) => r, + Err(_) => return vec![], + }; + + if !response.status().is_success() { + eprintln!( + " [warn] MusicBrainz search returned HTTP {}", + response.status() + ); + return vec![]; + } + + let body: MbReleaseSearchResponse = match response.json() { + Ok(b) => b, + Err(_) => return vec![], + }; + + body.releases + .into_iter() + .filter(|r| r.score >= min_score) + .map(|r| r.id) + .collect() +} + +/// Execute a MusicBrainz recording search and return release IDs from matches. +fn run_mb_recording_search(client: &Client, query: &str, min_score: u32) -> Vec { + let url = format!( + "https://musicbrainz.org/ws/2/recording/?query={}&fmt=json&limit=10", + urlencoded(query) + ); + + let response = match client.get(&url).send() { + Ok(r) => r, + Err(_) => return vec![], + }; + + if !response.status().is_success() { + eprintln!( + " [warn] MusicBrainz recording search returned HTTP {}", + response.status() + ); + return vec![]; + } + + let body: MbRecordingSearchResponse = match response.json() { + Ok(b) => b, + Err(_) => return vec![], + }; + + let mut ids: Vec = Vec::new(); + for rec in body.recordings.into_iter().filter(|r| r.score >= min_score) { + for rel in rec.releases { + if !ids.iter().any(|id| id == &rel.id) { + ids.push(rel.id); + } + } + } + ids +} + +/// Fetch release details from MusicBrainz, including genres/tags and the +/// release-group ID. +/// +/// Returns `(genres_string, release_group_id)`. +fn fetch_release_details(client: &Client, mbid: &str) -> (Option, Option) { + let url = format!( + "https://musicbrainz.org/ws/2/release/{}?inc=genres+tags+release-groups&fmt=json", + mbid + ); + + let response = match client.get(&url).send() { + Ok(r) => r, + Err(_) => return (None, None), + }; + + if !response.status().is_success() { + return (None, None); + } + + let details: MbReleaseDetails = match response.json() { + Ok(d) => d, + Err(_) => return (None, None), + }; + + // Extract release-group ID now so we can use it as a genre fallback. + let rgid = details.release_group.map(|rg| rg.id); + + // Extract genres: prefer curated release genres, then release tags, + // then release-group genres/tags. + let mut genre = pick_genre_string(details.genres, details.tags); + if genre.is_none() + && let Some(ref release_group_id) = rgid + { + thread::sleep(RATE_LIMIT_DELAY); + genre = fetch_release_group_genre(client, release_group_id); + } + + (genre, rgid) +} + +/// Fetch release-group genres/tags and return a genre string if available. +fn fetch_release_group_genre(client: &Client, release_group_id: &str) -> Option { + let url = format!( + "https://musicbrainz.org/ws/2/release-group/{}?inc=genres+tags&fmt=json", + release_group_id + ); + + let response = client.get(&url).send().ok()?; + if !response.status().is_success() { + return None; + } + + let details: MbReleaseGroupDetails = response.json().ok()?; + pick_genre_string(details.genres, details.tags) +} + +/// Non-genre MusicBrainz tags to discard. +/// +/// MusicBrainz community tags include technical metadata (encodings, years, +/// language names, format codes) that are meaningless as genre labels. +const TAG_BLOCKLIST: &[&str] = &[ + // Languages + "english", "french", "german", "spanish", "italian", "portuguese", + "japanese", "korean", "chinese", "russian", "swedish", "norwegian", + "danish", "dutch", "polish", "finnish", "czech", "hungarian", "turkish", + "arabic", "hebrew", "greek", "romanian", "ukrainian", "catalan", + // Technical / format descriptors + "isrc", "cd-text", "asin", "barcode", "album", "single", "ep", + "compilation", "soundtrack", "live", "instrumental", "digital", + "remaster", "remastered", "deluxe", "bonus", "stereo", "mono", +]; + +/// Return true if a MusicBrainz tag looks like a real genre label. +/// +/// Rejects: +/// - Tags containing digits (years like "2022", encodings like "iso-8859-1") +/// - Known non-genre technical / language tags +fn is_genre_tag(tag: &str) -> bool { + if tag.chars().any(|c| c.is_ascii_digit()) { + return false; + } + let lower = tag.to_ascii_lowercase(); + !TAG_BLOCKLIST.contains(&lower.as_str()) +} + +/// Build a comma-separated genre string from MusicBrainz genre/tag lists. +/// +/// Preference order: +/// 1. Curated `genres` +/// 2. Community `tags` (filtered to entries with at least one vote and +/// passing the genre-tag heuristic to strip technical metadata) +fn pick_genre_string(genres: Vec, tags: Vec) -> Option { + let mut names: Vec = genres.into_iter().map(|g| g.name).collect(); + if names.is_empty() { + names = tags + .into_iter() + .filter(|t| t.count >= 1 && is_genre_tag(&t.name)) + .map(|t| t.name) + .collect(); + } + if names.is_empty() { + None + } else { + Some(names.join(", ")) + } +} + +/// Try to download cover art, using multiple fallback strategies: +/// +/// 1. Try the specific release endpoint: `/release/{mbid}/front` +/// 2. If that fails and we have a release-group ID, try: `/release-group/{rgid}/front` +/// (this returns cover art from any edition of the album) +/// +/// Returns the local file path on success, or None if no art is found anywhere. +fn download_cover_with_fallback( + client: &Client, + mbid: &str, + release_group_id: Option<&str>, + covers_dir: &Path, +) -> Option { + // Use the MBID as the filename regardless of which endpoint succeeds, + // so we have a consistent cache key. + let dest = covers_dir.join(format!("{}.jpg", mbid)); + + // Skip download if we already have the file from a previous run. + if dest.exists() { + return Some(dest.to_string_lossy().to_string()); + } + + // Strategy 1: Try the specific release. + let release_url = format!("https://coverartarchive.org/release/{}/front", mbid); + if let Some(path) = try_download(client, &release_url, &dest) { + eprintln!(" Cover downloaded (from release)."); + return Some(path); + } + + // Strategy 2: Try the release-group (any edition of the album). + if let Some(rgid) = release_group_id { + let rg_url = format!("https://coverartarchive.org/release-group/{}/front", rgid); + thread::sleep(RATE_LIMIT_DELAY); + if let Some(path) = try_download(client, &rg_url, &dest) { + eprintln!(" Cover downloaded (from release-group)."); + return Some(path); + } + } + + None +} + +/// Decode image bytes, downscale if either dimension exceeds `MAX_COVER_DIM`, +/// and re-encode as JPEG at `COVER_JPEG_QUALITY`. +/// +/// Returns the processed JPEG bytes, or `None` if decoding or encoding fails. +/// Images already within the size limit are still re-encoded to JPEG so that +/// format is consistent regardless of what the Cover Art Archive sent. +fn resize_cover_bytes(bytes: &[u8]) -> Option> { + let img = image::load_from_memory(bytes).ok()?; + + // Downscale only if the image exceeds the target dimension on either axis. + let img = if img.width() > MAX_COVER_DIM || img.height() > MAX_COVER_DIM { + img.resize( + MAX_COVER_DIM, + MAX_COVER_DIM, + image::imageops::FilterType::Lanczos3, + ) + } else { + img + }; + + let mut out: Vec = Vec::new(); + img.write_with_encoder(JpegEncoder::new_with_quality(&mut out, COVER_JPEG_QUALITY)) + .ok()?; + Some(out) +} + +/// Attempt to download an image from the given URL and save it to `dest`. +/// The image is resized to at most `MAX_COVER_DIM` pixels per side before +/// being written, to keep cached covers from consuming excessive disk space. +/// Returns the local file path on success, or None on failure. +fn try_download(client: &Client, url: &str, dest: &Path) -> Option { + let response = client.get(url).send().ok()?; + + if !response.status().is_success() { + return None; + } + + let bytes = response.bytes().ok()?; + + let processed = match resize_cover_bytes(&bytes) { + Some(b) => b, + None => { + eprintln!(" [warn] Could not decode cover image; skipping."); + return None; + } + }; + + let mut file = fs::File::create(dest).ok()?; + file.write_all(&processed).ok()?; + + Some(dest.to_string_lossy().to_string()) +} + +// --------------------------------------------------------------------------- +// URL encoding helper +// --------------------------------------------------------------------------- + +/// Simple URL encoding for query parameters. +/// Encodes characters that are not unreserved per RFC 3986. +fn urlencoded(s: &str) -> String { + let mut result = String::with_capacity(s.len() * 2); + for c in s.chars() { + match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), + _ => { + for byte in c.to_string().as_bytes() { + result.push_str(&format!("%{:02X}", byte)); + } + } + } + } + result +} + +// --------------------------------------------------------------------------- +// Main enrichment function +// --------------------------------------------------------------------------- + +/// Run the enrichment process: find all uncached albums and fetch their +/// metadata and cover art from MusicBrainz / Cover Art Archive. +/// +/// Progress is printed to stderr so the user can see what's happening. +/// +/// # Arguments +/// - `conn` — SQLite database connection +/// - `force` — if true, re-fetch all albums even if already cached +/// - `quiet` — if true, suppress the "nothing to do" hint (used when called +/// from `report --html` where the user didn't explicitly ask for enrichment) +/// Enrich only the albums in `needed` that aren't already cached. +/// Used by `report --html` so we fetch covers only for what the report +/// will actually display, rather than the entire scrobble library. +pub fn run_enrich_targeted( + conn: &Connection, + needed: &std::collections::HashSet<(String, String)>, + quiet: bool, +) { + let all_uncached = match db::uncached_albums(conn) { + Ok(v) => v, + Err(e) => { + eprintln!("[error] Failed to query uncached albums: {}", e); + return; + } + }; + // Filter uncached list to only those the report needs. + let albums: Vec = all_uncached + .into_iter() + .filter(|a| needed.contains(&(a.artist.clone(), a.album.clone()))) + .collect(); + run_enrich_albums(conn, albums, quiet); +} + +pub fn run_enrich(conn: &Connection, force: bool, quiet: bool) { + if force { + conn.execute("DELETE FROM album_cache", []) + .expect("Failed to clear album_cache"); + eprintln!("Cleared album cache (force mode)."); + } + let albums = db::uncached_albums(conn).expect("Failed to query uncached albums"); + run_enrich_albums(conn, albums, quiet); +} + +/// Inner enrichment loop shared by `run_enrich` and `run_enrich_targeted`. +fn run_enrich_albums(conn: &Connection, albums: Vec, quiet: bool) { + if albums.is_empty() { + if !quiet { + eprintln!("All albums are already cached. Nothing to do."); + eprintln!("Use --force to re-fetch everything."); + } + return; + } + + let client = build_client(); + let covers = covers_dir(); + + eprintln!("Found {} album(s) to enrich.", albums.len()); + + let mut success_count = 0; + let mut cover_count = 0; + + for (i, album) in albums.iter().enumerate() { + eprintln!( + "[{}/{}] {} - {}", + i + 1, + albums.len(), + album.artist, + album.album + ); + + // Step 1: Search MusicBrainz for matching releases. + thread::sleep(RATE_LIMIT_DELAY); + let candidates = search_releases(&client, &album.artist, &album.album); + + if candidates.is_empty() { + eprintln!(" No match found on MusicBrainz."); + + // Cache a "no match" entry so we don't re-query next time. + let entry = AlbumCacheEntry { + artist: album.artist.clone(), + album: album.album.clone(), + musicbrainz_id: None, + cover_url: None, + genre: None, + fetched_at: now_str(), + }; + match db::upsert_album_cache(conn, &entry) { + Ok(_) => success_count += 1, + Err(e) => eprintln!(" [error] Failed to cache: {}", e), + } + continue; + } + + // Use the best candidate (first in the list) for metadata. + let primary_mbid = &candidates[0]; + eprintln!(" Found MBID: {}", primary_mbid); + + // Step 2: Fetch genres/tags and release-group ID from the primary release. + thread::sleep(RATE_LIMIT_DELAY); + let (genre, release_group_id) = fetch_release_details(&client, primary_mbid); + if let Some(ref g) = genre { + eprintln!(" Genres: {}", g); + } + + // Step 3: Try to download cover art with fallback strategies. + thread::sleep(RATE_LIMIT_DELAY); + let mut cover = download_cover_with_fallback( + &client, + primary_mbid, + release_group_id.as_deref(), + &covers, + ); + + // Step 4: If still no cover, try other candidate releases. + if cover.is_none() && candidates.len() > 1 { + eprintln!( + " No cover from primary release, trying {} other candidate(s)...", + candidates.len() - 1 + ); + for alt_mbid in &candidates[1..] { + thread::sleep(RATE_LIMIT_DELAY); + let alt_url = format!("https://coverartarchive.org/release/{}/front", alt_mbid); + let dest = covers.join(format!("{}.jpg", primary_mbid)); + if let Some(path) = try_download(&client, &alt_url, &dest) { + eprintln!(" Cover downloaded (from alternate release {}).", alt_mbid); + cover = Some(path); + break; + } + } + } + + if cover.is_some() { + cover_count += 1; + } else { + eprintln!(" No cover art available from any source."); + } + + // Step 5: Store the result in album_cache. + let entry = AlbumCacheEntry { + artist: album.artist.clone(), + album: album.album.clone(), + musicbrainz_id: Some(primary_mbid.clone()), + cover_url: cover, + genre, + fetched_at: now_str(), + }; + + match db::upsert_album_cache(conn, &entry) { + Ok(_) => success_count += 1, + Err(e) => eprintln!(" [error] Failed to cache: {}", e), + } + } + + eprintln!(); + eprintln!("Enrichment complete:"); + eprintln!(" Albums processed: {}", albums.len()); + eprintln!(" Successfully cached: {}", success_count); + eprintln!(" Covers downloaded: {}", cover_count); + eprintln!(" Covers directory: {}", covers.display()); +} + +/// Manually pin a MusicBrainz release to an (artist, album) pair. +/// +/// Fetches genres and cover art for `mbid`, then upserts the result into +/// `album_cache`, overwriting any existing entry. Called by `pin-album`. +pub fn enrich_by_mbid( + conn: &Connection, + artist: &str, + album: &str, + mbid: &str, + cover_url_override: Option<&str>, +) { + let client = build_client(); + let covers = covers_dir(); + + eprintln!("Fetching release details for MBID {}...", mbid); + let (genre, release_group_id) = fetch_release_details(&client, mbid); + + if let Some(ref g) = genre { + eprintln!(" Genres: {}", g); + } + + let cover = if let Some(url) = cover_url_override { + // User supplied a direct image URL — download it using the MBID as the + // filename so it fits into the same local cache as CAA covers. + // We proceed even if MusicBrainz returned no metadata (genre/rgid): + // the user explicitly asked for this cover, so MusicBrainz being + // unreachable or the release having no genres is not a reason to bail. + if genre.is_none() && release_group_id.is_none() { + eprintln!( + " [warn] MusicBrainz returned no data for MBID {}. \ + Proceeding with cover download only.", + mbid + ); + } + eprintln!(" Downloading cover from provided URL..."); + let dest = covers.join(format!("{}.jpg", mbid)); + let result = try_download(&client, url, &dest); + if result.is_some() { + eprintln!(" Cover downloaded."); + } else { + eprintln!(" [warn] Failed to download cover from provided URL."); + } + result + } else { + // Without a user-supplied URL we depend entirely on MusicBrainz / + // Cover Art Archive. If MB returned nothing the MBID is likely wrong. + if genre.is_none() && release_group_id.is_none() { + eprintln!( + " [error] No data returned for MBID {}. It may be invalid or not found.", + mbid + ); + return; + } + let result = + download_cover_with_fallback(&client, mbid, release_group_id.as_deref(), &covers); + if result.is_some() { + eprintln!(" Cover downloaded."); + } else { + eprintln!(" No cover art available on Cover Art Archive."); + } + result + }; + + let entry = AlbumCacheEntry { + artist: artist.to_string(), + album: album.to_string(), + musicbrainz_id: Some(mbid.to_string()), + cover_url: cover, + genre, + fetched_at: now_str(), + }; + + match db::upsert_album_cache(conn, &entry) { + Ok(_) => eprintln!( + "Pinned \"{}\" by \"{}\" to MBID {}.", + album, artist, mbid + ), + Err(e) => eprintln!("[error] Failed to update cache: {}", e), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_urlencoded_simple() { + assert_eq!(urlencoded("hello"), "hello"); + } + + #[test] + fn test_urlencoded_spaces_and_special() { + // Spaces should be encoded as %20. + assert_eq!(urlencoded("hello world"), "hello%20world"); + // Quotes should be encoded. + assert_eq!(urlencoded("\"test\""), "%22test%22"); + } + + #[test] + fn test_urlencoded_unicode() { + // Unicode characters like † should be percent-encoded byte by byte. + let encoded = urlencoded("†"); + assert!(encoded.starts_with('%')); + // † is U+2020, encoded in UTF-8 as E2 80 A0. + assert_eq!(encoded, "%E2%80%A0"); + } + + #[test] + fn test_urlencoded_preserves_unreserved() { + // RFC 3986 unreserved characters should not be encoded. + assert_eq!(urlencoded("A-Z_a-z.0~9"), "A-Z_a-z.0~9"); + } + + #[test] + fn test_strip_bracketed_segments() { + assert_eq!(strip_bracketed_segments("If (Killing Eve)"), "If"); + assert_eq!(strip_bracketed_segments("Album [Deluxe Edition]"), "Album"); + assert_eq!(strip_bracketed_segments("Name (Live) [Remaster]"), "Name"); + } + + #[test] + fn test_album_search_variants_parenthetical() { + let variants = album_search_variants("If (Killing Eve)"); + assert_eq!( + variants, + vec!["If (Killing Eve)".to_string(), "If".to_string()] + ); + } + + #[test] + fn test_album_search_variants_progressive_shortening() { + let variants = album_search_variants("Purple Rain Deluxe"); + assert_eq!( + variants, + vec![ + "Purple Rain Deluxe".to_string(), + "Purple Rain".to_string(), + "Purple".to_string(), + ] + ); + } + + #[test] + fn test_artist_search_variants_parenthetical_alias() { + let variants = artist_search_variants("††† (Crosses)"); + assert_eq!( + variants, + vec![ + "††† (Crosses)".to_string(), + "†††".to_string(), + "Crosses".to_string(), + ] + ); + } + + #[test] + fn test_pick_genre_string_prefers_genres_then_tags() { + let genre = pick_genre_string( + vec![MbGenre { + name: "trip hop".to_string(), + }], + vec![MbTag { + name: "electronic".to_string(), + count: 5, + }], + ); + assert_eq!(genre.as_deref(), Some("trip hop")); + + let from_tags = pick_genre_string( + vec![], + vec![ + MbTag { + name: "ambient".to_string(), + count: 0, + }, + MbTag { + name: "electronic".to_string(), + count: 2, + }, + ], + ); + assert_eq!(from_tags.as_deref(), Some("electronic")); + } + + #[test] + fn test_uncached_albums_query() { + // Verify the uncached_albums query works correctly. + let conn = db::open_memory_db().unwrap(); + + // Insert some scrobbles. + db::insert_scrobble( + &conn, + &db::NewScrobble { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + track_duration_secs: Some(291), + played_duration_secs: 291, + scrobbled_at: "2026-03-19T10:00:00".into(), + }, + ) + .unwrap(); + db::insert_scrobble( + &conn, + &db::NewScrobble { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "Telepathy".into(), + track_duration_secs: Some(215), + played_duration_secs: 200, + scrobbled_at: "2026-03-19T10:05:00".into(), + }, + ) + .unwrap(); + + // Both albums should be uncached initially. + let uncached = db::uncached_albums(&conn).unwrap(); + assert_eq!(uncached.len(), 2); + + // Cache one album WITH a cover_url. + db::upsert_album_cache( + &conn, + &db::AlbumCacheEntry { + artist: "Deftones".into(), + album: "White Pony".into(), + musicbrainz_id: Some("test-id".into()), + cover_url: Some("/path/to/cover.jpg".into()), + genre: Some("alternative metal".into()), + fetched_at: "2026-03-19T12:00:00".into(), + }, + ) + .unwrap(); + + // Now only one album should be uncached. + let uncached = db::uncached_albums(&conn).unwrap(); + assert_eq!(uncached.len(), 1); + assert_eq!(uncached[0].artist, "††† (Crosses)"); + + // Cache the other album but WITHOUT a cover_url — it should still + // appear as "uncached" so enrichment retries the cover download. + db::upsert_album_cache( + &conn, + &db::AlbumCacheEntry { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + musicbrainz_id: Some("test-id-2".into()), + cover_url: None, + genre: None, + fetched_at: "2000-01-01T00:00:00".into(), + }, + ) + .unwrap(); + + // Should still show as uncached because cover_url is NULL. + let uncached = db::uncached_albums(&conn).unwrap(); + assert_eq!(uncached.len(), 1); + assert_eq!(uncached[0].artist, "††† (Crosses)"); + } + + #[test] + fn test_upsert_album_cache_replaces() { + // Verify that upserting the same (artist, album) replaces the old entry. + let conn = db::open_memory_db().unwrap(); + + let entry1 = AlbumCacheEntry { + artist: "Deftones".into(), + album: "White Pony".into(), + musicbrainz_id: Some("id-1".into()), + cover_url: None, + genre: Some("rock".into()), + fetched_at: "2026-03-19T10:00:00".into(), + }; + db::upsert_album_cache(&conn, &entry1).unwrap(); + + // Upsert with different data for the same album. + let entry2 = AlbumCacheEntry { + artist: "Deftones".into(), + album: "White Pony".into(), + musicbrainz_id: Some("id-2".into()), + cover_url: Some("/path/to/cover.jpg".into()), + genre: Some("alternative metal, nu metal".into()), + fetched_at: "2026-03-19T12:00:00".into(), + }; + db::upsert_album_cache(&conn, &entry2).unwrap(); + + // Should still be only one entry, with the updated data. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM album_cache", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + // Verify the MBID was updated. + let mbid: String = conn + .query_row( + "SELECT musicbrainz_id FROM album_cache WHERE artist = 'Deftones'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(mbid, "id-2"); + } + + #[test] + fn test_uncached_albums_respects_retry_cooldown() { + let conn = db::open_memory_db().unwrap(); + + db::insert_scrobble( + &conn, + &db::NewScrobble { + artist: "Unloved".into(), + album: "If (Killing Eve)".into(), + title: "If".into(), + track_duration_secs: Some(180), + played_duration_secs: 160, + scrobbled_at: "2026-03-19T10:00:00".into(), + }, + ) + .unwrap(); + + // Recent failed lookup should NOT be retried immediately. + db::upsert_album_cache( + &conn, + &db::AlbumCacheEntry { + artist: "Unloved".into(), + album: "If (Killing Eve)".into(), + musicbrainz_id: None, + cover_url: None, + genre: None, + fetched_at: chrono::Local::now() + .naive_local() + .format("%Y-%m-%dT%H:%M:%S") + .to_string(), + }, + ) + .unwrap(); + + let uncached = db::uncached_albums(&conn).unwrap(); + assert!(uncached.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0a71e75 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,562 @@ +//! MPRIS Scrobbler — a local music scrobbler for MPRIS-compatible players. +//! +//! This is the CLI entry point. It provides four subcommands: +//! +//! - `watch` — monitors a player via `playerctl` and records scrobbles to SQLite +//! - `report` — generates listening statistics from the stored scrobble data +//! - `enrich` — fetches album art and genre info from MusicBrainz +//! - `last-scrobble` — prints the newest scrobble timestamp +//! - `pin-album` — manually assign a MusicBrainz ID to an album the automatic search missed +//! +//! ## Architecture overview +//! +//! The `watch` command spawns two `playerctl --follow` child processes: +//! +//! 1. **Metadata follower** — emits a line each time the track changes, +//! providing artist, album, title, and duration. +//! 2. **Status follower** — emits "Playing", "Paused", or "Stopped" on +//! playback state changes. +//! +//! Each child process gets its own reader thread that parses lines and sends +//! typed `Event` values over an `mpsc::channel` to the main thread. The main +//! thread owns the `ScrobbleTracker` state machine, which processes events +//! sequentially and decides when to write scrobbles to the database. +//! +//! ```text +//! [playerctl metadata] ──reader thread──→ ┐ +//! ├─ mpsc::channel ─→ [main: ScrobbleTracker → SQLite] +//! [playerctl status] ──reader thread──→ ┘ +//! ``` +//! +//! Ctrl+C triggers a graceful shutdown: the handler sends an `Eof` event +//! through the channel, causing the tracker to evaluate the last track +//! before exiting. + +mod db; +mod enrich; +mod report; +mod watcher; + +use clap::{Parser, Subcommand}; +use std::io::BufRead; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; + +/// Default playerctl player name. This matches the MPRIS bus name for the +/// user's primary player (qbz). Can be overridden with `--player`. +const DEFAULT_PLAYER: &str = "com.blitzfc.qbz"; + +// --------------------------------------------------------------------------- +// CLI definition (using clap derive) +// --------------------------------------------------------------------------- + +#[derive(Parser)] +#[command(name = "mpris-scrobbler", about = "MPRIS scrobbler using playerctl")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Watch playerctl metadata and scrobble tracks to the local database. + Watch { + /// Player name for playerctl (the MPRIS bus name). + /// Run `playerctl -l` to see available players. + #[arg(long, default_value = DEFAULT_PLAYER)] + player: String, + + /// Path to the SQLite database file. If not specified, defaults to + /// ~/.local/share/mpris-scrobbler/scrobbles.db (respects $XDG_DATA_HOME). + #[arg(long)] + db_path: Option, + }, + /// Generate scrobble reports from the local database. + Report { + /// Time period to report on: today, week, month, year, or all. + #[arg(long, default_value = "all")] + period: String, + + /// Output the report as JSON instead of terminal tables. + #[arg(long)] + json: bool, + + /// Output the report as standalone HTML. + #[arg(long)] + html: bool, + + /// Output directory path (used with --html). Creates a directory with + /// index.html and a covers/ subdirectory. If omitted, prints HTML to stdout. + #[arg(long)] + output: Option, + + /// Maximum number of entries in top-N lists (top artists, albums, tracks). + /// All-time sections default to 2.5x this value (rounded to nearest 5). + #[arg(long, default_value = "10")] + limit: i64, + + /// Override the all-time top-N limit. If not set, defaults to 2.5x + /// --limit rounded to the nearest multiple of 5. + #[arg(long)] + all_time_limit: Option, + + /// Path to the SQLite database file. Same default as `watch`. + #[arg(long)] + db_path: Option, + }, + /// Fetch album art and genre info from MusicBrainz for all scrobbled albums. + /// + /// This command looks up each unique (artist, album) pair that doesn't yet + /// have cached metadata, queries MusicBrainz for the release, downloads + /// cover art from the Cover Art Archive, and stores everything locally. + Enrich { + /// Re-fetch metadata for all albums, even those already cached. + #[arg(long)] + force: bool, + + /// Path to the SQLite database file. Same default as `watch`. + #[arg(long)] + db_path: Option, + }, + /// Print the newest scrobble timestamp and exit. + LastScrobble { + /// Path to the SQLite database file. Same default as `watch`. + #[arg(long)] + db_path: Option, + }, + /// Manually pin a MusicBrainz release ID to an album that automatic search + /// could not find. Fetches genres and cover art for the given MBID and + /// stores them in the album cache, overwriting any previous entry. + PinAlbum { + /// Artist name as it appears in the scrobble database. + #[arg(long)] + artist: String, + + /// Album name as it appears in the scrobble database. + #[arg(long)] + album: String, + + /// MusicBrainz release UUID to pin to this album. + #[arg(long)] + mbid: String, + + /// Optional direct URL to a cover image (JPEG or PNG). Use this when + /// the Cover Art Archive has no image for the given MBID. The image is + /// downloaded, resized, and stored locally just like a CAA cover. + #[arg(long)] + cover_url: Option, + + /// Path to the SQLite database file. Same default as `watch`. + #[arg(long)] + db_path: Option, + }, +} + +// --------------------------------------------------------------------------- +// Database path resolution +// --------------------------------------------------------------------------- + +/// Determine the default database path, following the XDG Base Directory spec. +/// +/// Path: $XDG_DATA_HOME/mpris-scrobbler/scrobbles.db +/// Falls back to: ~/.local/share/mpris-scrobbler/scrobbles.db +/// +/// Creates the parent directory if it doesn't exist. +fn default_db_path() -> String { + let data_dir = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { + let home = std::env::var("HOME").expect("HOME not set"); + format!("{}/.local/share", home) + }); + let dir = format!("{}/mpris-scrobbler", data_dir); + std::fs::create_dir_all(&dir).expect("Failed to create data directory"); + format!("{}/scrobbles.db", dir) +} + +// --------------------------------------------------------------------------- +// Watch command implementation +// --------------------------------------------------------------------------- + +/// Run the `watch` subcommand: spawn playerctl processes, read events, and +/// scrobble tracks to the database. +fn run_watch(player: &str, db_path: &str) { + // Open (or create) the database and wrap it in Arc> for sharing + // with the scrobble callback. In practice, only the main thread accesses + // it, but the Mutex is needed because the callback closure is FnMut and + // could theoretically be called from different contexts. + let conn = db::open_db(db_path).expect("Failed to open database"); + let conn = Arc::new(Mutex::new(conn)); + + eprintln!("Database: {}", db_path); + eprintln!("Watching player: {}", player); + + // Channel for sending events from reader threads to the main event loop. + let (tx, rx) = mpsc::channel::(); + + // --- Spawn playerctl metadata follower --- + // This process outputs one tab-separated line per track change: + // artist\talbum\ttitle\tmpris:length + let metadata_cmd = Command::new("playerctl") + .args([ + "-p", + player, + "--follow", + "metadata", + "--format", + "{{artist}}\t{{album}}\t{{title}}\t{{mpris:length}}", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + + let metadata_proc = match metadata_cmd { + Ok(proc) => proc, + Err(e) => { + eprintln!("Failed to spawn playerctl metadata: {}", e); + std::process::exit(1); + } + }; + + // --- Spawn playerctl status follower --- + // This process outputs one line per state change: "Playing", "Paused", or "Stopped". + let status_cmd = Command::new("playerctl") + .args(["-p", player, "--follow", "status"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn(); + + let status_proc = match status_cmd { + Ok(proc) => proc, + Err(e) => { + eprintln!("Failed to spawn playerctl status: {}", e); + std::process::exit(1); + } + }; + + // --- Metadata reader thread --- + // Reads lines from the metadata process's stdout, parses them into + // `Event::Metadata` values, and sends them through the channel. + // Sends `Event::Eof` when the process ends (stdout closes). + let tx_meta = tx.clone(); + let meta_handle = thread::spawn(move || { + let stdout = metadata_proc + .stdout + .expect("No stdout for metadata process"); + let reader = std::io::BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(l) => { + if let Some(event) = watcher::parse_metadata_line(&l) + && tx_meta.send(event).is_err() + { + break; // Receiver dropped — shutting down. + } + } + Err(_) => break, // Read error — process likely ended. + } + } + // Signal that this process has ended. + let _ = tx_meta.send(watcher::Event::Eof); + }); + + // --- Status reader thread --- + // Same pattern as the metadata reader, but parses status lines instead. + let tx_status = tx.clone(); + let status_handle = thread::spawn(move || { + let stdout = status_proc.stdout.expect("No stdout for status process"); + let reader = std::io::BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(l) => { + if let Some(event) = watcher::parse_status_line(&l) + && tx_status.send(event).is_err() + { + break; + } + } + Err(_) => break, + } + } + let _ = tx_status.send(watcher::Event::Eof); + }); + + // --- Ctrl+C handler --- + // Sets the `running` flag to false and sends an Eof event to unblock + // the main loop, allowing a graceful shutdown that evaluates the last track. + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let tx_ctrlc = tx.clone(); + ctrlc::set_handler(move || { + eprintln!("\nShutting down..."); + running_clone.store(false, Ordering::SeqCst); + let _ = tx_ctrlc.send(watcher::Event::Eof); + }) + .expect("Failed to set Ctrl+C handler"); + + // --- Main event loop --- + // Receives events from both reader threads and the Ctrl+C handler, + // and feeds them into the ScrobbleTracker state machine. + let mut tracker = watcher::create_db_tracker(conn); + let mut eof_count = 0; + + while running.load(Ordering::SeqCst) { + match rx.recv() { + Ok(event) => { + if event == watcher::Event::Eof { + eof_count += 1; + // Wait for both child processes to end before shutting down. + // (The Ctrl+C handler also sends Eof, so we may get up to 3.) + if eof_count >= 2 { + tracker.handle_event(watcher::Event::Eof); + break; + } + continue; + } + tracker.handle_event(event); + } + // Channel disconnected — all senders dropped. + Err(_) => break, + } + } + + // Final evaluation: ensure the last track is scrobbled if it qualifies. + // This is safe to call even if Eof was already handled above — the tracker + // handles the "no current track" case gracefully. + tracker.handle_event(watcher::Event::Eof); + + eprintln!("Goodbye."); + + // Wait for reader threads to finish (they should already be done since + // the child processes have ended or been killed). + let _ = meta_handle.join(); + let _ = status_handle.join(); +} + +/// Round a value to the nearest multiple of 5. +fn round_to_5(n: i64) -> i64 { + ((n + 2) / 5) * 5 +} + +// --------------------------------------------------------------------------- +// Report command implementation +// --------------------------------------------------------------------------- + +/// Run the `report` subcommand. +/// +/// Three output modes: +/// - **Terminal** (default): queries a single `--period` and prints ASCII tables. +/// - **JSON** (`--json`): same single-period data as pretty-printed JSON. +/// - **HTML** (`--html`): generates a multi-period report (Today / Week / Month / +/// All Time) with bar charts and album cover art. Auto-runs enrichment first. +/// With `--output `, writes `index.html` + `covers/` to a directory. +fn run_report( + period: &str, + json: bool, + html: bool, + output: Option<&str>, + limit: i64, + all_time_limit: i64, + db_path: &str, +) { + let conn = match db::open_db(db_path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to open database at {}: {}", db_path, e); + std::process::exit(1); + } + }; + + // Validate the period argument before querying. + let valid_periods = ["today", "week", "month", "year", "all"]; + if !valid_periods.contains(&period) { + eprintln!( + "Invalid period '{}'. Valid options: {}", + period, + valid_periods.join(", ") + ); + std::process::exit(1); + } + + if json && html { + eprintln!("Please choose one output format: either --json or --html."); + std::process::exit(1); + } + + // For HTML reports, enrich only the albums that will actually appear in + // the report (across all periods). Uses quiet mode so "nothing to do" + // isn't printed when everything is already cached. + if html { + let needed = report::albums_needed_for_report(&conn, limit, all_time_limit); + enrich::run_enrich_targeted(&conn, &needed, true); + } + + if html { + // HTML report gathers all periods (today, week, month, all) internally. + let html_report = report::render_html_report(&conn, limit, all_time_limit); + if let Some(dir) = output { + // Create the output directory structure: + // /index.html + // /covers/.jpg + let dir_path = std::path::Path::new(dir); + let covers_dir = dir_path.join("covers"); + if let Err(e) = std::fs::create_dir_all(&covers_dir) { + eprintln!("Failed to create directory {}: {}", covers_dir.display(), e); + std::process::exit(1); + } + + // Copy cover image files into the output covers/ subdirectory. + // Skip files where the destination is already up-to-date (same + // size AND dest is not older than src), so repeated report runs + // are fast but a re-pinned cover is always picked up. + let mut copied = 0; + let mut skipped = 0; + for src in &html_report.cover_files { + if let Some(filename) = src.file_name() { + let dest = covers_dir.join(filename); + let src_meta = std::fs::metadata(src); + let dest_meta = std::fs::metadata(&dest); + let up_to_date = match (src_meta, dest_meta) { + (Ok(s), Ok(d)) => { + let same_size = s.len() == d.len() && s.len() > 0; + let dest_fresh = d + .modified() + .ok() + .zip(s.modified().ok()) + .map(|(dt, st)| dt >= st) + .unwrap_or(false); + same_size && dest_fresh + } + _ => false, + }; + if up_to_date { + skipped += 1; + continue; + } + if let Err(e) = std::fs::copy(src, &dest) { + eprintln!(" [warn] Failed to copy {}: {}", src.display(), e); + } else { + copied += 1; + } + } + } + + // Write the HTML file. + let index_path = dir_path.join("index.html"); + if let Err(e) = std::fs::write(&index_path, &html_report.html) { + eprintln!("Failed to write {}: {}", index_path.display(), e); + std::process::exit(1); + } + + eprintln!("Wrote HTML report: {}", index_path.display()); + if copied > 0 || skipped > 0 { + eprintln!( + "Covers: {} copied, {} unchanged in {}", + copied, + skipped, + covers_dir.display() + ); + } + } else { + // No --output: print HTML to stdout (covers won't load, + // but useful for piping). + print!("{}", html_report.html); + } + return; + } + + // Terminal or JSON report: single period. + let effective_limit = if period == "all" { + all_time_limit + } else { + limit + }; + match report::gather_report(&conn, period, effective_limit) { + Ok(data) => { + if json { + report::print_json_report(&data); + } else { + report::print_terminal_report(&data); + } + } + Err(e) => { + eprintln!("Failed to generate report: {}", e); + std::process::exit(1); + } + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Watch { player, db_path } => { + let path = db_path.unwrap_or_else(default_db_path); + run_watch(&player, &path); + } + Commands::Report { + period, + json, + html, + output, + limit, + all_time_limit, + db_path, + } => { + let path = db_path.unwrap_or_else(default_db_path); + let atl = + all_time_limit.unwrap_or_else(|| round_to_5((limit as f64 * 2.5).round() as i64)); + run_report(&period, json, html, output.as_deref(), limit, atl, &path); + } + Commands::Enrich { force, db_path } => { + let path = db_path.unwrap_or_else(default_db_path); + let conn = match db::open_db(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to open database at {}: {}", path, e); + std::process::exit(1); + } + }; + enrich::run_enrich(&conn, force, false); + } + Commands::LastScrobble { db_path } => { + let path = db_path.unwrap_or_else(default_db_path); + let conn = match db::open_db(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to open database at {}: {}", path, e); + std::process::exit(1); + } + }; + match db::latest_scrobble_at(&conn) { + Ok(Some(ts)) => println!("{}", ts), + Ok(None) => {} + Err(e) => { + eprintln!("Failed to query latest scrobble: {}", e); + std::process::exit(1); + } + } + } + Commands::PinAlbum { + artist, + album, + mbid, + cover_url, + db_path, + } => { + let path = db_path.unwrap_or_else(default_db_path); + let conn = match db::open_db(&path) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to open database at {}: {}", path, e); + std::process::exit(1); + } + }; + enrich::enrich_by_mbid(&conn, &artist, &album, &mbid, cover_url.as_deref()); + } + } +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..07f9656 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,1840 @@ +//! Report module — generates listening statistics in three output formats. +//! +//! This module reads scrobble data from the database (via `db` module queries) +//! and presents it in one of three formats: +//! +//! 1. **Terminal tables** — box-drawing character tables with bar charts, +//! showing overview stats, top artists/albums/tracks, and recent scrobbles +//! for a single period. +//! +//! 2. **JSON** — the same single-period data serialized as pretty-printed JSON, +//! suitable for piping into other tools (e.g., `jq`). +//! +//! 3. **HTML** — a standalone, multi-period report with a dark theme. The HTML +//! output includes all periods (Today, This Week, This Month, All Time) in +//! one page, with: +//! - KPI cards for each period (scrobbles, listen time, unique counts) +//! - Horizontal bar charts for top artists, top albums, and top tracks +//! - Album cover art grids (fetched via the `enrich` module) +//! - Genre badges from MusicBrainz +//! - A recent scrobbles table (All Time section only) +//! +//! Terminal and JSON reports operate on a single `--period` flag. +//! HTML reports ignore `--period` and always render all periods together. +//! +//! ## Output structure for `--html --output ` +//! +//! ```text +//! / +//! ├── index.html ← properly indented, human-readable HTML +//! └── covers/ +//! ├── .jpg ← cover images copied from the enrichment cache +//! └── .jpg +//! ``` +//! +//! Cover images are referenced via relative `covers/` paths so the +//! directory is self-contained and can be moved or shared as-is. + +use crate::db; +use rusqlite::Connection; +use serde::Serialize; +use std::fmt::Write as _; + +/// Maximum bar width used in terminal tables. +const MAX_BAR_WIDTH: usize = 20; +/// Minimum bar width used in terminal tables when terminal is narrow. +const MIN_BAR_WIDTH: usize = 8; + +// --------------------------------------------------------------------------- +// Duration formatting +// --------------------------------------------------------------------------- + +/// Format a duration in seconds into a human-readable string. +/// +/// Examples: +/// - 45 seconds → "45s" +/// - 120 seconds → "2m" +/// - 3660 seconds → "1h 01m" +/// - 0 seconds → "0s" +pub fn format_duration(secs: i64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m", secs / 60) + } else { + let hours = secs / 3600; + let mins = (secs % 3600) / 60; + if mins == 0 { + format!("{}h", hours) + } else { + format!("{}h {:02}m", hours, mins) + } + } +} + +/// Format a play count with correct singular/plural grammar. +fn format_play_count(plays: i64) -> String { + if plays == 1 { + "1 play".to_string() + } else { + format!("{} plays", plays) + } +} + +/// Build a short "mood" label from top genres. +/// +/// Selection rule: +/// - take the top genre first +/// - then keep scanning in rank order and only take the next genre if its +/// listen time differs from the previously selected genre +/// +/// This avoids showing multiple mood tags with identical play-time weight. +fn mood_labels(top_genres: &[db::TopGenre], max_labels: usize) -> Vec { + let mut out: Vec = Vec::new(); + let mut last_selected_time: Option = None; + + for g in top_genres { + if out.len() >= max_labels { + break; + } + if let Some(prev_time) = last_selected_time + && g.listen_time_secs == prev_time + { + continue; + } + out.push(g.genre.clone()); + last_selected_time = Some(g.listen_time_secs); + } + + out +} + +/// Convert an ISO 8601 timestamp into a short, privacy-friendly relative +/// time label. Avoids exposing exact listening times. Labels are kept compact +/// so they fit well in both terminal tables and HTML columns. +/// +/// Examples: +/// - 10 minutes ago → "Moments ago" +/// - 50 minutes ago → "Within hour" +/// - 2 hours ago → "A while ago" +/// - 8 hours ago → "Today" +/// - yesterday → "Yesterday" +/// - 3 days ago → "Few days" +/// - 2 weeks ago → "About week" +/// - 5 weeks ago → "About month" +/// - 3 months ago → "Few months" +/// - 14 months ago → "Over year" +fn fuzzy_time(iso_timestamp: &str) -> String { + let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else { + return "Some time".to_string(); + }; + let now = chrono::Local::now().naive_local(); + let delta = now.signed_duration_since(then); + let hours = delta.num_hours(); + let days = delta.num_days(); + + // Use calendar-date comparison for "Today" and "Yesterday" so that a track + // scrobbled before midnight is never labelled "Today" after the date rolls over. + let today = now.date(); + let then_date = then.date(); + + match () { + _ if delta.num_minutes() < 20 => "Moments ago".to_string(), + _ if delta.num_minutes() < 60 => "Within hour".to_string(), + _ if hours < 3 => "A while ago".to_string(), + _ if then_date == today => "Today".to_string(), + _ if then_date == today - chrono::Duration::days(1) => "Yesterday".to_string(), + _ if days < 5 => "Few days".to_string(), + _ if days < 14 => "About week".to_string(), + _ if days < 21 => "2 weeks".to_string(), + _ if days < 45 => "About month".to_string(), + _ if days < 90 => "Few months".to_string(), + _ if days < 365 => "Months ago".to_string(), + _ => "Over year".to_string(), + } +} + +/// Terse relative time label for terminal tables. +/// +/// Uses minimal-width labels like "2m", "1h", "3d" to avoid column bloat. +/// Privacy is still maintained — no exact timestamps are shown. +/// +/// Examples: +/// - 2 minutes ago → "2m" +/// - 45 minutes ago → "45m" +/// - 2 hours ago → "2h" +/// - yesterday → "1d" +/// - 10 days ago → "10d" +/// - 5 weeks ago → "5w" +/// - 3 months ago → "3mo" +/// - 2 years ago → "2y" +fn terse_time(iso_timestamp: &str) -> String { + let Ok(then) = chrono::NaiveDateTime::parse_from_str(iso_timestamp, "%Y-%m-%dT%H:%M:%S") else { + return "?".to_string(); + }; + let now = chrono::Local::now().naive_local(); + let delta = now.signed_duration_since(then); + let mins = delta.num_minutes(); + let hours = delta.num_hours(); + let days = delta.num_days(); + + match () { + _ if mins < 1 => "<1m".to_string(), + _ if mins < 60 => format!("{}m", mins), + _ if hours < 24 => format!("{}h", hours), + _ if days < 14 => format!("{}d", days), + _ if days < 90 => format!("{}w", days / 7), + _ if days < 730 => format!("{}mo", days / 30), + _ => format!("{}y", days / 365), + } +} + +// --------------------------------------------------------------------------- +// Report data structures +// --------------------------------------------------------------------------- + +/// The complete report data, containing all sections. This struct is used +/// both for terminal rendering and JSON serialization, so all fields +/// implement `Serialize`. +#[derive(Debug, Serialize)] +pub struct ReportData { + /// Which time period this report covers. + pub period: PeriodInfo, + /// Aggregate statistics (total scrobbles, listen time, unique counts). + pub overview: db::Overview, + /// Top artists ranked by play count. + pub top_artists: Vec, + /// Top albums ranked by play count. + pub top_albums: Vec, + /// Top tracks ranked by play count. + pub top_tracks: Vec, + /// Top genres derived from cached album metadata. + pub top_genres: Vec, + /// Most recent scrobbles (newest first), limited to 20. + pub recent_scrobbles: Vec, +} + +/// Metadata about the time period the report covers. +/// For "all time" reports, `from` and `to` will be `None`. +#[derive(Debug, Serialize)] +pub struct PeriodInfo { + /// The period name as provided by the user (e.g., "week", "month", "all"). + pub name: String, + /// ISO 8601 start of the period, or None for "all". + pub from: Option, + /// ISO 8601 end of the period (current time), or None for "all". + pub to: Option, +} + +// --------------------------------------------------------------------------- +// Terminal box-drawing table renderer +// +// Uses Unicode box-drawing characters (─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼) and block +// elements (█ ░) to produce tables with horizontal bar charts directly in +// the terminal. No external crate needed. +// --------------------------------------------------------------------------- + +/// Render a table with box-drawing characters. +/// `headers` are the column names, `rows` are the cell values. +/// The last column in each row is treated as a numeric value for the bar chart +/// if `bar_max` is Some (the maximum value for scaling bars). +fn print_box_table( + headers: &[&str], + rows: &[Vec], + bar_max: Option, + shrinkable_cols: &[usize], +) { + if rows.is_empty() { + return; + } + + // Calculate column widths (max of header and all cell widths). + let ncols = headers.len(); + let mut widths: Vec = headers.iter().map(|h| visible_width(h)).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < ncols { + widths[i] = widths[i].max(visible_width(cell)); + } + } + } + + // Fit table to terminal width (best-effort): reduce bar width first, then + // shrink only selected text columns. + let terminal_width = terminal_columns(); + let mut bar_width = if bar_max.is_some() { MAX_BAR_WIDTH } else { 0 }; + + while table_width(&widths, bar_width) > terminal_width && bar_width > MIN_BAR_WIDTH { + bar_width -= 1; + } + + while table_width(&widths, bar_width) > terminal_width { + let mut changed = false; + let mut widest: Option = None; + for &idx in shrinkable_cols { + if idx >= widths.len() { + continue; + } + let min_w = 6usize; + if widths[idx] <= min_w { + continue; + } + if widest.is_none_or(|w| widths[idx] > widths[w]) { + widest = Some(idx); + } + } + if let Some(i) = widest { + widths[i] -= 1; + changed = true; + } + if !changed { + break; + } + } + + // If we have a bar chart, add space for it after the last column. + let bar_col = if bar_max.is_some() { bar_width + 2 } else { 0 }; + + let header_cells: Vec = headers + .iter() + .enumerate() + .map(|(i, h)| truncate_cell(h, widths[i])) + .collect(); + + // Top border: ┌──────┬──────┐ + print!(" ┌"); + for (i, w) in widths.iter().enumerate() { + print!("{}", "─".repeat(w + 2)); + if bar_max.is_some() && i == ncols - 1 { + print!("┬{}", "─".repeat(bar_col)); + } + if i < ncols - 1 { + print!("┬"); + } + } + println!("┐"); + + // Header row: │ Header │ Header │ + print!(" │"); + for (i, h) in header_cells.iter().enumerate() { + print!(" {: usize { + // Try ioctl first — this is the only reliable method. + #[cfg(unix)] + { + use std::mem::MaybeUninit; + #[repr(C)] + struct Winsize { + ws_row: libc::c_ushort, + ws_col: libc::c_ushort, + ws_xpixel: libc::c_ushort, + ws_ypixel: libc::c_ushort, + } + let mut ws = MaybeUninit::::uninit(); + let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, ws.as_mut_ptr()) }; + if ret == 0 { + let ws = unsafe { ws.assume_init() }; + if ws.ws_col >= 40 { + return ws.ws_col as usize; + } + } + } + + if let Ok(cols) = std::env::var("COLUMNS") + && let Ok(parsed) = cols.parse::() + && parsed >= 40 + { + return parsed; + } + 80 +} + +/// Compute rendered table width for current settings. +fn table_width(widths: &[usize], bar_width: usize) -> usize { + let ncols = widths.len(); + let mut total = 2 + 1 + 1; // indent + left border + right border + for (i, w) in widths.iter().enumerate() { + total += w + 2; + if i < ncols - 1 { + total += 1; + } + } + if bar_width > 0 { + total += 1 + (bar_width + 2); + } + total +} + +/// Visible character width (simple char-count approximation). +fn visible_width(s: &str) -> usize { + s.chars().count() +} + +/// Truncate a cell to a target width using ASCII ellipsis. +fn truncate_cell(s: &str, width: usize) -> String { + let len = visible_width(s); + if len <= width { + return s.to_string(); + } + if width <= 3 { + return s.chars().take(width).collect(); + } + let mut out: String = s.chars().take(width - 3).collect(); + out.push_str("..."); + out +} + +// --------------------------------------------------------------------------- +// Report generation +// --------------------------------------------------------------------------- + +/// Query the database and assemble all sections of the report. +/// +/// This is the main entry point for report generation. It runs all the +/// necessary SQL queries for the given period and limit, and returns +/// a `ReportData` struct ready for rendering. +/// +/// # Arguments +/// - `conn` — SQLite database connection +/// - `period` — time period filter ("today", "week", "month", "year", "all") +/// Return the set of `(artist, album)` pairs that will appear anywhere in the +/// HTML report at the given limits. Used to focus automatic enrichment on +/// exactly what the report needs rather than the entire scrobble library. +pub fn albums_needed_for_report( + conn: &Connection, + limit: i64, + all_time_limit: i64, +) -> std::collections::HashSet<(String, String)> { + let mut pairs = std::collections::HashSet::new(); + + let periods: &[(&str, i64)] = &[ + ("today", limit), + ("week", limit), + ("month", limit), + ("all", all_time_limit), + ]; + + for (period, lim) in periods { + let cover_lim = album_cover_grid_limit(*lim); + + if let Ok(albums) = db::top_albums(conn, period, cover_lim) { + for a in albums { + pairs.insert((a.artist, a.album)); + } + } + if let Ok(tracks) = db::top_tracks(conn, period, *lim) { + for t in tracks { + pairs.insert((t.artist, t.album)); + } + } + // Include the most-played album per top artist so artist_cover() + // has something to work with even when that album is outside top_albums. + if let Ok(artists) = db::top_artists(conn, period, *lim) { + for a in artists { + if let Some(album) = db::artist_top_album(conn, &a.artist) { + pairs.insert((a.artist, album)); + } + } + } + } + + pairs +} + +/// - `limit` — maximum number of entries in each top-N list +pub fn gather_report( + conn: &Connection, + period: &str, + limit: i64, +) -> Result { + // Compute the date range for the header/JSON metadata. + let range = db::period_range(period); + let period_info = PeriodInfo { + name: period.to_string(), + from: range.as_ref().map(|(f, _)| f.clone()), + to: range.as_ref().map(|(_, t)| t.clone()), + }; + + // Run all queries. Each query internally applies the same period filter. + Ok(ReportData { + period: period_info, + overview: db::overview(conn, period)?, + top_artists: db::top_artists(conn, period, limit)?, + top_albums: db::top_albums(conn, period, limit)?, + top_tracks: db::top_tracks(conn, period, limit)?, + top_genres: db::top_genres(conn, period, limit)?, + // Recent scrobbles are always limited to 20, regardless of the --limit flag. + recent_scrobbles: db::recent_scrobbles(conn, period, 20)?, + }) +} + +// --------------------------------------------------------------------------- +// Terminal output +// --------------------------------------------------------------------------- + +/// Render the report as box-drawing tables with bar charts to stdout. +/// +/// Output sections (each printed only if non-empty): +/// 1. Header with period name and date range +/// 2. Overview KPIs +/// 3. Top Artists with bar chart +/// 4. Top Albums with bar chart +/// 5. Top Genres with bar chart +/// 6. Top Tracks with bar chart +/// 7. Recent Scrobbles +pub fn print_terminal_report(data: &ReportData) { + // --- Header --- + let period_label = match data.period.name.as_str() { + "today" => "Today", + "week" => "This Week", + "month" => "This Month", + "year" => "This Year", + _ => "All Time", + }; + let date_range = match (&data.period.from, &data.period.to) { + (Some(from), Some(_)) if data.period.name == "today" => format!(" {}", &from[..10]), + (Some(from), Some(to)) => format!(" {} → {}", &from[..10], &to[..10]), + _ => String::new(), + }; + println!(); + println!( + " ┌─ Listening Report ─── {} ──{}─┐", + period_label, date_range + ); + println!(); + + // --- Overview KPIs --- + println!( + " Scrobbles: {} Listen time: {} Artists: {} Albums: {} Tracks: {}", + data.overview.total_scrobbles, + format_duration(data.overview.total_listen_time_secs), + data.overview.unique_artists, + data.overview.unique_albums, + data.overview.unique_tracks, + ); + + let mood = mood_labels(&data.top_genres, 6); + if !mood.is_empty() && data.period.name != "all" { + println!(" Mood: {}", mood.join(" · ")); + } + + // --- Top Artists --- + if !data.top_artists.is_empty() { + println!("\n Top Artists"); + let max_plays = data.top_artists.first().map(|a| a.plays).unwrap_or(1); + let rows: Vec> = data + .top_artists + .iter() + .enumerate() + .map(|(i, a)| { + vec![ + format!("{:>2}", i + 1), + a.artist.clone(), + format_duration(a.listen_time_secs), + a.plays.to_string(), + ] + }) + .collect(); + print_box_table( + &["#", "Artist", "Time", "Plays"], + &rows, + Some(max_plays), + &[1], + ); + } + + // --- Top Albums --- + if !data.top_albums.is_empty() { + println!("\n Top Albums"); + let max_plays = data.top_albums.first().map(|a| a.plays).unwrap_or(1); + let rows: Vec> = data + .top_albums + .iter() + .enumerate() + .map(|(i, a)| { + vec![ + format!("{:>2}", i + 1), + a.artist.clone(), + a.album.clone(), + format_duration(a.listen_time_secs), + a.plays.to_string(), + ] + }) + .collect(); + print_box_table( + &["#", "Artist", "Album", "Time", "Plays"], + &rows, + Some(max_plays), + &[1, 2], + ); + } + + // --- Top Genres --- + if !data.top_genres.is_empty() { + println!("\n Top Genres"); + let max_plays = data.top_genres.first().map(|g| g.plays).unwrap_or(1); + let rows: Vec> = data + .top_genres + .iter() + .enumerate() + .map(|(i, g)| { + vec![ + format!("{:>2}", i + 1), + g.genre.clone(), + format_duration(g.listen_time_secs), + g.plays.to_string(), + ] + }) + .collect(); + print_box_table( + &["#", "Genre", "Time", "Plays"], + &rows, + Some(max_plays), + &[1], + ); + } + + // --- Top Tracks --- + if !data.top_tracks.is_empty() { + println!("\n Top Tracks"); + let max_plays = data.top_tracks.first().map(|t| t.plays).unwrap_or(1); + let rows: Vec> = data + .top_tracks + .iter() + .enumerate() + .map(|(i, t)| { + vec![ + format!("{:>2}", i + 1), + t.artist.clone(), + t.title.clone(), + format_duration(t.listen_time_secs), + t.plays.to_string(), + ] + }) + .collect(); + print_box_table( + &["#", "Artist", "Title", "Time", "Plays"], + &rows, + Some(max_plays), + &[1, 2], + ); + } + + // --- Recent Scrobbles --- + if !data.recent_scrobbles.is_empty() { + println!("\n Recent Scrobbles"); + let rows: Vec> = data + .recent_scrobbles + .iter() + .map(|s| { + vec![ + terse_time(&s.scrobbled_at), + s.artist.clone(), + s.title.clone(), + s.album.clone(), + ] + }) + .collect(); + print_box_table( + &["Ago", "Artist", "Title", "Album"], + &rows, + None, + &[1, 2, 3], + ); + } + + println!(); +} + +// --------------------------------------------------------------------------- +// JSON output +// --------------------------------------------------------------------------- + +/// Serialize the report as pretty-printed JSON and write it to stdout. +/// This is the output format used with `--json`. +pub fn print_json_report(data: &ReportData) { + println!( + "{}", + serde_json::to_string_pretty(data).expect("Failed to serialize report") + ); +} + +/// Result of rendering an HTML report. Contains the HTML string and a list +/// of cover image files that should be copied into a `covers/` subdirectory +/// next to the HTML file. +pub struct HtmlReport { + /// The generated HTML document. + pub html: String, + /// Absolute paths to cover image files that the HTML references. + /// Each should be copied to `/covers/`. + pub cover_files: Vec, +} + +/// Render a multi-period HTML report. +/// +/// The report contains sections for Today, This Week, This Month, and All Time, +/// each with their own KPIs, top artists/albums/tracks with bar charts, and +/// album cover art cards. +/// +/// Cover images are referenced via relative `covers/` paths. +pub fn render_html_report(conn: &Connection, limit: i64, all_time_limit: i64) -> HtmlReport { + // Gather data for each period. All-time uses its own (larger) limit; + // shorter periods use the base limit as-is. + let periods: &[(&str, i64)] = &[ + ("today", limit), + ("week", limit), + ("month", limit), + ("all", all_time_limit), + ]; + let reports: Vec = periods + .iter() + .filter_map(|(p, l)| gather_report(conn, p, *l).ok()) + .collect(); + + let mut cover_files: Vec = Vec::new(); + let mut h = HtmlWriter::new(); + + // --- HTML head + CSS --- + h.line(""); + h.line(""); + h.line(""); + h.indent(); + h.line(""); + h.line(""); + h.line("Listening Report"); + h.line(""); + h.line(""); + h.line(""); + h.line(""); + h.dedent(); + h.line(""); + h.line(""); + h.open("
"); + + // --- Hero header --- + let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(); + h.open("
"); + h.line("

Listening Report

"); + h.linef(format_args!( + "
Generated by mpris-scrobbler on {}
", + html_escape(&generated_at) + )); + h.close("
"); + + // --- Mobile-friendly jump menu --- + // Show links only for periods that will actually render. + let nav_periods: Vec<(&str, &str)> = reports + .iter() + .filter(|d| d.overview.total_scrobbles > 0) + .map(|d| match d.period.name.as_str() { + "today" => ("Today", "period-today"), + "week" => ("This Week", "period-week"), + "month" => ("This Month", "period-month"), + _ => ("All Time", "period-all"), + }) + .collect(); + if !nav_periods.is_empty() { + h.open(""); + } + + // --- Period sections (Today → Week → Month → All Time) --- + for data in &reports { + let (label, is_all_time, section_id) = match data.period.name.as_str() { + "today" => ("Today", false, "period-today"), + "week" => ("This Week", false, "period-week"), + "month" => ("This Month", false, "period-month"), + _ => ("All Time", true, "period-all"), + }; + let range_str = match (&data.period.from, &data.period.to) { + (Some(from), Some(_)) if data.period.name == "today" => from[..10].to_string(), + (Some(from), Some(to)) => format!("{} → {}", &from[..10], &to[..10]), + _ => String::new(), + }; + + // Skip empty periods. + if data.overview.total_scrobbles == 0 { + continue; + } + + h.blank(); + h.open(&format!( + "
", + section_id + )); + h.linef(format_args!( + "
{}
", + html_escape(label) + )); + if !range_str.is_empty() { + h.linef(format_args!( + "
{}
", + html_escape(&range_str) + )); + } + + let mood = mood_labels(&data.top_genres, 6); + if !mood.is_empty() && !is_all_time { + h.open("
"); + h.linef(format_args!( + "Mood of {}:", + html_escape(label) + )); + for genre in mood { + h.linef(format_args!( + "{}", + html_escape(&genre) + )); + } + h.close("
"); + } + + // KPIs + h.open("
"); + write_kpi( + &mut h, + "Scrobbles", + &data.overview.total_scrobbles.to_string(), + ); + write_kpi( + &mut h, + "Listen time", + &format_duration(data.overview.total_listen_time_secs), + ); + write_kpi(&mut h, "Artists", &data.overview.unique_artists.to_string()); + write_kpi(&mut h, "Albums", &data.overview.unique_albums.to_string()); + write_kpi(&mut h, "Tracks", &data.overview.unique_tracks.to_string()); + h.close("
"); + + // Album cover grid — shown first after KPIs to visually illustrate + // the period's listening at a glance. + // + // We intentionally round the cover count up to a full desktop row so + // the grid doesn't end with a partially filled row when the user picks + // a limit like 20 and desktop layout has 6 columns. + let cover_limit = album_cover_grid_limit(limit); + let cover_albums = db::top_albums(conn, &data.period.name, cover_limit) + .unwrap_or_else(|_| data.top_albums.clone()); + if !cover_albums.is_empty() { + h.blank(); + h.open("
"); + h.line("

Top Album Covers

"); + h.open("
"); + for a in &cover_albums { + let meta = db::album_cache_meta(conn, &a.artist, &a.album) + .ok() + .flatten(); + let genre = meta.as_ref().and_then(|m| m.genre.as_ref()); + let cmd = pin_album_cmd( + &a.artist, + &a.album, + meta.as_ref().and_then(|m| m.mbid.as_deref()), + ); + h.open("
"); + + let cover_rel = resolve_cover( + meta.as_ref().and_then(|m| m.cover_url.clone()), + &mut cover_files, + ); + if let Some(ref rel) = cover_rel { + h.linef(format_args!( + "\"{}\"", + html_attr_escape(rel), + html_attr_escape(&format!("{} - {}", a.artist, a.album)) + )); + } else { + h.line("
No cover
"); + } + h.linef(format_args!( + "", + html_attr_escape(&cmd) + )); + + h.open("
"); + h.linef(format_args!( + "
{}
", + html_escape(&a.album) + )); + h.linef(format_args!( + "
{}
", + html_escape(&a.artist) + )); + h.linef(format_args!( + "
{} · {}
", + html_escape(&format_play_count(a.plays)), + html_escape(&format_duration(a.listen_time_secs)) + )); + if let Some(g) = genre { + let labels = top_genre_labels(g, 3); + if !labels.is_empty() { + h.open("
"); + for label in labels { + h.linef(format_args!( + "{}", + html_escape(&label) + )); + } + h.close("
"); + } + } + h.close("
"); // .meta + h.close("
"); + } + h.close("
"); // .grid + h.close("
"); + } + + // Top Albums as a bar table, matching the visual language used by + // Top Artists and Top Tracks. + let album_rows: Vec = data + .top_albums + .iter() + .enumerate() + .map(|(i, a)| { + let cover_url = db::album_cache_meta(conn, &a.artist, &a.album) + .ok() + .flatten() + .and_then(|m| m.cover_url); + let cover = resolve_cover(cover_url, &mut cover_files); + BarRow { + cells: vec![(i + 1).to_string(), a.artist.clone(), a.album.clone()], + value: a.plays, + suffix: format_duration(a.listen_time_secs), + cover, + } + }) + .collect(); + write_bar_table( + &mut h, + "Top Albums", + &["#", "Artist", "Album", "Plays", ""], + &album_rows, + ); + + // Top Artists with bar chart and mini cover. + // Each artist's cover is taken from their most-played album in this + // specific period. + let artist_rows: Vec = data + .top_artists + .iter() + .enumerate() + .map(|(i, a)| { + let cover = resolve_cover( + db::artist_cover(conn, &a.artist, &data.period.name), + &mut cover_files, + ); + BarRow { + cells: vec![(i + 1).to_string(), a.artist.clone()], + value: a.plays, + suffix: format_duration(a.listen_time_secs), + cover, + } + }) + .collect(); + write_bar_table( + &mut h, + "Top Artists", + &["#", "Artist", "Plays", ""], + &artist_rows, + ); + + // Top Genres with bar chart. + let genre_rows: Vec = data + .top_genres + .iter() + .enumerate() + .map(|(i, g)| BarRow { + cells: vec![(i + 1).to_string(), g.genre.clone()], + value: g.plays, + suffix: format_duration(g.listen_time_secs), + cover: None, + }) + .collect(); + write_bar_table( + &mut h, + "Top Genres", + &["#", "Genre", "Plays", ""], + &genre_rows, + ); + + // Top Tracks with bar chart and mini cover. + // Each track's cover comes from its album. + let track_rows: Vec = data + .top_tracks + .iter() + .enumerate() + .map(|(i, t)| { + let cover_url = db::album_cache_meta(conn, &t.artist, &t.album) + .ok() + .flatten() + .and_then(|m| m.cover_url); + let cover = resolve_cover(cover_url, &mut cover_files); + BarRow { + cells: vec![(i + 1).to_string(), t.artist.clone(), t.title.clone()], + value: t.plays, + suffix: format_duration(t.listen_time_secs), + cover, + } + }) + .collect(); + write_bar_table( + &mut h, + "Top Tracks", + &["#", "Artist", "Title", "Plays", ""], + &track_rows, + ); + + // Recent scrobbles (only for All Time section to avoid repetition) + if is_all_time && !data.recent_scrobbles.is_empty() { + write_plain_table( + &mut h, + "Recent Scrobbles", + &["When", "Artist", "Title", "Album"], + &data + .recent_scrobbles + .iter() + .map(|s| { + vec![ + fuzzy_time(&s.scrobbled_at), + s.artist.clone(), + s.title.clone(), + s.album.clone(), + ] + }) + .collect::>(), + ); + } + + h.close("
"); // .period-block + } + + h.close("
"); // .wrap + h.line(""); + h.line(""); + + // Deduplicate cover files — the same album may appear in multiple + // period sections, but we only need to copy each image once. + cover_files.sort(); + cover_files.dedup(); + + HtmlReport { + html: h.finish(), + cover_files, + } +} + +// --------------------------------------------------------------------------- +// CSS (kept minified since it's a style block, not meant to be read in HTML) +// --------------------------------------------------------------------------- + +const CSS: &str = "\ +:root { + --bg: #11100d; --panel: #1b1813; --panel2: #241f18; --text: #f1eadf; + --muted: #b8a892; --line: #3b3228; --accent: #f0c06a; --accent2: #d89a3d; + --bar: #3a2a16; --bar-fill: #d89a3d; +} +* { box-sizing: border-box; } +body { + margin: 0; color: var(--text); + font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace; + background: + radial-gradient(1200px 420px at 10% -10%, #2a231a, transparent), + radial-gradient(1000px 380px at 100% 0%, #31230f, transparent), + var(--bg); +} +.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 18px 56px; } +h1, h2, h3 { margin: 0; } +.hero { + padding: 22px 24px; border: 1px solid var(--line); + background: linear-gradient(145deg, var(--panel), #15120d); + border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.25); +} +.sub { margin-top: 6px; color: var(--muted); font-size: 14px; } +.jump-nav { + position: sticky; top: 0; z-index: 20; + margin-top: 12px; padding: 8px; + display: flex; gap: 8px; overflow-x: auto; + background: rgba(17, 16, 13, 0.84); + border: 1px solid var(--line); border-radius: 10px; + backdrop-filter: blur(8px); +} +.jump-nav a { + white-space: nowrap; text-decoration: none; color: var(--text); + font-size: 12px; font-weight: 600; + padding: 7px 10px; border-radius: 8px; + border: 1px solid #5b4631; + background: linear-gradient(140deg, #2b2218, #34281c); +} +.jump-nav a:hover { border-color: #a87937; } +.period-block { + margin-top: 32px; padding: 20px 24px; border: 1px solid var(--line); + background: var(--panel); border-radius: 14px; +} +.period-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; } +.period-range { font-size: 13px; color: var(--muted); margin-bottom: 14px; } +.mood { + margin-bottom: 12px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.mood-title { font-size: 11px; color: var(--muted); } +.mood-pill { + font-size: 9px; + line-height: 1; + letter-spacing: 0.01em; + color: #e9c98f; + padding: 2px 5px; + border: 1px solid #7e5e2f; + border-radius: 999px; +} +.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; } +.kpi { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 10px; } +.kpi .k { font-size: 11px; color: var(--muted); } +.kpi .v { margin-top: 4px; font-size: 22px; font-weight: 700; } +section { margin-top: 18px; } +.section-title { font-size: 16px; margin-bottom: 8px; } +.card { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } +table { width: 100%; border-collapse: collapse; } +th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--line); } +th { font-size: 11px; color: var(--muted); letter-spacing: .02em; text-transform: uppercase; } +tr:last-child td { border-bottom: none; } +.mono { font-family: \"JetBrains Mono\", \"Fira Code\", \"IBM Plex Mono\", \"Cascadia Code\", \"SFMono-Regular\", Menlo, Consolas, monospace; } +.bar-cell { width: 40%; } +.bar-wrap { display: flex; align-items: center; gap: 8px; } +.bar { height: 18px; border-radius: 4px; background: var(--bar-fill); min-width: 2px; } +.bar-label { font-size: 12px; color: var(--muted); white-space: nowrap; } +.grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; } +.album { position: relative; background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; } +.cover { width: 100%; aspect-ratio: 1/1; object-fit: cover; background: #0d1116; } +.ph { width: 100%; aspect-ratio: 1/1; display: grid; place-items: center; place-content: center; + color: #a99984; + background: linear-gradient(135deg, #17130d, #2c2116); font-size: 12px; } +.pin-dot { position: absolute; top: 5px; right: 5px; width: 22px; height: 22px; + border-radius: 50%; background: rgba(0,0,0,0.45); border: none; + cursor: pointer; font-size: 12px; line-height: 22px; text-align: center; + opacity: 0; transition: opacity 0.15s; z-index: 1; } +.album:hover .pin-dot { opacity: 1; } +.pin-dot:hover { background: rgba(0,0,0,0.75); } +.meta { padding: 8px; } +.t { font-size: 13px; font-weight: 700; line-height: 1.2; } +.a { margin-top: 3px; font-size: 11px; color: var(--muted); } +.genre { margin-top: 5px; display: flex; gap: 5px; flex-wrap: wrap; } +.pill { + display: inline-block; + font-size: 9px; + line-height: 1; + letter-spacing: 0.01em; + color: #e9c98f; + padding: 2px 5px; + border: 1px solid #7e5e2f; + border-radius: 999px; +} +.mini-cover { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; vertical-align: middle; } +.mini-ph { display: inline-block; width: 36px; height: 36px; border-radius: 4px; + background: linear-gradient(135deg, #17130d, #2c2116); vertical-align: middle; } +.nowrap { white-space: nowrap; } +.note { margin-top: 18px; color: var(--muted); font-size: 12px; } +@media (max-width: 980px) { .grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@media (max-width: 700px) { + .wrap { padding: 16px 12px 36px; } + .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .jump-nav { margin-top: 10px; padding: 7px; } +} +"; + +/// Desktop column count for the album cover grid. +const ALBUM_GRID_COLUMNS: i64 = 6; + +/// Round a user-provided limit up to the nearest full grid row. +/// +/// Examples with 6-column rows: +/// - limit 20 -> 24 +/// - limit 18 -> 18 +/// - limit 1 -> 6 +fn album_cover_grid_limit(limit: i64) -> i64 { + let columns = ALBUM_GRID_COLUMNS; + let safe = limit.max(1); + ((safe + columns - 1) / columns) * columns +} + +// --------------------------------------------------------------------------- +// HtmlWriter — helper for producing indented HTML output +// --------------------------------------------------------------------------- + +/// A simple helper that tracks indentation depth so the generated HTML is +/// human-readable. Each `open()` increases indent, each `close()` decreases. +struct HtmlWriter { + buf: String, + depth: usize, +} + +impl HtmlWriter { + fn new() -> Self { + Self { + buf: String::with_capacity(128 * 1024), + depth: 0, + } + } + + /// Write a self-contained line at the current indent level. + fn line(&mut self, s: &str) { + self.write_indent(); + self.buf.push_str(s); + self.buf.push('\n'); + } + + /// Write a formatted line at the current indent level. + fn linef(&mut self, args: std::fmt::Arguments<'_>) { + self.write_indent(); + let _ = write!(self.buf, "{}", args); + self.buf.push('\n'); + } + + /// Write raw text (no indent, no newline added — the text provides its own). + fn raw(&mut self, s: &str) { + self.buf.push_str(s); + } + + /// Write an opening tag and increase indent for children. + fn open(&mut self, tag: &str) { + self.line(tag); + self.indent(); + } + + /// Decrease indent and write a closing tag. + fn close(&mut self, tag: &str) { + self.dedent(); + self.line(tag); + } + + /// Write a blank line for visual separation. + fn blank(&mut self) { + self.buf.push('\n'); + } + + fn indent(&mut self) { + self.depth += 1; + } + + fn dedent(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn write_indent(&mut self) { + for _ in 0..self.depth { + self.buf.push_str(" "); + } + } + + /// Consume the writer and return the finished HTML string. + fn finish(self) -> String { + self.buf + } +} + +// --------------------------------------------------------------------------- +// HTML content helpers +// --------------------------------------------------------------------------- + +/// Convert an absolute cover file path (from the DB) into a relative path +/// for use in HTML (`covers/`), and track the source file for +/// copying into the output directory. Returns `None` if the path is missing +/// or the file doesn't exist on disk. +fn resolve_cover( + abs_path: Option, + cover_files: &mut Vec, +) -> Option { + let src = abs_path?; + let src_path = std::path::Path::new(&src); + let filename = src_path.file_name()?; + if src_path.exists() { + cover_files.push(src_path.to_path_buf()); + } + Some(format!("covers/{}", filename.to_string_lossy())) +} + +fn write_kpi(h: &mut HtmlWriter, key: &str, val: &str) { + h.open("
"); + h.linef(format_args!("
{}
", html_escape(key))); + h.linef(format_args!( + "
{}
", + html_escape(val) + )); + h.close("
"); +} + +/// A row in a bar-chart table. `cells` are the text columns, `value` is +/// the numeric value used for the bar width, `suffix` is shown after the +/// bar (e.g., listen time), and `cover` is an optional relative path to +/// a mini cover image shown at the start of the row. +struct BarRow { + cells: Vec, + value: i64, + suffix: String, + /// Relative path to a cover image (e.g., "covers/UUID.jpg"), or None. + cover: Option, +} + +/// Render a table where the last column is a horizontal bar chart. +fn write_bar_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[BarRow]) { + if rows.is_empty() { + return; + } + let max_val = rows.iter().map(|r| r.value).max().unwrap_or(1).max(1); + + h.blank(); + h.open("
"); + h.linef(format_args!( + "

{}

", + html_escape(title) + )); + h.open("
"); + h.open(""); + + // Check if any row has a cover image — if so, add a cover column. + let has_covers = rows.iter().any(|r| r.cover.is_some()); + + // Header + h.open(""); + h.open(""); + if has_covers { + h.line(""); // empty header for the cover column + } + for hdr in headers { + h.linef(format_args!("", html_escape(hdr))); + } + h.close(""); + h.close(""); + + // Body + h.open(""); + for row in rows { + h.open(""); + if has_covers { + if let Some(ref src) = row.cover { + h.linef(format_args!( + "", + html_attr_escape(src) + )); + } else { + h.line(""); + } + } + for cell in &row.cells { + h.linef(format_args!("", html_escape(cell))); + } + let pct = (row.value as f64 / max_val as f64 * 100.0).round() as u32; + h.linef(format_args!("", row.value)); + h.linef(format_args!( + "", + s = " ".repeat(h.depth), + pct = pct, + suffix = html_escape(&row.suffix) + )); + h.close(""); + } + h.close(""); + + h.close("
{}
\"\"{}{}\n\ + {s}
\n\ + {s}
\n\ + {s} {suffix}\n\ + {s}
\n\ + {s}
"); + h.close("
"); // .card + h.close("
"); +} + +/// Render a plain table (no bar chart). +fn write_plain_table(h: &mut HtmlWriter, title: &str, headers: &[&str], rows: &[Vec]) { + if rows.is_empty() { + return; + } + h.blank(); + h.open("
"); + h.linef(format_args!( + "

{}

", + html_escape(title) + )); + h.open("
"); + h.open(""); + + h.open(""); + h.open(""); + for hdr in headers { + h.linef(format_args!("", html_escape(hdr))); + } + h.close(""); + h.close(""); + + h.open(""); + for row in rows { + h.open(""); + for (i, col) in row.iter().enumerate() { + if i == 0 { + h.linef(format_args!( + "", + html_escape(col) + )); + } else { + h.linef(format_args!("", html_escape(col))); + } + } + h.close(""); + } + h.close(""); + + h.close("
{}
{}{}
"); + h.close("
"); + h.close("
"); +} + +/// Build the `pin-album` shell command for the given artist and album. +/// Uses the known MBID when available, otherwise substitutes `MBID_HERE`. +/// Double-quotes in string values are escaped so the command is safe to paste. +fn pin_album_cmd(artist: &str, album: &str, mbid: Option<&str>) -> String { + let a = artist.replace('"', "\\\""); + let b = album.replace('"', "\\\""); + let m = mbid.unwrap_or("MBID_HERE"); + format!( + "mpris-scrobbler pin-album --artist \"{a}\" --album \"{b}\" --mbid \"{m}\"" + ) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn html_attr_escape(s: &str) -> String { + html_escape(s).replace('"', """) +} + +/// Split a comma-separated genre list and keep only the first `max_labels` +/// cleaned labels. +fn top_genre_labels(genre_csv: &str, max_labels: usize) -> Vec { + genre_csv + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .take(max_labels) + .map(|s| s.to_string()) + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================= + // Duration formatting + // ======================================================================= + + #[test] + fn test_format_duration_seconds() { + assert_eq!(format_duration(45), "45s"); + } + + #[test] + fn test_format_duration_minutes() { + assert_eq!(format_duration(120), "2m"); + assert_eq!(format_duration(300), "5m"); + } + + #[test] + fn test_format_duration_hours() { + assert_eq!(format_duration(3600), "1h"); + assert_eq!(format_duration(3660), "1h 01m"); + assert_eq!(format_duration(7380), "2h 03m"); + } + + #[test] + fn test_format_duration_zero() { + assert_eq!(format_duration(0), "0s"); + } + + #[test] + fn test_format_play_count() { + assert_eq!(format_play_count(0), "0 plays"); + assert_eq!(format_play_count(1), "1 play"); + assert_eq!(format_play_count(2), "2 plays"); + } + + #[test] + fn test_truncate_cell() { + assert_eq!(truncate_cell("abcdef", 6), "abcdef"); + assert_eq!(truncate_cell("abcdef", 5), "ab..."); + assert_eq!(truncate_cell("abcdef", 3), "abc"); + } + + #[test] + fn test_album_cover_grid_limit_rounds_to_full_rows() { + assert_eq!(album_cover_grid_limit(20), 24); + assert_eq!(album_cover_grid_limit(18), 18); + assert_eq!(album_cover_grid_limit(1), 6); + assert_eq!(album_cover_grid_limit(0), 6); + } + + #[test] + fn test_top_genre_labels_caps_to_three() { + let labels = top_genre_labels("alternative rock, electronic, industrial, darkwave", 3); + assert_eq!( + labels, + vec![ + "alternative rock".to_string(), + "electronic".to_string(), + "industrial".to_string(), + ] + ); + } + + #[test] + fn test_mood_labels_caps_to_three() { + let genres = vec![ + db::TopGenre { + genre: "trip hop".to_string(), + plays: 5, + listen_time_secs: 1000, + }, + db::TopGenre { + genre: "electronic".to_string(), + plays: 4, + listen_time_secs: 900, + }, + db::TopGenre { + genre: "ambient".to_string(), + plays: 3, + listen_time_secs: 800, + }, + db::TopGenre { + genre: "downtempo".to_string(), + plays: 2, + listen_time_secs: 700, + }, + ]; + assert_eq!( + mood_labels(&genres, 3), + vec![ + "trip hop".to_string(), + "electronic".to_string(), + "ambient".to_string(), + ] + ); + } + + // ======================================================================= + // Report gathering + // ======================================================================= + + #[test] + fn test_gather_report_empty_db() { + // An empty database should produce a valid report with all zeros. + let conn = db::open_memory_db().unwrap(); + let report = gather_report(&conn, "all", 10).unwrap(); + assert_eq!(report.overview.total_scrobbles, 0); + assert!(report.top_artists.is_empty()); + assert!(report.top_genres.is_empty()); + assert!(report.recent_scrobbles.is_empty()); + } + + #[test] + fn test_gather_report_with_data() { + // Populate a test database and verify the report contains expected data. + let conn = db::open_memory_db().unwrap(); + let scrobbles = vec![ + db::NewScrobble { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "This Is a Trick".into(), + track_duration_secs: Some(186), + played_duration_secs: 186, + scrobbled_at: "2026-03-19T10:00:00".into(), + }, + db::NewScrobble { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "Telepathy".into(), + track_duration_secs: Some(215), + played_duration_secs: 200, + scrobbled_at: "2026-03-19T10:05:00".into(), + }, + db::NewScrobble { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + track_duration_secs: Some(291), + played_duration_secs: 291, + scrobbled_at: "2026-03-19T10:10:00".into(), + }, + ]; + for s in &scrobbles { + db::insert_scrobble(&conn, s).unwrap(); + } + + let report = gather_report(&conn, "all", 10).unwrap(); + + // Overview should reflect all 3 scrobbles. + assert_eq!(report.overview.total_scrobbles, 3); + // Two distinct artists: ††† (Crosses) and Deftones. + assert_eq!(report.top_artists.len(), 2); + // ††† (Crosses) has 2 plays, so it should be ranked first. + assert_eq!(report.top_artists[0].artist, "††† (Crosses)"); + // All 3 tracks are unique. + assert_eq!(report.top_tracks.len(), 3); + // No cached genres yet in this test fixture. + assert!(report.top_genres.is_empty()); + // Recent scrobbles should contain all 3. + assert_eq!(report.recent_scrobbles.len(), 3); + // Period should be "all" with no date range. + assert_eq!(report.period.name, "all"); + } + + #[test] + fn test_json_serialization() { + // Verify that the report can be serialized to JSON and contains + // expected fields. + let conn = db::open_memory_db().unwrap(); + db::insert_scrobble( + &conn, + &db::NewScrobble { + artist: "Test".into(), + album: "Album".into(), + title: "Song".into(), + track_duration_secs: Some(180), + played_duration_secs: 180, + scrobbled_at: "2026-03-19T10:00:00".into(), + }, + ) + .unwrap(); + + let report = gather_report(&conn, "all", 10).unwrap(); + let json = serde_json::to_string(&report).unwrap(); + assert!(json.contains("\"total_scrobbles\":1")); + assert!(json.contains("\"artist\":\"Test\"")); + } + + #[test] + fn test_html_render_contains_sections() { + let conn = db::open_memory_db().unwrap(); + db::insert_scrobble( + &conn, + &db::NewScrobble { + artist: "Test Artist".into(), + album: "Test Album".into(), + title: "Test Song".into(), + track_duration_secs: Some(180), + played_duration_secs: 170, + scrobbled_at: "2026-03-19T10:00:00".into(), + }, + ) + .unwrap(); + db::upsert_album_cache( + &conn, + &db::AlbumCacheEntry { + artist: "Test Artist".into(), + album: "Test Album".into(), + musicbrainz_id: None, + cover_url: None, + genre: Some("trip hop".into()), + fetched_at: "2026-03-19T10:10:00".into(), + }, + ) + .unwrap(); + + let report = render_html_report(&conn, 10, 25); + assert!(report.html.contains(", + }, + /// The player's playback state changed (Playing, Paused, or Stopped). + Status(PlayerStatus), + /// One of the playerctl child processes ended (stdout closed). + /// When both metadata and status processes have sent Eof, the watcher + /// evaluates the last track and shuts down. + Eof, +} + +/// Possible playback states reported by `playerctl --follow status`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PlayerStatus { + Playing, + Paused, + Stopped, +} + +// --------------------------------------------------------------------------- +// Internal track representation +// --------------------------------------------------------------------------- + +/// Holds metadata about the track currently being monitored. +/// This is internal to the tracker — not exposed outside the module. +#[derive(Debug, Clone)] +struct CurrentTrack { + artist: String, + album: String, + title: String, + /// Duration in microseconds as reported by MPRIS, or None if unavailable. + duration_us: Option, +} + +impl CurrentTrack { + /// Calculate the scrobble threshold for this track. + /// + /// Follows the Last.fm convention: + /// - If duration is known: min(50% of duration, 240 seconds) + /// - If duration is unknown: 240 seconds (4 minutes) + /// + /// This means short tracks (< 8 min) need 50% play time, while long + /// tracks cap out at 4 minutes of required listening. + fn threshold_secs(&self) -> f64 { + match self.duration_us { + Some(us) => { + let half = (us as f64 / 1_000_000.0) * 0.5; + half.min(240.0) + } + None => 240.0, + } + } + + /// Convert the MPRIS duration (microseconds) to whole seconds for storage. + /// Returns None if the duration was not provided by the player. + fn duration_secs(&self) -> Option { + self.duration_us.map(|us| (us / 1_000_000) as i64) + } +} + +// --------------------------------------------------------------------------- +// ScrobbleTracker — production version using real wall-clock time +// --------------------------------------------------------------------------- + +/// The core state machine that tracks playback and decides when to scrobble. +/// +/// Generic over `F` — the callback invoked when a track qualifies for +/// scrobbling. This allows the same logic to be used with a real DB callback +/// in production and a test collector in unit tests. +/// +/// ## State +/// +/// - `current_track` — the track currently being monitored (None if idle) +/// - `is_playing` — whether the player is currently in Playing state +/// - `playing_since` — the `Instant` when the current Playing stretch began +/// (None if paused or no track) +/// - `accumulated_secs` — total seconds of actual play time for the current +/// track, accumulated across multiple play/pause cycles +pub struct ScrobbleTracker { + current_track: Option, + is_playing: bool, + playing_since: Option, + accumulated_secs: f64, + scrobble_fn: F, +} + +impl ScrobbleTracker { + pub fn new(scrobble_fn: F) -> Self { + Self { + current_track: None, + is_playing: false, + playing_since: None, + accumulated_secs: 0.0, + scrobble_fn, + } + } + + /// Process an incoming event and update internal state. + /// + /// ## Event handling: + /// + /// - **Metadata**: A new track started. Evaluate the previous track + /// (scrobble if threshold met), then begin tracking the new one. + /// We assume the new track starts in Playing state. + /// + /// - **Status(Playing)**: Resume accumulating play time. If already + /// playing, this is a no-op (avoids double-counting). + /// + /// - **Status(Paused)**: Flush the current playing stretch into + /// `accumulated_secs` and stop the clock. The track remains active + /// so play time continues to accumulate when resumed. + /// + /// - **Status(Stopped)**: Evaluate the current track immediately (the + /// same logic as a new Metadata event or Eof). If the threshold is + /// met, scrobble; if not, discard. Either way, reset tracking state + /// so that a spurious subsequent Status(Playing) cannot accumulate + /// phantom time against the same track. + /// + /// - **Eof**: A playerctl process ended. Evaluate the current track + /// one last time (so the final track can be scrobbled on shutdown). + pub fn handle_event(&mut self, event: Event) { + match event { + Event::Metadata { + artist, + album, + title, + duration_us, + } => { + // Evaluate the previous track before switching to the new one. + self.evaluate_previous_track(); + + // Start tracking the new track. + self.current_track = Some(CurrentTrack { + artist, + album, + title, + duration_us, + }); + self.accumulated_secs = 0.0; + + // A metadata event means the player is actively playing the new track. + self.is_playing = true; + self.playing_since = Some(Instant::now()); + } + Event::Status(status) => match status { + PlayerStatus::Playing => { + // Only start the clock if we weren't already playing. + // This prevents double-counting if we receive redundant Playing events. + if !self.is_playing { + self.is_playing = true; + self.playing_since = Some(Instant::now()); + } + } + PlayerStatus::Paused => { + // Flush the elapsed time from the current playing stretch + // into the accumulator, then stop the clock. + self.flush_playing_time(); + self.is_playing = false; + self.playing_since = None; + } + PlayerStatus::Stopped => { + // Treat Stop as a final decision: evaluate the current + // track now (scrobble if threshold met, discard if not) + // and clear all tracking state. This prevents a spurious + // Status(Playing) that some players emit after Stopped + // from accumulating phantom time against the same track. + self.evaluate_previous_track(); + self.accumulated_secs = 0.0; + self.is_playing = false; + } + }, + Event::Eof => { + // Evaluate the last track when the player process ends. + self.evaluate_previous_track(); + self.current_track = None; + } + } + } + + /// If the player is currently in Playing state, calculate how much time + /// has elapsed since `playing_since` and add it to `accumulated_secs`. + /// Resets `playing_since` to None so the time isn't counted twice. + fn flush_playing_time(&mut self) { + if let Some(since) = self.playing_since.take() { + self.accumulated_secs += since.elapsed().as_secs_f64(); + } + } + + /// Check whether the current track has been played long enough to qualify + /// for scrobbling. If so, invoke `scrobble_fn` with the track data. + /// + /// This is called: + /// - When a new track starts (to evaluate the outgoing track) + /// - On Eof (to evaluate the last track before shutdown) + fn evaluate_previous_track(&mut self) { + // Flush any in-progress playing time first. + self.flush_playing_time(); + + if let Some(track) = self.current_track.take() { + let threshold = track.threshold_secs(); + + // Only scrobble if the user listened for at least the threshold duration. + if self.accumulated_secs >= threshold { + let now = chrono::Local::now() + .naive_local() + .format("%Y-%m-%dT%H:%M:%S") + .to_string(); + + // Extract duration before moving fields out of `track`. + let track_dur = track.duration_secs(); + + let scrobble = NewScrobble { + artist: track.artist, + album: track.album, + title: track.title, + track_duration_secs: track_dur, + played_duration_secs: self.accumulated_secs.round() as i64, + scrobbled_at: now, + }; + (self.scrobble_fn)(scrobble); + } + } + } +} + +// --------------------------------------------------------------------------- +// Parsing functions +// --------------------------------------------------------------------------- + +/// Parse a metadata line from playerctl's `--follow metadata --format` output. +/// +/// Expected format (tab-separated): +/// `{artist}\t{album}\t{title}\t{mpris:length}` +/// +/// The duration field (mpris:length) is optional — if missing or unparseable, +/// it will be `None`. At minimum, we need 3 tab-separated fields (artist, +/// album, title). Lines with both artist and title empty are rejected. +/// +/// Example input: +/// `"††† (Crosses)\t††† (Crosses)\tThis Is a Trick\t186000000"` +pub fn parse_metadata_line(line: &str) -> Option { + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 3 { + return None; + } + + let artist = parts[0].trim().to_string(); + let album = parts[1].trim().to_string(); + let title = parts[2].trim().to_string(); + + // The 4th field is mpris:length in microseconds. It may be missing entirely, + // empty, or contain a non-numeric value — all of which result in None. + let duration_us = parts.get(3).and_then(|s| s.trim().parse::().ok()); + + // Reject lines where both artist and title are empty (no useful metadata). + if artist.is_empty() && title.is_empty() { + return None; + } + + Some(Event::Metadata { + artist, + album, + title, + duration_us, + }) +} + +/// Parse a status line from playerctl's `--follow status` output. +/// +/// Expected values: "Playing", "Paused", or "Stopped" (with optional +/// trailing whitespace/newlines). +/// +/// Returns `None` for unrecognized status strings. +pub fn parse_status_line(line: &str) -> Option { + match line.trim() { + "Playing" => Some(Event::Status(PlayerStatus::Playing)), + "Paused" => Some(Event::Status(PlayerStatus::Paused)), + "Stopped" => Some(Event::Status(PlayerStatus::Stopped)), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Factory for production use +// --------------------------------------------------------------------------- + +/// Create a `ScrobbleTracker` wired up to insert scrobbles into the database. +/// +/// The callback acquires the mutex, inserts the scrobble, and logs the result +/// to stderr. The `Arc>` is shared with the main thread +/// but only accessed from the main event loop (single-threaded), so contention +/// is minimal. +pub fn create_db_tracker( + conn: std::sync::Arc>, +) -> ScrobbleTracker { + ScrobbleTracker::new(move |scrobble: NewScrobble| { + let conn = conn.lock().unwrap(); + match db::insert_scrobble(&conn, &scrobble) { + Ok(_) => { + eprintln!( + "[scrobbled] {} - {} ({}s)", + scrobble.artist, scrobble.title, scrobble.played_duration_secs + ); + } + Err(e) => { + eprintln!("[error] Failed to insert scrobble: {}", e); + } + } + }) +} + +// =========================================================================== +// Test-only code +// =========================================================================== + +/// A version of `ScrobbleTracker` that replaces `Instant::now()` with a +/// manually-controlled clock. This lets tests simulate time passing +/// (e.g., "advance 100 seconds") without real delays. +/// +/// Instead of calling a callback, scrobbled tracks are collected into the +/// `scrobbled` Vec for inspection in assertions. +#[cfg(test)] +pub struct TestableTracker { + current_track: Option, + is_playing: bool, + /// Simulated timestamp (in seconds) when the current Playing stretch began. + playing_since_secs: Option, + /// Accumulated play time for the current track (in seconds). + accumulated_secs: f64, + /// All tracks that were scrobbled during the test. + pub scrobbled: Vec, + /// The current simulated time, in seconds since the start of the test. + clock_secs: f64, +} + +#[cfg(test)] +impl TestableTracker { + pub fn new() -> Self { + Self { + current_track: None, + is_playing: false, + playing_since_secs: None, + accumulated_secs: 0.0, + scrobbled: Vec::new(), + clock_secs: 0.0, + } + } + + /// Advance the simulated clock by the given number of seconds. + /// Call this between events to simulate time passing. + pub fn advance_time(&mut self, secs: f64) { + self.clock_secs += secs; + } + + /// Flush elapsed playing time from the simulated clock into the accumulator. + /// Mirrors `ScrobbleTracker::flush_playing_time()` but uses `clock_secs` + /// instead of `Instant::elapsed()`. + fn flush_playing_time(&mut self) { + if let Some(since) = self.playing_since_secs.take() { + self.accumulated_secs += self.clock_secs - since; + } + } + + /// Evaluate the current track against the scrobble threshold. + /// If it qualifies, push it onto the `scrobbled` Vec. + fn evaluate_previous_track(&mut self) { + self.flush_playing_time(); + + if let Some(track) = self.current_track.take() { + let threshold = track.threshold_secs(); + if self.accumulated_secs >= threshold { + let track_dur = track.duration_secs(); + let scrobble = NewScrobble { + artist: track.artist, + album: track.album, + title: track.title, + track_duration_secs: track_dur, + played_duration_secs: self.accumulated_secs.round() as i64, + scrobbled_at: format!("test-time-{}", self.clock_secs), + }; + self.scrobbled.push(scrobble); + } + } + } + + /// Process an event — mirrors `ScrobbleTracker::handle_event()` exactly, + /// but uses simulated time instead of real wall-clock time. + pub fn handle_event(&mut self, event: Event) { + match event { + Event::Metadata { + artist, + album, + title, + duration_us, + } => { + self.evaluate_previous_track(); + self.current_track = Some(CurrentTrack { + artist, + album, + title, + duration_us, + }); + self.accumulated_secs = 0.0; + self.is_playing = true; + self.playing_since_secs = Some(self.clock_secs); + } + Event::Status(status) => match status { + PlayerStatus::Playing => { + if !self.is_playing { + self.is_playing = true; + self.playing_since_secs = Some(self.clock_secs); + } + } + PlayerStatus::Paused => { + self.flush_playing_time(); + self.is_playing = false; + self.playing_since_secs = None; + } + PlayerStatus::Stopped => { + self.evaluate_previous_track(); + self.accumulated_secs = 0.0; + self.is_playing = false; + } + }, + Event::Eof => { + self.evaluate_previous_track(); + self.current_track = None; + } + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ======================================================================= + // Parsing tests + // ======================================================================= + + #[test] + fn test_parse_metadata_line_normal() { + // Standard line with all four fields present. + let line = "††† (Crosses)\t††† (Crosses)\tThis Is a Trick\t186000000"; + let event = parse_metadata_line(line).unwrap(); + assert_eq!( + event, + Event::Metadata { + artist: "††† (Crosses)".to_string(), + album: "††† (Crosses)".to_string(), + title: "This Is a Trick".to_string(), + duration_us: Some(186_000_000), + } + ); + } + + #[test] + fn test_parse_metadata_line_missing_duration() { + // Duration field is present but empty — should parse as None. + let line = "Artist\tAlbum\tTitle\t"; + let event = parse_metadata_line(line).unwrap(); + assert_eq!( + event, + Event::Metadata { + artist: "Artist".to_string(), + album: "Album".to_string(), + title: "Title".to_string(), + duration_us: None, + } + ); + } + + #[test] + fn test_parse_metadata_line_no_duration_field() { + // Only three fields (no duration column at all). + let line = "Artist\tAlbum\tTitle"; + let event = parse_metadata_line(line).unwrap(); + assert_eq!( + event, + Event::Metadata { + artist: "Artist".to_string(), + album: "Album".to_string(), + title: "Title".to_string(), + duration_us: None, + } + ); + } + + #[test] + fn test_parse_metadata_line_empty_artist_and_title() { + // Both artist and title are empty — should be rejected. + let line = "\tAlbum\t\t100"; + assert!(parse_metadata_line(line).is_none()); + } + + #[test] + fn test_parse_metadata_line_too_few_fields() { + // Only two fields — not enough to form a valid metadata event. + let line = "Artist\tAlbum"; + assert!(parse_metadata_line(line).is_none()); + } + + #[test] + fn test_parse_status_line() { + assert_eq!( + parse_status_line("Playing"), + Some(Event::Status(PlayerStatus::Playing)) + ); + assert_eq!( + parse_status_line("Paused"), + Some(Event::Status(PlayerStatus::Paused)) + ); + assert_eq!( + parse_status_line("Stopped"), + Some(Event::Status(PlayerStatus::Stopped)) + ); + // Unrecognized status should return None. + assert_eq!(parse_status_line("Unknown"), None); + // Trailing newline should be handled gracefully. + assert_eq!( + parse_status_line("Playing\n"), + Some(Event::Status(PlayerStatus::Playing)) + ); + } + + // ======================================================================= + // Threshold calculation tests + // ======================================================================= + + #[test] + fn test_threshold_known_duration() { + let track = CurrentTrack { + artist: "A".into(), + album: "B".into(), + title: "C".into(), + duration_us: Some(186_000_000), // 186 seconds + }; + // 50% of 186s = 93s. min(93, 240) = 93s. + assert!((track.threshold_secs() - 93.0).abs() < 0.01); + } + + #[test] + fn test_threshold_long_track() { + let track = CurrentTrack { + artist: "A".into(), + album: "B".into(), + title: "C".into(), + duration_us: Some(600_000_000), // 600 seconds = 10 minutes + }; + // 50% of 600s = 300s. min(300, 240) = 240s (capped at 4 minutes). + assert!((track.threshold_secs() - 240.0).abs() < 0.01); + } + + #[test] + fn test_threshold_unknown_duration() { + let track = CurrentTrack { + artist: "A".into(), + album: "B".into(), + title: "C".into(), + duration_us: None, + }; + // Unknown duration defaults to 240s (4 minutes). + assert!((track.threshold_secs() - 240.0).abs() < 0.01); + } + + // ======================================================================= + // Scrobble decision tests (using TestableTracker with simulated time) + // ======================================================================= + + #[test] + fn test_scrobble_after_threshold() { + // Play a track for longer than its threshold — it should be scrobbled + // when the next track starts. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "This Is a Trick".into(), + duration_us: Some(186_000_000), // threshold = 93s + }); + + // Simulate playing for 100 seconds (above the 93s threshold). + tracker.advance_time(100.0); + + // When the next track arrives, the previous one gets evaluated. + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), + }); + + assert_eq!(tracker.scrobbled.len(), 1); + assert_eq!(tracker.scrobbled[0].title, "This Is a Trick"); + assert_eq!(tracker.scrobbled[0].played_duration_secs, 100); + } + + #[test] + fn test_no_scrobble_below_threshold() { + // Skip a track after only 10 seconds — should NOT be scrobbled. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "This Is a Trick".into(), + duration_us: Some(186_000_000), // threshold = 93s + }); + + tracker.advance_time(10.0); // Only 10s — well below 93s threshold. + + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), + }); + + assert_eq!(tracker.scrobbled.len(), 0); + } + + #[test] + fn test_pause_resume_accumulates_correctly() { + // Play 50s → pause for 1 hour → resume → play 60s more. + // Total actual play time = 110s, which exceeds the 107.5s threshold. + // The 1-hour pause should NOT count. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "Telepathy".into(), + duration_us: Some(215_000_000), // threshold = 107.5s + }); + + // Play for 50 seconds, then pause. + tracker.advance_time(50.0); + tracker.handle_event(Event::Status(PlayerStatus::Paused)); + + // Paused for 1 hour — this time should NOT be counted. + tracker.advance_time(3600.0); + tracker.handle_event(Event::Status(PlayerStatus::Playing)); + + // Play for 60 more seconds. Total play time: 50 + 60 = 110s. + tracker.advance_time(60.0); + + // Next track triggers evaluation of "Telepathy". + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), + }); + + assert_eq!(tracker.scrobbled.len(), 1); + assert_eq!(tracker.scrobbled[0].title, "Telepathy"); + // 50 + 60 = 110 seconds of actual play time. + assert_eq!(tracker.scrobbled[0].played_duration_secs, 110); + } + + #[test] + fn test_pause_resume_below_threshold() { + // Play 30s → pause → play 30s = 60s total, below the 107.5s threshold. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "Telepathy".into(), + duration_us: Some(215_000_000), // threshold = 107.5s + }); + + tracker.advance_time(30.0); + tracker.handle_event(Event::Status(PlayerStatus::Paused)); + tracker.advance_time(500.0); // Long pause — doesn't count. + tracker.handle_event(Event::Status(PlayerStatus::Playing)); + tracker.advance_time(30.0); + + // Total play time = 60s, below 107.5s threshold. + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), + }); + + assert_eq!(tracker.scrobbled.len(), 0); + } + + #[test] + fn test_eof_evaluates_last_track() { + // The last track should be scrobbled when the player process ends (Eof), + // not just when a new track starts. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), // threshold = 145.5s + }); + + tracker.advance_time(200.0); + tracker.handle_event(Event::Eof); + + assert_eq!(tracker.scrobbled.len(), 1); + assert_eq!(tracker.scrobbled[0].title, "Digital Bath"); + } + + #[test] + fn test_unknown_duration_uses_4min_threshold() { + // When duration is unknown, the threshold falls back to 240s (4 minutes). + let mut tracker = TestableTracker::new(); + + // First attempt: play for 200s (below 240s) — should NOT scrobble. + tracker.handle_event(Event::Metadata { + artist: "Unknown".into(), + album: "".into(), + title: "Mystery".into(), + duration_us: None, + }); + tracker.advance_time(200.0); + tracker.handle_event(Event::Eof); + assert_eq!(tracker.scrobbled.len(), 0); + + // Second attempt: play for 250s (above 240s) — should scrobble. + let mut tracker2 = TestableTracker::new(); + tracker2.handle_event(Event::Metadata { + artist: "Unknown".into(), + album: "".into(), + title: "Mystery".into(), + duration_us: None, + }); + tracker2.advance_time(250.0); + tracker2.handle_event(Event::Eof); + assert_eq!(tracker2.scrobbled.len(), 1); + } + + #[test] + fn test_multiple_tracks_sequence() { + // Simulate a listening session with 3 tracks: + // Track 1: played fully (186s > 93s threshold) → scrobbled + // Track 2: skipped quickly (5s < 107.5s threshold) → NOT scrobbled + // Track 3: played fully (291s > 145.5s threshold) → scrobbled + let mut tracker = TestableTracker::new(); + + // Track 1: ††† (Crosses) - This Is a Trick + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "This Is a Trick".into(), + duration_us: Some(186_000_000), // threshold = 93s + }); + tracker.advance_time(186.0); + + // Track 2: ††† (Crosses) - Telepathy (skipped after 5 seconds) + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "Telepathy".into(), + duration_us: Some(215_000_000), // threshold = 107.5s + }); + tracker.advance_time(5.0); + + // Track 3: Deftones - Digital Bath + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), // threshold = 145.5s + }); + tracker.advance_time(291.0); + + // End of session. + tracker.handle_event(Event::Eof); + + // Only tracks 1 and 3 should be scrobbled. + assert_eq!(tracker.scrobbled.len(), 2); + assert_eq!(tracker.scrobbled[0].title, "This Is a Trick"); + assert_eq!(tracker.scrobbled[1].title, "Digital Bath"); + } + + #[test] + fn test_stop_below_threshold_no_scrobble() { + // Play a track for 3 seconds then stop — should NOT be scrobbled, + // even if a spurious Status(Playing) follows (some players emit this) + // and a large amount of phantom time elapses before the next event. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "††† (Crosses)".into(), + album: "††† (Crosses)".into(), + title: "This Is a Trick".into(), + duration_us: Some(186_000_000), // threshold = 93s + }); + + tracker.advance_time(3.0); + tracker.handle_event(Event::Status(PlayerStatus::Stopped)); + + // Spurious Playing emitted by the player after stopping — this used + // to start the clock again, letting phantom time push accumulated_secs + // past the threshold. + tracker.handle_event(Event::Status(PlayerStatus::Playing)); + tracker.advance_time(200.0); // phantom time — player isn't actually playing + + // Session ends (or a new track arrives). + tracker.handle_event(Event::Eof); + + assert_eq!( + tracker.scrobbled.len(), + 0, + "3-second play must not be scrobbled" + ); + } + + #[test] + fn test_stop_above_threshold_scrobbles_immediately() { + // Play a track past its threshold, then stop — should be scrobbled + // at the moment Stop arrives, not waiting for the next track or Eof. + let mut tracker = TestableTracker::new(); + + tracker.handle_event(Event::Metadata { + artist: "Deftones".into(), + album: "White Pony".into(), + title: "Digital Bath".into(), + duration_us: Some(291_000_000), // threshold = 145.5s + }); + + tracker.advance_time(200.0); // above threshold + tracker.handle_event(Event::Status(PlayerStatus::Stopped)); + + assert_eq!(tracker.scrobbled.len(), 1); + assert_eq!(tracker.scrobbled[0].title, "Digital Bath"); + assert_eq!(tracker.scrobbled[0].played_duration_secs, 200); + } +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..b6de9cb --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Interactive uninstaller for mpris-scrobbler. +# +# Steps: +# 1. Stop and disable the systemd user service +# 2. Remove the systemd service unit file +# 3. Remove the installed binary +# 4. Optionally remove all data (database, covers, config) +# +# Each step asks for confirmation before proceeding. + +set -euo pipefail + +BIN_NAME="mpris-scrobbler" +PUBLISH_BIN_NAME="mpris-scrobbler-publish" +INSTALL_DIR="$HOME/.local/bin" +SERVICE_DIR="$HOME/.config/systemd/user" +SERVICE_NAME="mpris-scrobbler.service" + +DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/mpris-scrobbler" + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } + +confirm() { + local prompt="$1" + echo "" + read -rp "$(echo -e "${YELLOW}$prompt [y/N]${NC} ")" answer + case "$answer" in + [yY]|[yY][eE][sS]) return 0 ;; + *) return 1 ;; + esac +} + +# ----------------------------------------------------------------------- +# Step 1: Stop and disable the service +# ----------------------------------------------------------------------- + +if systemctl --user is-enabled "$SERVICE_NAME" &>/dev/null || \ + systemctl --user is-active "$SERVICE_NAME" &>/dev/null; then + if confirm "Step 1/4: Stop and disable $SERVICE_NAME?"; then + systemctl --user stop "$SERVICE_NAME" 2>/dev/null || true + systemctl --user disable "$SERVICE_NAME" 2>/dev/null || true + info "Service stopped and disabled." + else + warn "Skipping service stop/disable." + fi +else + info "Step 1/4: Service is not installed or not running. Nothing to stop." +fi + +# ----------------------------------------------------------------------- +# Step 2: Remove service unit file +# ----------------------------------------------------------------------- + +if [[ -f "$SERVICE_DIR/$SERVICE_NAME" ]]; then + if confirm "Step 2/4: Remove $SERVICE_DIR/$SERVICE_NAME?"; then + rm "$SERVICE_DIR/$SERVICE_NAME" + systemctl --user daemon-reload + info "Service unit removed and daemon reloaded." + else + warn "Skipping service unit removal." + fi +else + info "Step 2/4: No service unit file found. Nothing to remove." +fi + +# ----------------------------------------------------------------------- +# Step 3: Remove binary and publish helper +# ----------------------------------------------------------------------- + +if [[ -f "$INSTALL_DIR/$BIN_NAME" || -f "$INSTALL_DIR/$PUBLISH_BIN_NAME" ]]; then + if confirm "Step 3/4: Remove installed binaries from $INSTALL_DIR?"; then + if [[ -f "$INSTALL_DIR/$BIN_NAME" ]]; then + rm "$INSTALL_DIR/$BIN_NAME" + info "Removed: $INSTALL_DIR/$BIN_NAME" + fi + if [[ -f "$INSTALL_DIR/$PUBLISH_BIN_NAME" ]]; then + rm "$INSTALL_DIR/$PUBLISH_BIN_NAME" + info "Removed: $INSTALL_DIR/$PUBLISH_BIN_NAME" + fi + else + warn "Skipping binary removal." + fi +else + info "Step 3/4: No installed binaries found in $INSTALL_DIR. Nothing to remove." +fi + +# ----------------------------------------------------------------------- +# Step 4: Remove data (database, covers) +# ----------------------------------------------------------------------- + +if [[ -d "$DATA_DIR" ]]; then + echo "" + warn "Data directory: $DATA_DIR" + if [[ -f "$DATA_DIR/scrobbles.db" ]]; then + local_size=$(du -sh "$DATA_DIR" 2>/dev/null | cut -f1) + scrobble_count=$(sqlite3 "$DATA_DIR/scrobbles.db" "SELECT COUNT(*) FROM scrobbles;" 2>/dev/null || echo "unknown") + info " Database: $DATA_DIR/scrobbles.db ($scrobble_count scrobbles)" + fi + if [[ -d "$DATA_DIR/covers" ]]; then + cover_count=$(find "$DATA_DIR/covers" -type f 2>/dev/null | wc -l) + info " Covers: $DATA_DIR/covers/ ($cover_count files)" + fi + info " Total size: $(du -sh "$DATA_DIR" 2>/dev/null | cut -f1)" + + if confirm "Step 4/4: DELETE all scrobble data, covers, and database? (THIS CANNOT BE UNDONE)"; then + rm -rf "$DATA_DIR" + info "Data directory removed." + else + warn "Keeping data at $DATA_DIR." + fi +else + info "Step 4/4: No data directory found at $DATA_DIR. Nothing to remove." +fi + +echo "" +info "Uninstall complete."