Furumi init

This commit is contained in:
2026-05-23 13:08:09 +03:00
parent b8afaa1864
commit 8912c51165
42 changed files with 14279 additions and 54 deletions
Generated
+518 -10
View File
@@ -118,6 +118,12 @@ dependencies = [
"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 = "argon2"
version = "0.5.3"
@@ -306,6 +312,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -362,6 +374,12 @@ 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"
version = "1.5.0"
@@ -656,6 +674,26 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "croner"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576"
dependencies = [
"chrono",
"derive_builder",
"strum",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -1023,6 +1061,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "ff"
version = "0.13.1"
@@ -1045,6 +1089,16 @@ 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 = "flume"
version = "0.11.1"
@@ -1081,16 +1135,27 @@ dependencies = [
name = "furumusic"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"chrono",
"cot",
"croner",
"encoding_rs",
"id3",
"openidconnect",
"reqwest",
"schemars 0.9.0",
"serde",
"serde_json",
"sha2",
"sqlx",
"symphonia",
"tokio",
"tokio-cron-scheduler",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
@@ -1223,11 +1288,24 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[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 = "gimli"
version = "0.32.3"
@@ -1424,7 +1502,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -1556,6 +1634,23 @@ dependencies = [
"zerovec",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "id3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
dependencies = [
"bitflags 2.11.1",
"byteorder",
"flate2",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1674,6 +1769,12 @@ dependencies = [
"spin",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc"
version = "0.2.186"
@@ -1692,7 +1793,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"libc",
"plain",
"redox_syscall 0.7.5",
@@ -1790,6 +1891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
@@ -1851,6 +1953,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[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"
@@ -2176,6 +2289,16 @@ 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 = "primeorder"
version = "0.13.6"
@@ -2273,6 +2396,12 @@ 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.8.6"
@@ -2338,7 +2467,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -2347,7 +2476,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"
dependencies = [
"bitflags",
"bitflags 2.11.1",
]
[[package]]
@@ -2422,7 +2551,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.7",
]
[[package]]
@@ -2827,6 +2956,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -2914,6 +3049,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls",
"serde",
"serde_json",
"sha2",
@@ -2923,6 +3059,7 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
@@ -2971,7 +3108,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags",
"bitflags 2.11.1",
"byteorder",
"bytes",
"chrono",
@@ -3014,7 +3151,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [
"atoi",
"base64 0.22.1",
"bitflags",
"bitflags 2.11.1",
"byteorder",
"chrono",
"crc",
@@ -3092,6 +3229,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3108,6 +3266,189 @@ dependencies = [
"serde_json",
]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-adpcm",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "2.0.117"
@@ -3260,6 +3601,22 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-cron-scheduler"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f50e41f200fd8ed426489bd356910ede4f053e30cebfbd59ef0f856f0d7432a"
dependencies = [
"chrono",
"chrono-tz",
"croner",
"num-derive",
"num-traits",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
@@ -3372,7 +3729,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bitflags 2.11.1",
"bytes",
"futures-util",
"http",
@@ -3596,6 +3953,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -3635,7 +4003,16 @@ version = "1.0.3+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
dependencies = [
"wit-bindgen",
"wit-bindgen 0.57.1",
]
[[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 0.51.0",
]
[[package]]
@@ -3699,6 +4076,40 @@ 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 2.14.0",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"semver",
]
[[package]]
name = "web-sys"
version = "0.3.98"
@@ -3719,6 +4130,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
@@ -4028,12 +4448,100 @@ dependencies = [
"memchr",
]
[[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"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[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 2.14.0",
"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 2.11.1",
"indexmap 2.14.0",
"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 2.14.0",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "writeable"
version = "0.6.3"
+13 -2
View File
@@ -9,9 +9,20 @@ cot = { path = "../cot/cot", features = ["postgres", "json", "openapi", "swagger
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
tokio = { version = "1", features = ["sync"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
base64 = "0.22"
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = "0.4.44"
symphonia = { version = "0.5", default-features = false, features = ["mp3","aac","flac","vorbis","wav","alac","adpcm","pcm","mpa","isomp4","ogg","aiff","mkv"] }
id3 = "1"
encoding_rs = "0.8"
sha2 = "0.10"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
anyhow = "1.0"
tokio-cron-scheduler = "0.15"
croner = "3"
async-trait = "0.1"
uuid = "1"
+105
View File
@@ -0,0 +1,105 @@
You are a music metadata normalization assistant. Your job is to take raw metadata extracted from audio files and produce clean, accurate, canonical metadata suitable for a music library database.
## Rules
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
- "deep purple" → "Deep Purple"
- "AC DC" → "AC/DC"
- "guns n roses" → "Guns N' Roses"
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
- Examples:
- Artist or path: "Саша Скул и Олег Харитонов" with DB containing "Саша Скул" → artist: "Саша Скул", featured_artists: ["Олег Харитонов"]
- Artist: "Metallica & Lou Reed" with DB containing "Metallica" → artist: "Metallica", featured_artists: ["Lou Reed"]
- Artist: "Artist A / Artist B" with neither in DB → artist: "Artist A", featured_artists: ["Artist B"] (first listed = primary)
- **NEVER create a new compound artist** like "X и Y" or "X & Y" as a single artist name. Always split into primary + featured.
2. **Featured artists**: Many tracks include collaborations. Guest artists can be indicated by ANY of the following markers (case-insensitive) in the artist field, track title, filename, or path:
- English: "feat.", "ft.", "featuring", "with"
- Russian: "п.у.", "при участии"
- Parenthetical: "(feat. X)", "(ft. X)", "(п.у. X)", "(при участии X)"
- Any other language-specific equivalent indicating a guest/featured collaboration
You must:
- Extract the **primary artist** (the main performer) into the "artist" field.
- Extract ALL **featured/guest artists** into a separate "featured_artists" array.
- Remove the collaboration marker and featured artist names from the track title, keeping only the song name.
- When multiple featured artists are listed, split them by commas or "&" into separate entries.
- Examples:
- Artist: "НСМВГЛП feat. XACV SQUAD" → artist: "НСМВГЛП", featured_artists: ["XACV SQUAD"]
- Title: "Знаешь ли ты feat. SharOn" → title: "Знаешь ли ты", featured_artists: ["SharOn"]
- Title: "Ваши мамки (п.у. Ваня Айван,Иван Смех, Жильцов)" → title: "Ваши мамки", featured_artists: ["Ваня Айван", "Иван Смех", "Жильцов"]
- **IMPORTANT**: Always check for parenthetical markers like "(п.у. ...)" or "(feat. ...)" at the end of track titles. These are very common and must not be missed.
- Apply the same capitalization and consistency rules to featured artist names.
- If the database already contains a matching featured artist name, use the existing canonical form.
3. **Release names** must use correct capitalization and canonical spelling.
- Use title case for English releases.
- Preserve original language for non-English releases.
- If the database already contains a matching release under the same artist, use the existing name exactly.
- Do not alter the creative content of release names (same principle as track titles).
- **Remastered editions**: A remastered release is a separate entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster, append " (Remastered)" to the release name if not already present, and use the year of the remaster release.
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
- Use title case for English titles.
- Preserve original language for non-English titles.
- Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content.
5. **Year**: If not present in tags, try to infer from the file path. Only set a year if you are confident it is correct.
6. **Track number**: If not present in tags, try to infer from the filename (e.g., "03 - Song.flac" → track 3).
7. **Genre**: Normalize to a common genre name. Avoid overly specific sub-genres unless the existing database already uses them.
8. **Encoding issues**: Raw metadata may contain mojibake (e.g., Cyrillic text misread as Latin-1). If you detect garbled text that looks like encoding errors, attempt to determine the intended text.
9. **Preservation principle**: When in doubt, preserve the original value. Only change metadata when you are confident the change is a correction.
10. **Consistency**: When the database already contains entries for an artist or release, your output MUST match the existing canonical names.
11. **Confidence**: Rate your confidence from 0.0 to 1.0.
- 1.0: All fields are clear and unambiguous.
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
- 0.5-0.8: Some guesswork involved, human review recommended.
- Below 0.5: Significant uncertainty, definitely needs review.
12. **Release type**: Determine the type of release based on all available evidence.
Allowed values (use exactly one, lowercase):
- `album`: Full-length release, typically 4+ tracks
- `single`: One or two tracks released as a single
- `ep`: Short release, typically 3-6 tracks
- `compilation`: Best-of, greatest hits, anthology
- `mixtape`: Mixtape release
- `live`: Live recording, concert, live album
- `soundtrack`: Film/game/TV soundtrack
- `remix`: Remix album or collection
- `demo`: Demo recording
Determination rules (in priority order):
- If the folder path contains keywords like "Single", "Сингл" → `single`
- If the folder path contains "EP" → `ep`
- If the folder path contains "Live", "Concert", "Концерт" → `live`
- If the folder path contains "Soundtrack", "OST" → `soundtrack`
- If the folder path contains "Remix" → `remix`
- If the folder path contains "Demo" → `demo`
- If the folder path contains "Mixtape" → `mixtape`
- If the folder path contains "Compilation", "сборник", "Greatest Hits" → `compilation`
- If track count in folder is 12 → likely `single`
- If track count in folder is 36 → likely `ep`
- If track count is 7+ → likely `album`
- When in doubt, default to `album`
## Response format
You MUST respond with a single JSON object, no markdown fences, no extra text:
{"artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": [], "release_type": "album", "confidence": 0.95, "notes": "brief explanation of changes made"}
- Use null for fields you cannot determine.
- Use an empty array [] for "featured_artists" if there are no featured artists.
- The "notes" field should briefly explain what you changed and why.
- "release_type" must be exactly one of: "album", "single", "ep", "compilation", "mixtape", "live", "soundtrack", "remix", "demo"
+111
View File
@@ -0,0 +1,111 @@
You are a music metadata normalization assistant. Your job is to take raw metadata extracted from multiple audio files in the same folder and produce clean, accurate, canonical metadata suitable for a music library database.
## Rules
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
- "deep purple" → "Deep Purple"
- "AC DC" → "AC/DC"
- "guns n roses" → "Guns N' Roses"
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
- Examples:
- Artist or path: "Саша Скул и Олег Харитонов" with DB containing "Саша Скул" → artist: "Саша Скул", featured_artists: ["Олег Харитонов"]
- Artist: "Metallica & Lou Reed" with DB containing "Metallica" → artist: "Metallica", featured_artists: ["Lou Reed"]
- Artist: "Artist A / Artist B" with neither in DB → artist: "Artist A", featured_artists: ["Artist B"] (first listed = primary)
- **NEVER create a new compound artist** like "X и Y" or "X & Y" as a single artist name. Always split into primary + featured.
2. **Featured artists**: Many tracks include collaborations. Guest artists can be indicated by ANY of the following markers (case-insensitive) in the artist field, track title, filename, or path:
- English: "feat.", "ft.", "featuring", "with"
- Russian: "п.у.", "при участии"
- Parenthetical: "(feat. X)", "(ft. X)", "(п.у. X)", "(при участии X)"
- Any other language-specific equivalent indicating a guest/featured collaboration
You must:
- Extract the **primary artist** (the main performer) into the "artist" field.
- Extract ALL **featured/guest artists** into a separate "featured_artists" array.
- Remove the collaboration marker and featured artist names from the track title, keeping only the song name.
- When multiple featured artists are listed, split them by commas or "&" into separate entries.
- Examples:
- Artist: "НСМВГЛП feat. XACV SQUAD" → artist: "НСМВГЛП", featured_artists: ["XACV SQUAD"]
- Title: "Знаешь ли ты feat. SharOn" → title: "Знаешь ли ты", featured_artists: ["SharOn"]
- Title: "Ваши мамки (п.у. Ваня Айван,Иван Смех, Жильцов)" → title: "Ваши мамки", featured_artists: ["Ваня Айван", "Иван Смех", "Жильцов"]
- **IMPORTANT**: Always check for parenthetical markers like "(п.у. ...)" or "(feat. ...)" at the end of track titles. These are very common and must not be missed.
- Apply the same capitalization and consistency rules to featured artist names.
- If the database already contains a matching featured artist name, use the existing canonical form.
3. **Release names** must use correct capitalization and canonical spelling.
- Use title case for English releases.
- Preserve original language for non-English releases.
- If the database already contains a matching release under the same artist, use the existing name exactly.
- Do not alter the creative content of release names (same principle as track titles).
- **Remastered editions**: A remastered release is a separate entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster, append " (Remastered)" to the release name if not already present, and use the year of the remaster release.
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
- Use title case for English titles.
- Preserve original language for non-English titles.
- Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content.
5. **Year**: If not present in tags, try to infer from the file path. Only set a year if you are confident it is correct.
6. **Track number**: If not present in tags, try to infer from the filename (e.g., "03 - Song.flac" → track 3).
7. **Genre**: Normalize to a common genre name. Avoid overly specific sub-genres unless the existing database already uses them.
8. **Encoding issues**: Raw metadata may contain mojibake (e.g., Cyrillic text misread as Latin-1). If you detect garbled text that looks like encoding errors, attempt to determine the intended text.
9. **Preservation principle**: When in doubt, preserve the original value. Only change metadata when you are confident the change is a correction.
10. **Consistency**: When the database already contains entries for an artist or release, your output MUST match the existing canonical names. All files from the same album MUST use the same artist name, album name, year, genre, and release_type.
11. **Confidence**: Rate your confidence from 0.0 to 1.0 per file.
- 1.0: All fields are clear and unambiguous.
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
- 0.5-0.8: Some guesswork involved, human review recommended.
- Below 0.5: Significant uncertainty, definitely needs review.
12. **Release type**: Determine the type of release based on all available evidence.
Allowed values (use exactly one, lowercase):
- `album`: Full-length release, typically 4+ tracks
- `single`: One or two tracks released as a single
- `ep`: Short release, typically 3-6 tracks
- `compilation`: Best-of, greatest hits, anthology
- `mixtape`: Mixtape release
- `live`: Live recording, concert, live album
- `soundtrack`: Film/game/TV soundtrack
- `remix`: Remix album or collection
- `demo`: Demo recording
Determination rules (in priority order):
- If the folder path contains keywords like "Single", "Сингл" → `single`
- If the folder path contains "EP" → `ep`
- If the folder path contains "Live", "Concert", "Концерт" → `live`
- If the folder path contains "Soundtrack", "OST" → `soundtrack`
- If the folder path contains "Remix" → `remix`
- If the folder path contains "Demo" → `demo`
- If the folder path contains "Mixtape" → `mixtape`
- If the folder path contains "Compilation", "сборник", "Greatest Hits" → `compilation`
- If total track count is 12 → likely `single`
- If total track count is 36 → likely `ep`
- If track count is 7+ → likely `album`
- When in doubt, default to `album`
## Input format
You will receive metadata for MULTIPLE files from the same folder at once. Each file is separated by a heading with its filename. Process ALL files and return results for each one.
## Response format
You MUST respond with a JSON array. Each element corresponds to one input file and MUST include the "filename" field matching the input filename exactly:
[{"filename": "01 - Song.flac", "artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": [], "release_type": "album", "confidence": 0.95, "notes": "..."}, {"filename": "02 - Song.flac", "artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 2, "genre": "...", "featured_artists": [], "release_type": "album", "confidence": 0.95, "notes": "..."}]
- Use null for fields you cannot determine.
- Use an empty array [] for "featured_artists" if there are no featured artists.
- The "notes" field should briefly explain what you changed and why.
- "release_type" must be exactly one of: "album", "single", "ep", "compilation", "mixtape", "live", "soundtrack", "remix", "demo"
- You MUST return exactly one result per input file. Do not skip any files.
- The "filename" field MUST match the input filename character-for-character.
+522 -5
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use cot::db::Database;
use cot::db::migrations::SyncDynMigration;
use cot::json::Json;
use cot::request::extractors::{Path, RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
@@ -15,8 +16,14 @@ use serde::Deserialize;
use crate::auth;
use crate::config::AppConfig;
use crate::i18n::I18n;
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
use views::{OidcSettingsForm, SetupForm, UserForm};
use views::{ArtistForm, CronForm, OidcSettingsForm, ReleaseForm, SetImageBody, SetupForm, UploadImageBody, UserForm};
#[derive(Debug, Deserialize)]
struct ReviewsQuery {
status: Option<String>,
}
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
#[derive(Debug)]
@@ -42,11 +49,17 @@ pub static BUILD_INFO: BuildInfo = BuildInfo {
pub struct AdminApp {
config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
}
impl AdminApp {
pub fn new(config: Arc<AppConfig>) -> Self {
Self { config }
pub fn new(
config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
) -> Self {
Self { config, registry, scheduler_handle }
}
}
@@ -60,12 +73,32 @@ struct PathId {
id: i64,
}
#[derive(Debug, Deserialize)]
struct PathName {
name: String,
}
#[derive(Debug, Deserialize)]
struct PathNameRunId {
name: String,
run_id: i64,
}
#[derive(Debug, Deserialize)]
struct ReleasesQuery {
artist_id: Option<i64>,
}
impl App for AdminApp {
fn name(&self) -> &'static str {
"admin"
}
fn router(&self) -> Router {
// Create a shared sqlx pool for admin routes that need it
let pool_config = Arc::clone(&self.config);
let pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>> = Arc::new(tokio::sync::OnceCell::new());
Router::with_urls([
// -- Setup (first-run, no auth required) --------------------------
Route::with_handler_and_name(
@@ -95,12 +128,12 @@ impl App for AdminApp {
Route::with_handler_and_name(
"/",
|session: Session, db: Database, i18n: I18n| async move {
// First-run redirect
let count = User::count_all(&db).await.unwrap_or(0);
if count == 0 {
return Ok(auth::redirect("/admin/setup"));
}
let admin = match auth::require_admin_or_redirect(&session, &db).await {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
@@ -167,6 +200,27 @@ impl App for AdminApp {
}),
"admin_settings",
),
// -- Settings probe (HTMX fragment) -----------------------------------
Route::with_handler_and_name(
"/settings/probe",
{
let config = Arc::clone(&self.config);
move |session: Session, db: Database, i18n: I18n| {
let config = Arc::clone(&config);
async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::settings_probe_handler(admin, i18n, &config, &db)
.await?
.into_response()
}
}
},
"admin_settings_probe",
),
// -- Users --------------------------------------------------------
Route::with_handler_and_name(
"/users",
@@ -238,6 +292,463 @@ impl App for AdminApp {
),
"admin_users_delete",
),
// -- Artists ------------------------------------------------------
Route::with_handler_and_name(
"/artists",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_list(admin, i18n, &db).await?.into_response()
},
"admin_artists",
),
Route::with_handler_and_name(
"/artists/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_new(admin, i18n).await?.into_response()
})
.post(
|session: Session, db: Database, form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_create(admin, &db, form).await
},
),
"admin_artists_new",
),
Route::with_handler_and_name(
"/artists/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ArtistForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_update(admin, &db, path.0.id, form).await
},
),
"admin_artists_edit",
),
Route::with_handler_and_name(
"/artists/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_delete(admin, &db, path.0.id).await
},
),
"admin_artists_delete",
),
Route::with_handler_and_name(
"/artists/{id}/available-covers",
get(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_available_covers(admin, &db, path.0.id).await
},
),
"admin_artists_available_covers",
),
Route::with_handler_and_name(
"/artists/{id}/set-image",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>,
json: Json<SetImageBody>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::artists_set_image(admin, &db, path.0.id, json.0).await
},
),
"admin_artists_set_image",
),
Route::with_handler_and_name(
"/artists/{id}/upload-image",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>,
json: Json<UploadImageBody>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
let (live_config, _) = AppConfig::load_with_db(&db).await;
views::artists_upload_image(admin, &db, pg_pool, &live_config, path.0.id, json.0).await
}
}
}),
"admin_artists_upload_image",
),
// -- Releases -----------------------------------------------------
Route::with_handler_and_name(
"/releases",
|session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReleasesQuery>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_list(admin, i18n, &db, query.0.artist_id)
.await?
.into_response()
},
"admin_releases",
),
Route::with_handler_and_name(
"/releases/new",
get(|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_new(admin, i18n, &db).await?.into_response()
})
.post(
|session: Session, db: Database,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_create(admin, &db, form).await
},
),
"admin_releases_new",
),
Route::with_handler_and_name(
"/releases/{id}/edit",
get(
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_edit(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
)
.post(
|session: Session, db: Database, path: Path<PathId>,
form: RequestForm<ReleaseForm>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_update(admin, &db, path.0.id, form).await
},
),
"admin_releases_edit",
),
Route::with_handler_and_name(
"/releases/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::releases_delete(admin, &db, path.0.id).await
},
),
"admin_releases_delete",
),
// -- Media Files --------------------------------------------------
Route::with_handler_and_name(
"/media-files",
|session: Session, db: Database, i18n: I18n| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_list(admin, i18n, &db).await?.into_response()
},
"admin_media_files",
),
Route::with_handler_and_name(
"/media-files/{id}/delete",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::media_files_delete(admin, &db, path.0.id).await
},
),
"admin_media_files_delete",
),
// -- Jobs ---------------------------------------------------------
Route::with_handler_and_name(
"/jobs",
{
let registry = Arc::clone(&self.registry);
move |session: Session, db: Database, i18n: I18n| {
let registry = Arc::clone(&registry);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::jobs_list(admin, i18n, &db, &registry).await?.into_response()
}
}
},
"admin_jobs",
),
Route::with_handler_and_name(
"/jobs/{name}/run",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_now(admin, &handle, &path.0.name).await
}
}
}),
"admin_job_run",
),
Route::with_handler_and_name(
"/jobs/{name}/toggle",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_toggle_enabled(admin, &db, &handle, &path.0.name).await
}
}
}),
"admin_job_toggle",
),
Route::with_handler_and_name(
"/jobs/{name}/cron",
cot::router::method::post({
let handle = Arc::clone(&self.scheduler_handle);
move |session: Session, db: Database, path: Path<PathName>,
form: RequestForm<CronForm>| {
let handle = Arc::clone(&handle);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_update_cron(admin, &db, &handle, &path.0.name, form).await
}
}
}),
"admin_job_cron",
),
Route::with_handler_and_name(
"/jobs/{name}/runs/{run_id}",
{
move |session: Session, db: Database, i18n: I18n,
path: Path<PathNameRunId>| {
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::job_run_detail(admin, i18n, &db, &path.0.name, path.0.run_id)
.await?
.into_response()
}
}
},
"admin_job_run_detail",
),
Route::with_handler_and_name(
"/jobs/{name}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
path: Path<PathName>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::job_detail(admin, i18n, &db, pg_pool, &path.0.name)
.await?
.into_response()
}
}
},
"admin_job_detail",
),
// -- Reviews: clear -----------------------------------------------
Route::with_handler_and_name(
"/reviews/clear",
cot::router::method::post(
|session: Session, db: Database,
query: UrlQuery<ReviewsQuery>| async move {
let admin =
match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::reviews_clear(admin, &db, query.0.status.as_deref()).await
},
),
"admin_reviews_clear",
),
// -- Reviews ------------------------------------------------------
Route::with_handler_and_name(
"/reviews",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, i18n: I18n,
query: UrlQuery<ReviewsQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::reviews_list(admin, i18n, &db, pg_pool, query.0.status.as_deref())
.await?
.into_response()
}
}
},
"admin_reviews",
),
Route::with_handler_and_name(
"/reviews/{id}",
|session: Session, db: Database, i18n: I18n,
path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_detail(admin, i18n, &db, path.0.id)
.await?
.into_response()
},
"admin_review_detail",
),
Route::with_handler_and_name(
"/reviews/{id}/approve",
cot::router::method::post({
let config = Arc::clone(&self.config);
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathId>| {
let config = Arc::clone(&config);
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
let pg_pool = pool.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(3)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
}).await;
views::review_approve(admin, &config, &db, pg_pool, path.0.id).await
}
}
}),
"admin_review_approve",
),
Route::with_handler_and_name(
"/reviews/{id}/reject",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_reject(admin, &db, path.0.id).await
},
),
"admin_review_reject",
),
Route::with_handler_and_name(
"/reviews/{id}/requeue",
cot::router::method::post(
|session: Session, db: Database, path: Path<PathId>| async move {
let admin = match auth::require_admin_or_redirect(&session, &db).await {
Ok(u) => u,
Err(resp) => return Ok(resp),
};
views::review_requeue(admin, &db, path.0.id).await
},
),
"admin_review_requeue",
),
])
}
@@ -247,6 +758,12 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations(
crate::user::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::music::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::scheduler::db_migrations::MIGRATIONS,
));
all
}
}
+1141 -11
View File
File diff suppressed because it is too large Load Diff
+408
View File
@@ -0,0 +1,408 @@
//! Cover art extraction and management.
//!
//! Sources (in priority order):
//! 1. Standalone image files in the album folder (cover.jpg, folder.jpg, etc.)
//! 2. Embedded cover art in audio file metadata (ID3 APIC, Vorbis METADATA_BLOCK_PICTURE, etc.)
//!
//! The first usable image found is saved as a MediaFile with file_type="cover_art"
//! and linked to the Release via cover_file_id.
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// Image data extracted from an audio file or found on disk.
#[derive(Debug)]
pub struct CoverImage {
pub data: Vec<u8>,
pub mime_type: String,
/// Where this image came from (for logging).
pub source: CoverSource,
}
#[derive(Debug)]
pub enum CoverSource {
/// A standalone image file in the folder.
FolderFile(PathBuf),
/// Embedded in an audio file's metadata.
Embedded(PathBuf),
}
/// Well-known cover art filenames, in priority order.
/// Case-insensitive matching is used.
const COVER_FILENAMES: &[&str] = &[
"cover",
"folder",
"front",
"album",
"albumart",
"albumartsmall",
"thumb",
"artwork",
];
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "gif"];
fn is_image_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str())
}
fn mime_for_image(path: &Path) -> String {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" => "image/jpeg".to_string(),
"png" => "image/png".to_string(),
"webp" => "image/webp".to_string(),
"gif" => "image/gif".to_string(),
"bmp" => "image/bmp".to_string(),
_ => "application/octet-stream".to_string(),
}
}
/// Scan a folder for image files that look like cover art.
///
/// Returns image file paths sorted by priority:
/// - Files with well-known names (cover.jpg, front.png, etc.) first
/// - Then any other image files
pub fn find_folder_images(folder: &Path) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(folder) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut images: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().into_owned();
!name.starts_with('.') && is_image_file(&name)
})
.map(|e| e.path())
.collect();
// Sort: well-known names first (by priority index), then alphabetically
images.sort_by(|a, b| {
let pri_a = cover_name_priority(a);
let pri_b = cover_name_priority(b);
pri_a.cmp(&pri_b).then_with(|| a.cmp(b))
});
images
}
/// Return a priority index for a filename (lower = higher priority).
/// Well-known cover filenames get indices 0..N, unknown ones get usize::MAX.
fn cover_name_priority(path: &Path) -> usize {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
for (i, &known) in COVER_FILENAMES.iter().enumerate() {
if stem == known {
return i;
}
}
usize::MAX
}
/// Try to find the best cover image for a folder of audio files.
///
/// Strategy:
/// 1. Look for standalone image files in the folder (prioritized by filename).
/// 2. Try to extract embedded cover art from each audio file.
///
/// Returns the first usable image found, or None.
pub async fn find_best_cover(
folder: &Path,
audio_files: &[PathBuf],
) -> Option<CoverImage> {
// Strategy 1: folder images
let folder_images = find_folder_images(folder);
for img_path in &folder_images {
match tokio::fs::read(img_path).await {
Ok(data) if !data.is_empty() => {
let mime = mime_for_image(img_path);
return Some(CoverImage {
data,
mime_type: mime,
source: CoverSource::FolderFile(img_path.clone()),
});
}
_ => continue,
}
}
// Strategy 2: embedded cover art from audio files
for audio_path in audio_files {
let path = audio_path.to_path_buf();
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
if let Ok(Some(cover)) = result {
return Some(cover);
}
}
None
}
/// Extract embedded cover art from an audio file.
///
/// Tries Symphonia first (works for FLAC, OGG, etc.), then falls back to
/// id3 crate for MP3 files.
///
/// Must be called from a blocking context.
fn extract_embedded_cover(path: &Path) -> Option<CoverImage> {
// Try Symphonia visuals first
if let Some(cover) = extract_cover_symphonia(path) {
return Some(cover);
}
// Fallback: id3 for MP3
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("mp3"))
.unwrap_or(false);
if is_mp3 {
return extract_cover_id3(path);
}
None
}
fn extract_cover_symphonia(path: &Path) -> Option<CoverImage> {
use symphonia::core::formats::FormatOptions;
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions {
enable_gapless: false,
..Default::default()
},
&MetadataOptions::default(),
)
.ok()?;
// Check side-data metadata (ID3 before format)
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
for visual in rev.visuals() {
if !visual.data.is_empty() {
let mime = if visual.media_type.is_empty() {
guess_image_mime(&visual.data)
} else {
visual.media_type.to_string()
};
return Some(CoverImage {
data: visual.data.to_vec(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
});
}
}
}
// Check format-level metadata
if let Some(rev) = probed.format.metadata().current() {
for visual in rev.visuals() {
if !visual.data.is_empty() {
let mime = if visual.media_type.is_empty() {
guess_image_mime(&visual.data)
} else {
visual.media_type.to_string()
};
return Some(CoverImage {
data: visual.data.to_vec(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
});
}
}
}
None
}
fn extract_cover_id3(path: &Path) -> Option<CoverImage> {
let tag = id3::Tag::read_from_path(path).ok()?;
// Prefer front cover (picture type 3), then any picture
let mut best: Option<&id3::frame::Picture> = None;
for pic in tag.pictures() {
if pic.picture_type == id3::frame::PictureType::CoverFront {
best = Some(pic);
break;
}
if best.is_none() {
best = Some(pic);
}
}
let pic = best?;
if pic.data.is_empty() {
return None;
}
let mime = if pic.mime_type.is_empty() || pic.mime_type == "image/" {
guess_image_mime(&pic.data)
} else {
pic.mime_type.clone()
};
Some(CoverImage {
data: pic.data.clone(),
mime_type: mime,
source: CoverSource::Embedded(path.to_path_buf()),
})
}
/// Guess MIME type from image magic bytes.
fn guess_image_mime(data: &[u8]) -> String {
if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
"image/jpeg".to_string()
} else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
"image/png".to_string()
} else if data.starts_with(b"RIFF") && data.len() > 12 && &data[8..12] == b"WEBP" {
"image/webp".to_string()
} else if data.starts_with(b"GIF8") {
"image/gif".to_string()
} else if data.starts_with(&[0x42, 0x4D]) {
"image/bmp".to_string()
} else {
"image/jpeg".to_string() // default assumption
}
}
/// Compute SHA-256 hash of image data.
pub fn hash_image(data: &[u8]) -> String {
let digest = Sha256::digest(data);
format!("{:x}", digest)
}
/// Extension for a MIME type.
pub fn extension_for_mime(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
"image/bmp" => "bmp",
_ => "jpg",
}
}
/// Save cover image data to the storage directory and create a MediaFile record.
///
/// Returns the MediaFile ID on success.
pub async fn save_cover_to_storage(
db: &cot::db::Database,
pool: &sqlx::PgPool,
storage_dir: &str,
artist_name: &str,
release_title: &str,
cover: &CoverImage,
) -> anyhow::Result<i64> {
let hash = hash_image(&cover.data);
// Check if we already have this exact image in the DB
let existing: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM furumusic__media_file WHERE sha256_hash = $1 AND file_type = 'cover_art' LIMIT 1",
)
.bind(&hash)
.fetch_optional(pool)
.await?;
if let Some((id,)) = existing {
return Ok(id);
}
let ext = extension_for_mime(&cover.mime_type);
let filename = format!("cover.{ext}");
let artist_dir = sanitize_dir_name(artist_name);
let album_dir = sanitize_dir_name(release_title);
let dest_dir = Path::new(storage_dir).join(&artist_dir).join(&album_dir);
tokio::fs::create_dir_all(&dest_dir).await?;
let dest_path = dest_dir.join(&filename);
// Write image data
tokio::fs::write(&dest_path, &cover.data).await?;
let relative_path = dest_path.to_string_lossy().to_string();
let file_size = cover.data.len() as i64;
let media_file = crate::music::MediaFile::create(
db,
"cover_art",
&relative_path,
&filename,
&cover.mime_type,
file_size,
&hash,
None,
None,
None,
None,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
tracing::info!(
media_file_id = media_file.id_val(),
hash = %hash,
mime = %cover.mime_type,
size = file_size,
"Saved cover art"
);
Ok(media_file.id_val())
}
/// Set the cover_file_id on a release (if not already set).
pub async fn assign_cover_to_release(
pool: &sqlx::PgPool,
release_id: i64,
cover_file_id: i64,
) -> anyhow::Result<()> {
sqlx::query(
"UPDATE furumusic__release SET cover_file_id = $1, updated_at = $3 WHERE id = $2 AND cover_file_id IS NULL",
)
.bind(cover_file_id)
.bind(release_id)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await?;
Ok(())
}
fn sanitize_dir_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_owned()
}
+65
View File
@@ -0,0 +1,65 @@
use serde::{Deserialize, Serialize};
/// Raw metadata extracted from audio file tags.
#[derive(Debug, Default)]
pub struct RawMetadata {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub track_number: Option<u32>,
pub year: Option<u32>,
pub genre: Option<String>,
pub duration_secs: Option<f64>,
}
/// Hints parsed from the file path (directory structure + filename).
#[derive(Debug, Default)]
pub struct PathHints {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
}
/// Normalized metadata returned by the LLM.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct NormalizedFields {
pub title: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub genre: Option<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
pub release_type: Option<String>,
pub confidence: Option<f64>,
pub notes: Option<String>,
}
/// A similar artist found via pg_trgm fuzzy search.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SimilarArtist {
pub id: i64,
pub name: String,
pub similarity: f32,
}
/// A similar release found via pg_trgm fuzzy search.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SimilarRelease {
pub id: i64,
pub title: String,
pub year: Option<i32>,
pub similarity: f32,
}
/// Context about other files in the same folder (for the LLM).
pub struct FolderContext {
pub folder_path: String,
pub folder_files: Vec<String>,
pub track_count: usize,
}
+169
View File
@@ -0,0 +1,169 @@
use std::path::Path;
use symphonia::core::{
codecs::CODEC_TYPE_NULL,
formats::FormatOptions,
io::MediaSourceStream,
meta::{MetadataOptions, StandardTagKey},
probe::Hint,
};
use super::dto::RawMetadata;
/// Extract metadata from an audio file.
///
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
/// (e.g. ID3 tag with large embedded cover art exceeds Symphonia's probe limit).
///
/// Must be called from a blocking context (`spawn_blocking`).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => Ok(meta),
Err(e) => {
let is_mp3 = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("mp3"))
.unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
extract_mp3_via_id3(path)
} else {
Err(e)
}
}
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe().format(
&hint,
mss,
&FormatOptions {
enable_gapless: false,
..Default::default()
},
&MetadataOptions::default(),
)?;
let mut meta = RawMetadata::default();
// Check metadata side-data (e.g. ID3 tags probed before format)
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
extract_tags(rev.tags(), &mut meta);
}
// Also check format-embedded metadata
if let Some(rev) = probed.format.metadata().current() {
if meta.title.is_none() {
extract_tags(rev.tags(), &mut meta);
}
}
// Duration
meta.duration_secs = probed
.format
.tracks()
.iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.and_then(|t| {
let n_frames = t.codec_params.n_frames?;
let tb = t.codec_params.time_base?;
Some(n_frames as f64 * tb.numer as f64 / tb.denom as f64)
});
Ok(meta)
}
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
use id3::TagLike;
let tag =
id3::Tag::read_from_path(path).map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
let mut meta = RawMetadata::default();
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
meta.track_number = tag.track();
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
Ok(meta)
}
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
for tag in tags {
let value = fix_encoding(tag.value.to_string());
if let Some(key) = tag.std_key {
match key {
StandardTagKey::TrackTitle => {
if meta.title.is_none() {
meta.title = Some(value);
}
}
StandardTagKey::Artist | StandardTagKey::Performer => {
if meta.artist.is_none() {
meta.artist = Some(value);
}
}
StandardTagKey::Album => {
if meta.album.is_none() {
meta.album = Some(value);
}
}
StandardTagKey::TrackNumber => {
if meta.track_number.is_none() {
meta.track_number = value.parse().ok();
}
}
StandardTagKey::Date | StandardTagKey::OriginalDate => {
if meta.year.is_none() {
meta.year = value[..4.min(value.len())].parse().ok();
}
}
StandardTagKey::Genre => {
if meta.genre.is_none() {
meta.genre = Some(value);
}
}
_ => {}
}
}
}
}
/// Heuristic to fix mojibake (CP1251 bytes interpreted as Latin-1/Windows-1252).
fn fix_encoding(s: String) -> String {
let bytes: Vec<u8> = s
.chars()
.map(|c| c as u32)
.filter(|&c| c <= 255)
.map(|c| c as u8)
.collect();
if bytes.len() != s.chars().count() {
return s;
}
let has_mojibake = bytes.iter().any(|&b| b >= 0xC0);
if !has_mojibake {
return s;
}
let (decoded, _, errors) = encoding_rs::WINDOWS_1251.decode(&bytes);
if errors {
return s;
}
decoded.into_owned()
}
+156
View File
@@ -0,0 +1,156 @@
pub mod cover_art;
pub mod dto;
pub mod metadata;
pub mod mover;
pub mod normalize;
pub mod path_hints;
pub mod rag;
use serde::Deserialize;
// ---------------------------------------------------------------------------
// LLM health probe — called from the admin settings page
// ---------------------------------------------------------------------------
/// Result of probing the LLM API.
#[derive(Debug, Default)]
pub struct AgentProbeResult {
pub ok: bool,
pub model_intro: String,
pub model_name: String,
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub tokens_per_sec: Option<f64>,
pub latency_ms: u64,
pub error: String,
}
/// Send a lightweight "introduce yourself" prompt to the LLM and return the
/// response together with timing / usage statistics when available.
pub async fn probe_llm(
llm_url: &str,
llm_model: &str,
llm_auth: &str,
) -> AgentProbeResult {
let start = std::time::Instant::now();
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
return AgentProbeResult {
error: format!("failed to create HTTP client: {e}"),
..Default::default()
};
}
};
let body = serde_json::json!({
"model": llm_model,
"messages": [
{
"role": "user",
"content": "Introduce yourself briefly: what model are you, who made you? Reply in 12 sentences."
}
],
"stream": false,
"temperature": 0.3,
"max_tokens": 256
});
let url = format!("{}/v1/chat/completions", llm_url.trim_end_matches('/'));
let mut req = client.post(&url).json(&body);
if !llm_auth.is_empty() {
req = req.header("Authorization", llm_auth);
}
let resp = match req.send().await {
Ok(r) => r,
Err(e) => {
return AgentProbeResult {
latency_ms: start.elapsed().as_millis() as u64,
error: format!("connection failed: {e}"),
..Default::default()
};
}
};
let elapsed = start.elapsed();
let latency_ms = elapsed.as_millis() as u64;
if !resp.status().is_success() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
return AgentProbeResult {
latency_ms,
error: format!("HTTP {status}: {}", &body_text[..body_text.len().min(300)]),
..Default::default()
};
}
#[derive(Deserialize)]
struct ProbeResponse {
choices: Option<Vec<ProbeChoice>>,
model: Option<String>,
usage: Option<ProbeUsage>,
}
#[derive(Deserialize)]
struct ProbeChoice {
message: Option<ProbeMessage>,
}
#[derive(Deserialize)]
struct ProbeMessage {
content: Option<String>,
}
#[derive(Deserialize)]
struct ProbeUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
}
let raw: ProbeResponse = match resp.json().await {
Ok(r) => r,
Err(e) => {
return AgentProbeResult {
latency_ms,
error: format!("failed to parse response: {e}"),
..Default::default()
};
}
};
let model_intro = raw
.choices
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.message.as_ref())
.and_then(|m| m.content.clone())
.unwrap_or_default();
let model_name = raw.model.unwrap_or_default();
let prompt_tokens = raw.usage.as_ref().and_then(|u| u.prompt_tokens);
let completion_tokens = raw.usage.as_ref().and_then(|u| u.completion_tokens);
// Compute tokens/sec from completion tokens and wall time
let tokens_per_sec = completion_tokens.map(|ct| {
if elapsed.as_secs_f64() > 0.0 {
ct as f64 / elapsed.as_secs_f64()
} else {
0.0
}
});
AgentProbeResult {
ok: true,
model_intro,
model_name,
prompt_tokens,
completion_tokens,
tokens_per_sec,
latency_ms,
error: String::new(),
}
}
+66
View File
@@ -0,0 +1,66 @@
use std::path::{Path, PathBuf};
pub enum MoveOutcome {
/// File was moved/renamed to destination.
Moved(PathBuf),
/// Destination already existed; inbox duplicate was removed.
Merged(PathBuf),
}
/// Move a file from inbox to the permanent storage directory.
///
/// Creates the directory structure: `storage_dir/artist/album/filename`
///
/// If `rename` fails (cross-device), falls back to copy + remove.
/// If the destination already exists the inbox copy is removed and
/// `MoveOutcome::Merged` is returned.
pub async fn move_to_storage(
storage_dir: &Path,
artist: &str,
album: &str,
filename: &str,
source: &Path,
) -> anyhow::Result<MoveOutcome> {
let artist_dir = sanitize_dir_name(artist);
let album_dir = sanitize_dir_name(album);
let dest_dir = storage_dir.join(&artist_dir).join(&album_dir);
tokio::fs::create_dir_all(&dest_dir).await?;
let dest = dest_dir.join(filename);
// File already at destination — remove the inbox duplicate
if dest.exists() {
if source.exists() {
tokio::fs::remove_file(source).await?;
tracing::info!(from = ?source, to = ?dest, "merged duplicate into existing storage file");
}
return Ok(MoveOutcome::Merged(dest));
}
// Try atomic rename first (same filesystem)
match tokio::fs::rename(source, &dest).await {
Ok(()) => {}
Err(_) => {
// Cross-device: copy then remove
tokio::fs::copy(source, &dest).await?;
tokio::fs::remove_file(source).await?;
}
}
tracing::info!(from = ?source, to = ?dest, "moved file to storage");
Ok(MoveOutcome::Moved(dest))
}
/// Remove characters that are unsafe for directory names.
fn sanitize_dir_name(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
_ => c,
})
.collect::<String>()
.trim()
.trim_matches('.')
.to_owned()
}
+483
View File
@@ -0,0 +1,483 @@
use serde::{Deserialize, Serialize};
use super::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata, SimilarArtist, SimilarRelease};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// A single message in the chat history.
#[derive(Clone, Serialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
response_format: ChatResponseFormat,
stream: bool,
temperature: f64,
}
#[derive(Serialize)]
struct ChatResponseFormat {
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
struct ChatResponse {
model: Option<String>,
choices: Vec<ChatChoice>,
usage: Option<ChatUsage>,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Deserialize)]
struct ChatResponseMessage {
content: String,
}
#[derive(Deserialize, Default)]
struct ChatUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
}
async fn call_llm_chat(
base_url: &str,
model: &str,
messages: &[ChatMessage],
auth: Option<&str>,
) -> anyhow::Result<(String, String, ChatUsage)> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(600))
.build()?;
let request = ChatRequest {
model: model.to_owned(),
messages: messages.to_vec(),
response_format: ChatResponseFormat {
kind: "json_object".to_owned(),
},
stream: false,
temperature: 0.1,
};
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
tracing::info!(
%url,
model,
message_count = messages.len(),
"Calling LLM API (chat mode)..."
);
let start = std::time::Instant::now();
let mut req = client.post(&url).json(&request);
if let Some(auth_header) = auth {
req = req.header("Authorization", auth_header);
}
let resp = req.send().await?;
let elapsed = start.elapsed();
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
anyhow::bail!("LLM returned {}: {}", status, body);
}
let chat_resp: ChatResponse = resp.json().await?;
let resp_model = chat_resp.model.unwrap_or_else(|| model.to_owned());
let usage = chat_resp.usage.unwrap_or_default();
let content = chat_resp
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))?
.message
.content;
tracing::info!(
elapsed_ms = elapsed.as_millis() as u64,
response_len = content.len(),
prompt_tokens = usage.prompt_tokens.unwrap_or(0),
completion_tokens = usage.completion_tokens.unwrap_or(0),
model = %resp_model,
"LLM response received"
);
tracing::debug!(raw_response = %content, "LLM raw output");
Ok((content, resp_model, usage))
}
// ---------------------------------------------------------------------------
// Batch normalize — process multiple files in one LLM call
// ---------------------------------------------------------------------------
/// Input for one file in a batch normalize call.
pub struct BatchFileInput {
pub filename: String,
pub raw: RawMetadata,
pub hints: PathHints,
}
/// Result of a batch normalize call.
pub struct BatchNormalizeResult {
/// (filename, normalized_fields) pairs.
pub results: Vec<(String, NormalizedFields)>,
pub model: String,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub duration_ms: u64,
}
/// Estimate the token count for a batch of files.
/// Uses the rough heuristic of 1 token per 4 characters.
fn estimate_batch_tokens(
system_prompt: &str,
files: &[BatchFileInput],
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> u64 {
let system_tokens = system_prompt.len() as u64 / 4;
// Shared context (RAG + folder) — sent once
let mut shared_chars: u64 = 0;
for a in similar_artists {
shared_chars += 40 + a.name.len() as u64;
}
for r in similar_releases {
shared_chars += 50 + r.title.len() as u64;
}
if let Some(ctx) = folder_ctx {
shared_chars += 60 + ctx.folder_path.len() as u64;
for f in &ctx.folder_files {
shared_chars += 4 + f.len() as u64;
}
}
let shared_tokens = shared_chars / 4;
// Per-file: metadata input + expected response
let mut per_file_tokens: u64 = 0;
for f in files {
let mut chars: u64 = 40 + f.filename.len() as u64; // header
if let Some(v) = &f.raw.title { chars += 10 + v.len() as u64; }
if let Some(v) = &f.raw.artist { chars += 12 + v.len() as u64; }
if let Some(v) = &f.raw.album { chars += 12 + v.len() as u64; }
if f.raw.year.is_some() { chars += 12; }
if f.raw.track_number.is_some() { chars += 18; }
if let Some(v) = &f.raw.genre { chars += 10 + v.len() as u64; }
// hints
if let Some(v) = &f.hints.artist { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.album { chars += 16 + v.len() as u64; }
if let Some(v) = &f.hints.title { chars += 15 + v.len() as u64; }
if f.hints.year.is_some() { chars += 14; }
if f.hints.track_number.is_some() { chars += 20; }
per_file_tokens += chars / 4;
// Expected response per file (~150 tokens)
per_file_tokens += 150;
}
system_tokens + shared_tokens + per_file_tokens
}
/// Build the user message for a batch of files.
fn build_batch_user_message(
files: &[BatchFileInput],
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> String {
let mut msg = String::with_capacity(4096);
// Shared context first
if let Some(ctx) = folder_ctx {
msg.push_str("## Folder context\n");
msg.push_str(&format!("Folder path: \"{}\"\n", ctx.folder_path));
msg.push_str(&format!("Total files in folder: {}\n\n", ctx.track_count));
}
if !similar_artists.is_empty() {
msg.push_str("## Existing artists in database\n");
for a in similar_artists {
msg.push_str(&format!("- \"{}\" (similarity: {:.2})\n", a.name, a.similarity));
}
msg.push('\n');
}
if !similar_releases.is_empty() {
msg.push_str("## Existing releases in database\n");
for r in similar_releases {
let year_str = r.year.map(|y| format!(", year: {y}")).unwrap_or_default();
msg.push_str(&format!("- \"{}\" (similarity: {:.2}{})\n", r.title, r.similarity, year_str));
}
msg.push('\n');
}
// Per-file metadata
msg.push_str(&format!("## Files to process ({})\n\n", files.len()));
for f in files {
msg.push_str(&format!("### {}\n", f.filename));
if let Some(v) = &f.raw.title { msg.push_str(&format!("Title: \"{v}\"\n")); }
if let Some(v) = &f.raw.artist { msg.push_str(&format!("Artist: \"{v}\"\n")); }
if let Some(v) = &f.raw.album { msg.push_str(&format!("Release: \"{v}\"\n")); }
if let Some(v) = f.raw.year { msg.push_str(&format!("Year: {v}\n")); }
if let Some(v) = f.raw.track_number { msg.push_str(&format!("Track: {v}\n")); }
if let Some(v) = &f.raw.genre { msg.push_str(&format!("Genre: \"{v}\"\n")); }
// Path hints (only if different from tag metadata)
let has_hints = f.hints.artist.is_some()
|| f.hints.album.is_some()
|| f.hints.title.is_some()
|| f.hints.year.is_some()
|| f.hints.track_number.is_some();
if has_hints {
if let Some(v) = &f.hints.artist { msg.push_str(&format!("Path artist: \"{v}\"\n")); }
if let Some(v) = &f.hints.album { msg.push_str(&format!("Path release: \"{v}\"\n")); }
if let Some(v) = &f.hints.title { msg.push_str(&format!("Path title: \"{v}\"\n")); }
if let Some(v) = f.hints.year { msg.push_str(&format!("Path year: {v}\n")); }
if let Some(v) = f.hints.track_number { msg.push_str(&format!("Path track: {v}\n")); }
}
msg.push('\n');
}
msg
}
/// Normalize a batch of files in one LLM call.
/// If the batch is too large for the context window, it is automatically
/// split in half and each half is processed recursively.
pub async fn normalize_batch(
llm_url: &str,
llm_model: &str,
llm_auth: &str,
system_prompt: &str,
context_limit: u64,
files: Vec<BatchFileInput>,
similar_artists: &[SimilarArtist],
similar_releases: &[SimilarRelease],
folder_ctx: Option<&FolderContext>,
) -> anyhow::Result<BatchNormalizeResult> {
// Estimate tokens
let estimated = estimate_batch_tokens(
system_prompt, &files, similar_artists, similar_releases, folder_ctx,
);
// If over 80% of context limit and more than 1 file, split
let limit_80 = context_limit * 80 / 100;
if estimated > limit_80 && files.len() > 1 {
tracing::info!(
estimated_tokens = estimated,
context_limit,
file_count = files.len(),
"Batch too large, splitting in half"
);
let mid = files.len() / 2;
let mut files_vec = files;
let right = files_vec.split_off(mid);
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
// Merge results
let mut results = left_result.results;
results.extend(right_result.results);
return Ok(BatchNormalizeResult {
results,
model: left_result.model,
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
duration_ms: left_result.duration_ms + right_result.duration_ms,
});
}
// Build and send
let user_message = build_batch_user_message(
&files, similar_artists, similar_releases, folder_ctx,
);
let messages = vec![
ChatMessage { role: "system".into(), content: system_prompt.to_owned() },
ChatMessage { role: "user".into(), content: user_message },
];
let start = std::time::Instant::now();
let call_result = call_llm_chat(
llm_url, llm_model, &messages,
if llm_auth.is_empty() { None } else { Some(llm_auth) },
).await;
let duration_ms = start.elapsed().as_millis() as u64;
// If LLM error and batch > 1, try splitting (handles context overflow errors)
let (response_text, resp_model, usage) = match call_result {
Ok(r) => r,
Err(e) if files.len() > 1 => {
let err_str = e.to_string().to_lowercase();
let is_context_error = err_str.contains("context")
|| err_str.contains("too long")
|| err_str.contains("maximum")
|| err_str.contains("length")
|| err_str.contains("token");
if is_context_error {
tracing::warn!(
file_count = files.len(),
"LLM error suggests context overflow, splitting batch: {e}"
);
let mid = files.len() / 2;
let mut files_vec = files;
let right = files_vec.split_off(mid);
let left = files_vec;
let left_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
left, similar_artists, similar_releases, folder_ctx,
)).await?;
let right_result = Box::pin(normalize_batch(
llm_url, llm_model, llm_auth, system_prompt, context_limit,
right, similar_artists, similar_releases, folder_ctx,
)).await?;
let mut results = left_result.results;
results.extend(right_result.results);
return Ok(BatchNormalizeResult {
results,
model: left_result.model,
prompt_tokens: left_result.prompt_tokens + right_result.prompt_tokens,
completion_tokens: left_result.completion_tokens + right_result.completion_tokens,
duration_ms: left_result.duration_ms + right_result.duration_ms,
});
}
return Err(e);
}
Err(e) => return Err(e),
};
let prompt_tokens = usage.prompt_tokens.unwrap_or(0) as u64;
let completion_tokens = usage.completion_tokens.unwrap_or(0) as u64;
// Parse batch response
let results = parse_batch_response(&response_text, &files)?;
Ok(BatchNormalizeResult {
results,
model: resp_model,
prompt_tokens,
completion_tokens,
duration_ms,
})
}
/// Parse a batch JSON array response from the LLM.
/// Returns (filename, NormalizedFields) pairs.
/// Handles: clean JSON array, markdown-fenced JSON, and wrapped `{"results": [...]}`.
fn parse_batch_response(
response: &str,
files: &[BatchFileInput],
) -> anyhow::Result<Vec<(String, NormalizedFields)>> {
let cleaned = response.trim();
// Strip markdown code fences if present
let json_str = if cleaned.starts_with("```") {
let start = cleaned.find('[')
.or_else(|| cleaned.find('{'))
.unwrap_or(0);
let end_bracket = cleaned.rfind(']').map(|i| i + 1);
let end_brace = cleaned.rfind('}').map(|i| i + 1);
let end = end_bracket.or(end_brace).unwrap_or(cleaned.len());
&cleaned[start..end]
} else {
cleaned
};
#[derive(Deserialize)]
struct BatchLlmOutput {
filename: Option<String>,
artist: Option<String>,
album: Option<String>,
title: Option<String>,
year: Option<i32>,
track_number: Option<i32>,
genre: Option<String>,
#[serde(default)]
featured_artists: Vec<String>,
release_type: Option<String>,
confidence: Option<f64>,
notes: Option<String>,
}
// Try parsing as array first, then as {"results": [...]} wrapper
let items: Vec<BatchLlmOutput> = if json_str.starts_with('[') {
serde_json::from_str(json_str)
} else {
// Try as wrapper object with a "results" or "files" key
#[derive(Deserialize)]
struct Wrapper {
#[serde(alias = "files")]
results: Vec<BatchLlmOutput>,
}
serde_json::from_str::<Wrapper>(json_str).map(|w| w.results)
}
.map_err(|e| {
anyhow::anyhow!(
"Failed to parse batch LLM response: {} — raw: {}",
e,
&response[..response.len().min(500)]
)
})?;
// Build a map of filename → NormalizedFields
let mut results = Vec::with_capacity(files.len());
let mut matched = std::collections::HashSet::new();
for item in &items {
let filename = match &item.filename {
Some(f) => f.clone(),
None => continue,
};
let fields = NormalizedFields {
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
year: item.year,
track_number: item.track_number,
genre: item.genre.clone(),
featured_artists: item.featured_artists.clone(),
release_type: item.release_type.clone(),
confidence: item.confidence,
notes: item.notes.clone(),
};
matched.insert(filename.clone());
results.push((filename, fields));
}
// Warn about files the LLM missed
for f in files {
if !matched.contains(&f.filename) {
tracing::warn!(
filename = %f.filename,
"LLM batch response missing result for file"
);
}
}
Ok(results)
}
+197
View File
@@ -0,0 +1,197 @@
use std::path::Path;
use super::dto::PathHints;
/// Parse metadata hints from the file path relative to the inbox directory.
///
/// Recognized patterns:
/// Artist/Album/01 - Title.ext
/// Artist/Album (Year)/01 - Title.ext
/// Artist/(Year) Album/01 - Title.ext
/// Artist/Album [Year]/01 - Title.ext
/// 01 - Title.ext (flat, no artist/album)
pub fn parse(relative_path: &Path) -> PathHints {
let components: Vec<&str> = relative_path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
let mut hints = PathHints::default();
let filename = components.last().copied().unwrap_or("");
let stem = Path::new(filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
// Parse track number and title from filename
parse_filename(stem, &mut hints);
match components.len() {
// Artist/Album/file.ext
3.. => {
hints.artist = Some(components[0].to_owned());
let album_raw = components[1];
let (album, year) = parse_album_with_year(album_raw);
hints.album = Some(album);
if year.is_some() {
hints.year = year;
}
}
// Album/file.ext (or Artist/file.ext — ambiguous, treat as album)
2 => {
let dir = components[0];
let (name, year) = parse_album_with_year(dir);
hints.album = Some(name);
if year.is_some() {
hints.year = year;
}
}
// Just file.ext
_ => {}
}
hints
}
/// Try to extract track number and title from a filename stem.
///
/// Patterns: "01 - Title", "01. Title", "1 Title", "Title"
fn parse_filename(stem: &str, hints: &mut PathHints) {
let trimmed = stem.trim();
// Try "NN - Title" or "NN. Title"
if let Some(rest) = try_strip_track_prefix(trimmed) {
let (num_str, title) = rest;
if let Ok(num) = num_str.parse::<i32>() {
hints.track_number = Some(num);
if !title.is_empty() {
hints.title = Some(title.to_owned());
}
return;
}
}
// No track number found, use full stem as title
if !trimmed.is_empty() {
hints.title = Some(trimmed.to_owned());
}
}
/// Try to parse "NN - Rest" or "NN. Rest" from a string.
/// Returns (number_str, rest) if successful.
fn try_strip_track_prefix(s: &str) -> Option<(&str, &str)> {
let digit_end = s.find(|c: char| !c.is_ascii_digit())?;
if digit_end == 0 {
return None;
}
let num_str = &s[..digit_end];
let rest = s[digit_end..].trim_start();
let title = if let Some(stripped) = rest.strip_prefix("- ") {
stripped.trim()
} else if let Some(stripped) = rest.strip_prefix(". ") {
stripped.trim()
} else if let Some(stripped) = rest.strip_prefix('.') {
stripped.trim()
} else {
rest
};
Some((num_str, title))
}
/// Extract album name and optional year from directory name.
///
/// Patterns: "Album (2001)", "(2001) Album", "Album [2001]", "Album"
fn parse_album_with_year(dir: &str) -> (String, Option<i32>) {
// Try "Album (YYYY)" or "Album [YYYY]"
for (open, close) in [('(', ')'), ('[', ']')] {
if let Some(start) = dir.rfind(open) {
if let Some(end) = dir[start..].find(close) {
let inside = &dir[start + 1..start + end];
if let Ok(year) = inside.trim().parse::<i32>() {
if (1900..=2100).contains(&year) {
let album = format!(
"{}{}",
&dir[..start].trim(),
&dir[start + end + 1..].trim()
);
let album = album.trim().to_owned();
return (album, Some(year));
}
}
}
}
}
// Try "(YYYY) Album"
if dir.starts_with('(') {
if let Some(end) = dir.find(')') {
let inside = &dir[1..end];
if let Ok(year) = inside.trim().parse::<i32>() {
if (1900..=2100).contains(&year) {
let album = dir[end + 1..].trim().to_owned();
return (album, Some(year));
}
}
}
}
(dir.to_owned(), None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_artist_album_track() {
let p = PathBuf::from("Pink Floyd/Wish You Were Here (1975)/03 - Have a Cigar.flac");
let h = parse(&p);
assert_eq!(h.artist.as_deref(), Some("Pink Floyd"));
assert_eq!(h.album.as_deref(), Some("Wish You Were Here"));
assert_eq!(h.year, Some(1975));
assert_eq!(h.track_number, Some(3));
assert_eq!(h.title.as_deref(), Some("Have a Cigar"));
}
#[test]
fn test_year_prefix() {
let p = PathBuf::from("Artist/(2020) Album Name/01. Song.flac");
let h = parse(&p);
assert_eq!(h.artist.as_deref(), Some("Artist"));
assert_eq!(h.album.as_deref(), Some("Album Name"));
assert_eq!(h.year, Some(2020));
assert_eq!(h.track_number, Some(1));
assert_eq!(h.title.as_deref(), Some("Song"));
}
#[test]
fn test_flat_file() {
let p = PathBuf::from("05 - Something.mp3");
let h = parse(&p);
assert_eq!(h.artist, None);
assert_eq!(h.album, None);
assert_eq!(h.track_number, Some(5));
assert_eq!(h.title.as_deref(), Some("Something"));
}
#[test]
fn test_no_track_number() {
let p = PathBuf::from("Artist/Album/Song Name.flac");
let h = parse(&p);
assert_eq!(h.track_number, None);
assert_eq!(h.title.as_deref(), Some("Song Name"));
}
#[test]
fn test_square_bracket_year() {
let p = PathBuf::from("Band/Album [1999]/track.flac");
let h = parse(&p);
assert_eq!(h.album.as_deref(), Some("Album"));
assert_eq!(h.year, Some(1999));
}
}
+97
View File
@@ -0,0 +1,97 @@
use sqlx::PgPool;
use super::dto::{SimilarArtist, SimilarRelease};
/// Find artists with similar names using pg_trgm.
/// Short names (<3 chars) fall back to ILIKE prefix match.
pub async fn find_similar_artists(
pool: &PgPool,
name: &str,
limit: i32,
) -> anyhow::Result<Vec<SimilarArtist>> {
if name.chars().count() < 3 {
let rows: Vec<(i64, String, f32)> = sqlx::query_as(
"SELECT id, name, 1.0::real AS similarity FROM furumusic__artist \
WHERE name_sort ILIKE $1 || '%' ORDER BY name LIMIT $2",
)
.bind(name.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, similarity)| SimilarArtist {
id,
name,
similarity,
})
.collect())
} else {
let rows: Vec<(i64, String, f32)> = sqlx::query_as(
r#"SELECT id, name, MAX(sim) AS similarity FROM (
SELECT id, name, similarity(name_sort, $1) AS sim
FROM furumusic__artist WHERE name_sort % $1
UNION ALL
SELECT id, name, 0.01::real AS sim
FROM furumusic__artist WHERE name_sort ILIKE '%' || $1 || '%'
) sub GROUP BY id, name ORDER BY similarity DESC LIMIT $2"#,
)
.bind(name.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, name, similarity)| SimilarArtist {
id,
name,
similarity,
})
.collect())
}
}
/// Find releases with similar titles using pg_trgm.
pub async fn find_similar_releases(
pool: &PgPool,
title: &str,
limit: i32,
) -> anyhow::Result<Vec<SimilarRelease>> {
let rows: Vec<(i64, String, Option<i32>, f32)> = sqlx::query_as(
"SELECT id, title, year, similarity(title_sort, $1) AS similarity \
FROM furumusic__release WHERE title_sort % $1 \
ORDER BY similarity DESC LIMIT $2",
)
.bind(title.to_lowercase())
.bind(limit)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, title, year, similarity)| SimilarRelease {
id,
title,
year,
similarity,
})
.collect())
}
/// Check if a file with the given SHA-256 hash is actively used in the library.
/// Returns true only if a media_file with this hash exists AND at least one
/// track references it via audio_file_id. Orphaned media_files (no track)
/// are ignored so that re-discovery is possible after the user deletes
/// artists/releases/tracks.
pub async fn file_hash_exists(pool: &PgPool, sha256: &str) -> anyhow::Result<bool> {
let row: (bool,) = sqlx::query_as(
"SELECT EXISTS(\
SELECT 1 FROM furumusic__media_file mf \
JOIN furumusic__track t ON t.audio_file_id = mf.id \
WHERE mf.sha256_hash = $1\
)",
)
.bind(sha256)
.fetch_one(pool)
.await?;
Ok(row.0)
}
+63
View File
@@ -128,6 +128,15 @@ pub struct ConfigSources {
pub oidc_button_text: ConfigSource,
pub oidc_admin_groups: ConfigSource,
pub swagger_enabled: ConfigSource,
pub agent_enabled: ConfigSource,
pub agent_inbox_dir: ConfigSource,
pub agent_storage_dir: ConfigSource,
pub agent_llm_url: ConfigSource,
pub agent_llm_model: ConfigSource,
pub agent_llm_auth: ConfigSource,
pub agent_confidence_threshold: ConfigSource,
pub agent_context_limit: ConfigSource,
pub agent_concurrency: ConfigSource,
}
impl Default for ConfigSources {
@@ -143,6 +152,15 @@ impl Default for ConfigSources {
oidc_button_text: ConfigSource::Default,
oidc_admin_groups: ConfigSource::Default,
swagger_enabled: ConfigSource::Default,
agent_enabled: ConfigSource::Default,
agent_inbox_dir: ConfigSource::Default,
agent_storage_dir: ConfigSource::Default,
agent_llm_url: ConfigSource::Default,
agent_llm_model: ConfigSource::Default,
agent_llm_auth: ConfigSource::Default,
agent_confidence_threshold: ConfigSource::Default,
agent_context_limit: ConfigSource::Default,
agent_concurrency: ConfigSource::Default,
}
}
}
@@ -227,6 +245,24 @@ pub struct AppConfig {
pub oidc_admin_groups: String,
/// Whether the Swagger UI is served at /swagger/.
pub swagger_enabled: bool,
/// Whether the AI agent background loop is enabled.
pub agent_enabled: bool,
/// Directory to scan for incoming audio files.
pub agent_inbox_dir: String,
/// Directory for organized permanent storage.
pub agent_storage_dir: String,
/// LLM API URL (OpenAI-compatible).
pub agent_llm_url: String,
/// LLM model name.
pub agent_llm_model: String,
/// LLM Authorization header value (e.g. "Bearer sk-...").
pub agent_llm_auth: String,
/// Confidence threshold for auto-approval (0.01.0).
pub agent_confidence_threshold: f64,
/// LLM context window size in tokens. Chat history resets when approaching this limit.
pub agent_context_limit: u64,
/// Number of files to process in parallel via the LLM.
pub agent_concurrency: u64,
}
impl Default for AppConfig {
@@ -242,6 +278,15 @@ impl Default for AppConfig {
oidc_button_text: "Sign in with SSO".into(),
oidc_admin_groups: String::new(),
swagger_enabled: false,
agent_enabled: false,
agent_inbox_dir: String::new(),
agent_storage_dir: String::new(),
agent_llm_url: "http://localhost:8080".into(),
agent_llm_model: "default".into(),
agent_llm_auth: String::new(),
agent_confidence_threshold: 0.85,
agent_context_limit: 8192,
agent_concurrency: 2,
}
}
}
@@ -258,6 +303,15 @@ impl_env_overrides!(
oidc_button_text,
oidc_admin_groups,
swagger_enabled,
agent_enabled,
agent_inbox_dir,
agent_storage_dir,
agent_llm_url,
agent_llm_model,
agent_llm_auth,
agent_confidence_threshold,
agent_context_limit,
agent_concurrency,
);
impl AppConfig {
@@ -324,6 +378,15 @@ impl AppConfig {
apply_db_field!(oidc_button_text);
apply_db_field!(oidc_admin_groups);
apply_db_field!(swagger_enabled);
apply_db_field!(agent_enabled);
apply_db_field!(agent_inbox_dir);
apply_db_field!(agent_storage_dir);
apply_db_field!(agent_llm_url);
apply_db_field!(agent_llm_model);
apply_db_field!(agent_llm_auth);
apply_db_field!(agent_confidence_threshold);
apply_db_field!(agent_context_limit);
apply_db_field!(agent_concurrency);
}
}
+2
View File
@@ -40,6 +40,7 @@ impl Lang {
macro_rules! translations {
( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => {
#[derive(Debug)]
#[allow(dead_code)]
pub struct Translations {
pub lang: $crate::i18n::Lang,
$( pub $key: &'static str, )*
@@ -149,6 +150,7 @@ fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang {
// I18n extractor
// ---------------------------------------------------------------------------
#[allow(dead_code)]
pub struct I18n {
pub lang: Lang,
pub t: &'static Translations,
+149
View File
@@ -97,4 +97,153 @@ translations! {
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
// Artist management
nav_artists: "Artists" , "Артисты";
artists_heading: "Artists" , "Артисты";
artists_add: "Add artist" , "Добавить артиста";
artists_name: "Name" , "Имя";
artists_hidden: "Hidden" , "Скрыт";
artists_actions: "Actions" , "Действия";
artists_edit: "Edit" , "Редактировать";
artists_delete: "Delete" , "Удалить";
artists_delete_confirm: "Are you sure?" , "Вы уверены?";
artists_new_heading: "New artist" , "Новый артист";
artists_edit_heading: "Edit artist" , "Редактирование артиста";
artists_empty: "No artists yet." , "Артистов пока нет.";
artists_releases: "Releases" , "Релизы";
artists_tracks: "Tracks" , "Треки";
artists_view_releases: "View releases" , "Показать релизы";
artists_image: "Artist Image" , "Изображение артиста";
artists_no_image: "No image set." , "Изображение не задано.";
artists_upload_image: "Upload custom image" , "Загрузить изображение";
artists_upload: "Upload" , "Загрузить";
artists_pick_cover: "Or pick from album covers" , "Или выберите обложку альбома";
artists_no_covers: "No album covers available." , "Обложки альбомов недоступны.";
artists_remove_image: "Remove image" , "Удалить изображение";
// Release management
nav_releases: "Releases" , "Релизы";
releases_heading: "Releases" , "Релизы";
releases_add: "Add release" , "Добавить релиз";
releases_title: "Title" , "Название";
releases_type: "Type" , "Тип";
releases_year: "Year" , "Год";
releases_artist: "Artist" , "Артист";
releases_artists: "Artists" , "Артисты";
releases_hidden: "Hidden" , "Скрыт";
releases_actions: "Actions" , "Действия";
releases_edit: "Edit" , "Редактировать";
releases_delete: "Delete" , "Удалить";
releases_delete_confirm: "Are you sure?" , "Вы уверены?";
releases_new_heading: "New release" , "Новый релиз";
releases_edit_heading: "Edit release" , "Редактирование релиза";
releases_empty: "No releases yet." , "Релизов пока нет.";
releases_no_artist: "— no artist —" , "— без артиста —";
releases_select_artist: "Select artist..." , "Выберите артиста...";
releases_filter_all: "All artists" , "Все артисты";
releases_filter_label: "Filter by artist" , "Фильтр по артисту";
// Media files
nav_media_files: "Media Files" , "Медиафайлы";
media_files_heading: "Media Files" , "Медиафайлы";
media_files_empty: "No media files found." , "Медиафайлы не найдены.";
media_files_filename: "Filename" , "Файл";
media_files_type: "Type" , "Тип";
media_files_format: "Format" , "Формат";
media_files_size: "Size" , "Размер";
media_files_path: "Path" , "Путь";
media_files_hash: "SHA-256" , "SHA-256";
media_files_created: "Created" , "Создан";
media_files_track: "Track" , "Трек";
media_files_orphan: "Orphan" , "Без трека";
media_files_actions: "Actions" , "Действия";
media_files_delete: "Delete" , "Удалить";
media_files_delete_confirm: "Delete this media file?" , "Удалить этот медиафайл?";
// Job management
nav_jobs: "Jobs" , "Задания";
nav_reviews: "Reviews" , "Проверки";
jobs_heading: "Scheduled Jobs" , "Запланированные задания";
jobs_name: "Name" , "Имя";
jobs_description: "Description" , "Описание";
jobs_cron: "Cron" , "Cron";
jobs_enabled: "Enabled" , "Включено";
jobs_last_run: "Last run" , "Последний запуск";
jobs_next_run: "Next run" , "Следующий запуск";
jobs_actions: "Actions" , "Действия";
jobs_run_now: "Run now" , "Запустить";
jobs_enable: "Enable" , "Включить";
jobs_disable: "Disable" , "Выключить";
jobs_run_history: "Run history" , "История запусков";
jobs_run_status: "Status" , "Статус";
jobs_run_started: "Started" , "Начало";
jobs_run_duration: "Duration" , "Длительность";
jobs_run_trigger: "Trigger" , "Триггер";
jobs_run_log: "Log" , "Лог";
jobs_run_error: "Error" , "Ошибка";
jobs_cron_help: "7-field cron: sec min hour day month weekday year" , "7-полевой cron: сек мин час день месяц день_недели год";
jobs_cron_update: "Update cron" , "Обновить cron";
jobs_back_to_list: "Back to jobs" , "Назад к заданиям";
jobs_run_detail: "Run detail" , "Детали запуска";
jobs_back_to_job: "Back to job" , "Назад к заданию";
// Review management
reviews_heading: "Pending Reviews" , "Ожидающие проверки";
reviews_empty: "No reviews." , "Проверок нет.";
reviews_status: "Status" , "Статус";
reviews_type: "Type" , "Тип";
reviews_input_path: "Input" , "Файл";
reviews_confidence: "Confidence" , "Уверенность";
reviews_approve: "Approve" , "Подтвердить";
reviews_reject: "Reject" , "Отклонить";
reviews_context: "Context" , "Контекст";
reviews_result: "Result" , "Результат";
reviews_created: "Created" , "Создано";
reviews_view: "View" , "Открыть";
reviews_clear_all: "Clear all" , "Очистить все";
reviews_clear_filtered: "Clear shown" , "Очистить показанные";
reviews_clear_confirm: "Are you sure? This will delete the selected reviews." , "Вы уверены? Выбранные проверки будут удалены.";
reviews_back_to_list: "Back to reviews" , "Назад к проверкам";
reviews_filter_all: "All" , "Все";
reviews_filter_pending: "Pending" , "Ожидают";
reviews_filter_approved: "Approved" , "Подтверждённые";
reviews_filter_rejected: "Rejected" , "Отклонённые";
reviews_filter_queued: "Queued" , "В очереди";
reviews_filter_processing: "Processing" , "В обработке";
reviews_filter_auto_approved: "Auto-approved" , "Авто-подтверждённые";
reviews_filter_failed: "Failed" , "Ошибочные";
reviews_error: "Error" , "Ошибка";
reviews_requeue: "Re-queue" , "В очередь";
reviews_requeue_confirm: "Re-queue this item for processing?" , "Поставить в очередь на повторную обработку?";
// Processing stats
settings_agent_concurrency: "Concurrency" , "Параллелизм";
reviews_model: "Model" , "Модель";
reviews_llm_duration: "LLM time" , "Время LLM";
reviews_tokens: "Tokens (in/out)" , "Токены (вх/вых)";
// Agent settings
settings_agent: "Agent" , "Агент";
settings_agent_help: "AI music processing agent configuration. Enable and configure the background agent that automatically processes audio files." , "Настройки AI-агента обработки музыки. Включите и настройте фоновый агент, который автоматически обрабатывает аудиофайлы.";
settings_agent_enabled: "Agent enabled" , "Агент включён";
settings_agent_inbox: "Inbox directory" , "Папка входящих";
settings_agent_storage: "Storage directory" , "Папка хранилища";
settings_agent_llm_url: "LLM API URL" , "URL API LLM";
settings_agent_llm_model: "LLM model" , "Модель LLM";
settings_agent_threshold: "Confidence threshold" , "Порог уверенности";
settings_agent_context: "Context limit (tokens)" , "Лимит контекста (токены)";
settings_agent_llm_auth: "LLM auth header" , "Заголовок авторизации LLM";
settings_agent_status: "Agent Status" , "Статус агента";
settings_agent_status_disabled: "Agent is disabled." , "Агент отключён.";
settings_agent_status_no_url: "LLM URL is not configured." , "URL LLM не настроен.";
settings_agent_status_ok: "LLM connection OK" , "Подключение к LLM OK";
settings_agent_status_error: "LLM connection error" , "Ошибка подключения к LLM";
settings_agent_model_name: "Model" , "Модель";
settings_agent_latency: "Latency" , "Задержка";
settings_agent_prompt_tokens: "Prompt tokens" , "Токенов на промпт";
settings_agent_completion_tokens: "Completion tokens" , "Токенов на ответ";
settings_agent_tokens_per_sec: "Tokens/sec" , "Токенов/сек";
settings_agent_status_loading: "Checking connection" , "Проверка подключения";
}
+58
View File
@@ -0,0 +1,58 @@
use crate::scheduler::{Job, JobContext, JobLog};
/// Periodic job that auto-assigns artist images from their release covers.
///
/// For every artist that has no `image_file_id`, picks the cover of the most
/// recent release (by year) that has one. Runs after the cover backfill job
/// so freshly-extracted covers are available.
pub struct ArtistImageBackfillJob;
#[async_trait::async_trait]
impl Job for ArtistImageBackfillJob {
fn name(&self) -> &'static str {
"artist_image_backfill"
}
fn description(&self) -> &'static str {
"Auto-assign artist images from release covers"
}
fn default_cron(&self) -> &'static str {
// 03:15 daily — after cover_backfill at 03:00
"0 15 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let result = sqlx::query(
"UPDATE furumusic__artist a \
SET image_file_id = ( \
SELECT r.cover_file_id \
FROM furumusic__release_artist ra \
JOIN furumusic__release r ON r.id = ra.release_id \
WHERE ra.artist_id = a.id \
AND r.cover_file_id IS NOT NULL \
ORDER BY r.year DESC NULLS LAST \
LIMIT 1 \
), \
updated_at = $1 \
WHERE a.image_file_id IS NULL \
AND EXISTS ( \
SELECT 1 FROM furumusic__release_artist ra2 \
JOIN furumusic__release r2 ON r2.id = ra2.release_id \
WHERE ra2.artist_id = a.id AND r2.cover_file_id IS NOT NULL \
)",
)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(&ctx.pool)
.await?;
let count = result.rows_affected();
if count > 0 {
log.info(&format!("Assigned images to {count} artists from release covers"));
} else {
log.info("All artists already have images (or no covers available)");
}
Ok(())
}
}
+69
View File
@@ -0,0 +1,69 @@
use crate::scheduler::{Job, JobContext, JobLog};
/// Fallback job that assigns artist images from track cover art.
///
/// The primary `artist_image_backfill` job uses release covers. This job
/// runs afterwards and covers the case where the release itself has no
/// cover but individual tracks do (e.g. when cover art is embedded in the
/// audio file and extracted per-track rather than per-release).
///
/// For every artist that *still* has no `image_file_id` after the release-
/// based backfill, picks the `cover_file_id` of the most recent track
/// (by year, then track id) that has one.
pub struct ArtistTrackImageBackfillJob;
#[async_trait::async_trait]
impl Job for ArtistTrackImageBackfillJob {
fn name(&self) -> &'static str {
"artist_track_image_backfill"
}
fn description(&self) -> &'static str {
"Auto-assign artist images from track covers (fallback)"
}
fn default_cron(&self) -> &'static str {
// 03:30 daily — after artist_image_backfill at 03:15
"0 30 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let result = sqlx::query(
"UPDATE furumusic__artist a \
SET image_file_id = ( \
SELECT t.cover_file_id \
FROM furumusic__track_artist ta \
JOIN furumusic__track t ON t.id = ta.track_id \
WHERE ta.artist_id = a.id \
AND t.cover_file_id IS NOT NULL \
AND t.is_hidden = false \
ORDER BY t.year DESC NULLS LAST, t.id DESC \
LIMIT 1 \
), \
updated_at = $1 \
WHERE a.image_file_id IS NULL \
AND a.is_hidden = false \
AND EXISTS ( \
SELECT 1 FROM furumusic__track_artist ta2 \
JOIN furumusic__track t2 ON t2.id = ta2.track_id \
WHERE ta2.artist_id = a.id \
AND t2.cover_file_id IS NOT NULL \
AND t2.is_hidden = false \
)",
)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(&ctx.pool)
.await?;
let count = result.rows_affected();
if count > 0 {
log.info(&format!(
"Assigned images to {count} artists from track covers"
));
} else {
log.info("All artists already have images (or no track covers available)");
}
Ok(())
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::path::{Path, PathBuf};
use crate::agent::cover_art;
use crate::scheduler::{Job, JobContext, JobLog};
/// One-shot / periodic job that finds releases without cover art and attempts
/// to extract or discover covers from their audio files in storage.
pub struct CoverBackfillJob;
#[async_trait::async_trait]
impl Job for CoverBackfillJob {
fn name(&self) -> &'static str {
"cover_backfill"
}
fn description(&self) -> &'static str {
"Backfill cover art for releases missing covers"
}
fn default_cron(&self) -> &'static str {
// Once a day at 03:00
"0 0 3 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let storage_dir = &ctx.config.agent_storage_dir;
if storage_dir.is_empty() {
log.warn("agent_storage_dir is not configured, skipping cover backfill");
return Ok(());
}
// Find all releases without a cover
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT r.id, r.title \
FROM furumusic__release r \
WHERE r.cover_file_id IS NULL \
ORDER BY r.id",
)
.fetch_all(&ctx.pool)
.await?;
if rows.is_empty() {
log.info("All releases already have cover art, nothing to backfill");
return Ok(());
}
log.info(&format!(
"Found {} releases without cover art, starting backfill...",
rows.len()
));
let mut assigned = 0u32;
let mut failed = 0u32;
let mut skipped_no_audio = 0u32;
let mut skipped_no_cover = 0u32;
let total = rows.len();
for (i, (release_id, release_title)) in rows.iter().enumerate() {
log.info(&format!(
"[{}/{}] Processing release {release_id} \"{release_title}\"...",
i + 1,
total,
));
// Find audio files belonging to this release via tracks → media_file
let audio_paths: Vec<(String,)> = sqlx::query_as(
"SELECT mf.file_path \
FROM furumusic__track t \
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id \
WHERE t.release_id = $1 AND mf.file_type = 'audio'",
)
.bind(release_id)
.fetch_all(&ctx.pool)
.await
.unwrap_or_default();
if audio_paths.is_empty() {
log.warn(&format!(
"Release {release_id} \"{release_title}\": no audio files found, skipping"
));
skipped_no_audio += 1;
continue;
}
// Determine the folder from the first audio file's path
let first_path = Path::new(&audio_paths[0].0);
let folder = first_path.parent().unwrap_or(Path::new("."));
// Collect all audio file paths as PathBuf
let audio_files: Vec<PathBuf> = audio_paths
.iter()
.map(|(p,)| PathBuf::from(p))
.collect();
// Try to find cover art
let cover = match cover_art::find_best_cover(folder, &audio_files).await {
Some(c) => c,
None => {
log.info(&format!(
"Release {release_id} \"{release_title}\": no cover image found in {} audio files, skipping",
audio_files.len(),
));
skipped_no_cover += 1;
continue;
}
};
let source_desc = match &cover.source {
cover_art::CoverSource::FolderFile(p) => format!("folder: {}", p.display()),
cover_art::CoverSource::Embedded(p) => format!("embedded: {}", p.display()),
};
// Look up artist name for storage path
let artist_name: String = sqlx::query_scalar(
"SELECT a.name FROM furumusic__artist a \
JOIN furumusic__release_artist ra ON ra.artist_id = a.id \
WHERE ra.release_id = $1 \
ORDER BY ra.position LIMIT 1",
)
.bind(release_id)
.fetch_optional(&ctx.pool)
.await
.ok()
.flatten()
.unwrap_or_else(|| "Unknown Artist".to_string());
match cover_art::save_cover_to_storage(
&ctx.db,
&ctx.pool,
storage_dir,
&artist_name,
release_title,
&cover,
)
.await
{
Ok(cover_file_id) => {
if let Err(e) = cover_art::assign_cover_to_release(
&ctx.pool,
*release_id,
cover_file_id,
)
.await
{
log.warn(&format!(
"Release {release_id} \"{release_title}\": saved cover but failed to assign: {e}"
));
failed += 1;
} else {
log.info(&format!(
"Release {release_id} \"{release_title}\": assigned cover from {source_desc}"
));
assigned += 1;
}
}
Err(e) => {
log.warn(&format!(
"Release {release_id} \"{release_title}\": failed to save cover: {e}"
));
failed += 1;
}
}
}
log.info(&format!(
"Cover backfill complete: {assigned} assigned, {failed} failed, \
{skipped_no_audio} skipped (no audio), {skipped_no_cover} skipped (no cover found)"
));
Ok(())
}
}
+240
View File
@@ -0,0 +1,240 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
use crate::scheduler::{Job, JobContext, JobLog, PendingReview};
/// Guard to prevent overlapping inbox_discover runs.
static DISCOVER_RUNNING: AtomicBool = AtomicBool::new(false);
const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
];
pub struct InboxDiscoverJob;
#[async_trait::async_trait]
impl Job for InboxDiscoverJob {
fn name(&self) -> &'static str {
"inbox_discover"
}
fn description(&self) -> &'static str {
"Scan inbox for new audio files and queue them for processing"
}
fn default_cron(&self) -> &'static str {
"0 */5 * * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
// Prevent overlapping discover runs
if DISCOVER_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
log.info("Another inbox_discover is already running, skipping");
return Ok(());
}
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
DISCOVER_RUNNING.store(false, Ordering::SeqCst);
}
}
let _guard = Guard;
let config = &ctx.config;
if config.agent_inbox_dir.is_empty() {
log.info("No inbox directory configured, skipping");
return Ok(());
}
let inbox = Path::new(&config.agent_inbox_dir);
if !inbox.exists() {
log.warn(&format!("Inbox path does not exist: {}", inbox.display()));
return Ok(());
}
let mut audio_files = Vec::new();
collect_audio_files(inbox, &mut audio_files).await?;
log.info(&format!("Found {} audio files in inbox", audio_files.len()));
if audio_files.is_empty() {
return Ok(());
}
let groups = group_by_folder(&audio_files);
log.info(&format!("Grouped into {} folder batches", groups.len()));
let mut discovered = 0u64;
let mut skipped_hash = 0u64;
let mut skipped_existing = 0u64;
for (_folder, files) in &groups {
for file_path in files {
let input_path_str = file_path.to_string_lossy().to_string();
// Skip if a PendingReview already exists for this path
match PendingReview::exists_for_path(&ctx.db, &input_path_str).await {
Ok(true) => {
skipped_existing += 1;
continue;
}
Ok(false) => {}
Err(e) => {
log.warn(&format!("Error checking existing review for {}: {e}", input_path_str));
continue;
}
}
// Compute SHA-256 hash
let path_clone = file_path.to_path_buf();
let (hash, file_size) = match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
let data = std::fs::read(&path_clone)?;
let digest = Sha256::digest(&data);
let hash = format!("{:x}", digest);
let size = data.len() as i64;
Ok((hash, size))
})
.await?
{
Ok(v) => v,
Err(e) => {
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
continue;
}
};
// Skip if hash already in media_files
if crate::agent::rag::file_hash_exists(&ctx.pool, &hash).await.unwrap_or(false) {
skipped_hash += 1;
continue;
}
// Extract raw metadata
let path_for_meta = file_path.to_path_buf();
let raw_meta = match tokio::task::spawn_blocking(move || {
crate::agent::metadata::extract(&path_for_meta)
})
.await?
{
Ok(m) => m,
Err(e) => {
log.warn(&format!("Failed to extract metadata from {}: {e}", file_path.display()));
continue;
}
};
// Parse path hints
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
let hints = crate::agent::path_hints::parse(relative);
// Build context JSON
let context = serde_json::json!({
"sha256": hash,
"file_size": file_size,
"raw_title": raw_meta.title,
"raw_artist": raw_meta.artist,
"raw_album": raw_meta.album,
"raw_track_number": raw_meta.track_number,
"raw_year": raw_meta.year,
"raw_genre": raw_meta.genre,
"duration_secs": raw_meta.duration_secs,
"path_title": hints.title,
"path_artist": hints.artist,
"path_album": hints.album,
"path_year": hints.year,
"path_track_number": hints.track_number,
});
let context_str = serde_json::to_string(&context).unwrap_or_default();
// Create PendingReview with status "queued"
PendingReview::create_queued(
&ctx.db,
ctx.run_id,
"new_file",
Some(&input_path_str),
Some(&context_str),
)
.await
.map_err(|e| anyhow::anyhow!("failed to create queued review: {e}"))?;
discovered += 1;
}
}
log.info(&format!(
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
discovered, skipped_hash, skipped_existing
));
// Trigger inbox_process in background if new files were discovered
// and no orchestrator is already running
if discovered > 0 {
if crate::jobs::inbox_process::is_orchestrator_running() {
log.info("New files discovered but inbox_process already running, it will pick them up");
} else {
log.info("Spawning inbox_process in background...");
let config = ctx.config.clone();
let db = ctx.db.clone();
let pool = ctx.pool.clone();
let registry = ctx.registry.clone();
tokio::spawn(async move {
if let Err(e) = crate::scheduler::trigger_job_now(
&config, &db, &pool, &registry, "inbox_process",
)
.await
{
tracing::error!("Background inbox_process trigger failed: {e}");
}
});
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Helpers (moved from inbox_scan.rs)
// ---------------------------------------------------------------------------
pub fn group_by_folder(files: &[PathBuf]) -> Vec<(PathBuf, Vec<PathBuf>)> {
use std::collections::HashMap;
let mut map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for f in files {
let folder = f.parent().unwrap_or(f).to_path_buf();
map.entry(folder).or_default().push(f.clone());
}
let mut groups: Vec<(PathBuf, Vec<PathBuf>)> = map.into_iter().collect();
groups.sort_by(|a, b| a.0.cmp(&b.0));
for (_, files) in &mut groups {
files.sort();
}
groups
}
pub async fn collect_audio_files(
dir: &Path,
audio: &mut Vec<PathBuf>,
) -> anyhow::Result<()> {
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let ft = entry.file_type().await?;
if ft.is_dir() {
Box::pin(collect_audio_files(&entry.path(), audio)).await?;
} else if ft.is_file() && is_audio_file(&name) {
audio.push(entry.path());
}
}
Ok(())
}
pub fn is_audio_file(name: &str) -> bool {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str())
}
+957
View File
@@ -0,0 +1,957 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use cot::db::{Database, Model};
/// Guard to prevent multiple inbox_process orchestrators from running simultaneously.
static ORCHESTRATOR_RUNNING: AtomicBool = AtomicBool::new(false);
/// Well-known advisory lock ID for the inbox_process orchestrator.
/// PostgreSQL advisory locks use a 64-bit key; this is an arbitrary unique value.
const ORCHESTRATOR_ADVISORY_LOCK_ID: i64 = 0x4655_5255_4D55_5349; // "FURUMUSI" in hex
/// Check if an orchestrator is currently running (used by inbox_discover to avoid redundant triggers).
pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
/// Returns true if the lock was acquired (no other orchestrator is running).
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
match sqlx::query_scalar::<_, bool>(
"SELECT pg_try_advisory_lock($1)"
)
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(pool)
.await
{
Ok(acquired) => acquired,
Err(e) => {
tracing::error!("Failed to acquire advisory lock: {e}");
false
}
}
}
/// Release the PostgreSQL advisory lock for the orchestrator.
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.execute(pool)
.await;
}
use crate::config::AppConfig;
use crate::music::{
Artist, MediaFile, Release, ReleaseArtist, Track, TrackArtist,
};
use crate::scheduler::{Job, JobContext, JobLog, JobRun, PendingReview, ProcessingStats};
use crate::agent::dto::{FolderContext, NormalizedFields, RawMetadata, PathHints};
use crate::agent::normalize::BatchFileInput;
use crate::agent::mover;
const AUDIO_EXTENSIONS: &[&str] = &[
"mp3", "flac", "ogg", "opus", "aac", "m4a", "wav", "ape", "wv", "wma", "tta", "aiff", "aif",
];
// ---------------------------------------------------------------------------
// InboxProcessJob — orchestrator that runs until ALL queued files are done
// ---------------------------------------------------------------------------
pub struct InboxProcessJob;
#[async_trait::async_trait]
impl Job for InboxProcessJob {
fn name(&self) -> &'static str {
"inbox_process"
}
fn description(&self) -> &'static str {
"Orchestrator: process queued files in folder batches"
}
fn default_cron(&self) -> &'static str {
"30 */5 * * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
// --- Guard 1: AtomicBool (fast in-process check) ---
let prev = ORCHESTRATOR_RUNNING.load(Ordering::SeqCst);
tracing::info!(
previous_value = prev,
"inbox_process: checking ORCHESTRATOR_RUNNING AtomicBool"
);
if ORCHESTRATOR_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() {
log.info("Another inbox_process orchestrator is already running (AtomicBool), skipping");
return Ok(());
}
struct AtomicGuard;
impl Drop for AtomicGuard {
fn drop(&mut self) {
tracing::info!("inbox_process: releasing ORCHESTRATOR_RUNNING AtomicBool");
ORCHESTRATOR_RUNNING.store(false, Ordering::SeqCst);
}
}
let _atomic_guard = AtomicGuard;
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
if !try_acquire_orchestrator_lock(&ctx.pool).await {
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
return Ok(());
}
tracing::info!("inbox_process: advisory lock acquired");
let pool_for_unlock = ctx.pool.clone();
struct AdvisoryGuard {
pool: sqlx::PgPool,
}
impl Drop for AdvisoryGuard {
fn drop(&mut self) {
let pool = self.pool.clone();
tokio::spawn(async move {
release_orchestrator_lock(&pool).await;
tracing::info!("inbox_process: advisory lock released");
});
}
}
let _advisory_guard = AdvisoryGuard { pool: pool_for_unlock };
let config = Arc::clone(&ctx.config);
let mut total_ok = 0u64;
let mut total_fail = 0u64;
// Outer loop: re-check for newly queued files after each round
loop {
let queued = PendingReview::list_queued(&ctx.db)
.await
.map_err(|e| anyhow::anyhow!("failed to list queued reviews: {e}"))?;
if queued.is_empty() {
if total_ok == 0 && total_fail == 0 {
log.info("No queued files to process");
} else {
log.info("No more queued files, finishing");
}
break;
}
// Group queued reviews by parent folder
let groups = group_reviews_by_folder(&queued, &config.agent_inbox_dir);
log.info(&format!(
"{} queued file(s) in {} folder batch(es)",
queued.len(),
groups.len(),
));
for (folder_rel, reviews) in groups {
let file_count = reviews.len();
log.info(&format!(
"Folder batch: \"{}\" ({} files)",
folder_rel, file_count,
));
let (ok, fail) = process_folder_batch(
&ctx.db, &config, &ctx.pool, &folder_rel, reviews, log,
).await;
total_ok += ok;
total_fail += fail;
log.info(&format!(
"Folder done: {ok} ok, {fail} err. Total so far: {total_ok} ok, {total_fail} err"
));
}
}
// Cleanup empty dirs
let inbox_path = Path::new(&config.agent_inbox_dir);
if total_ok > 0 && !config.agent_inbox_dir.is_empty() {
cleanup_empty_dirs(inbox_path).await;
}
log.info(&format!(
"Orchestrator finished: {total_ok} succeeded, {total_fail} failed"
));
Ok(())
}
}
// ---------------------------------------------------------------------------
// FileProcessJob — registered for admin UI visibility (no cron, never auto-triggered)
// ---------------------------------------------------------------------------
pub struct FileProcessJob;
#[async_trait::async_trait]
impl Job for FileProcessJob {
fn name(&self) -> &'static str {
"file_process"
}
fn description(&self) -> &'static str {
"Process audio files through LLM (spawned by orchestrator)"
}
fn default_cron(&self) -> &'static str {
"" // no cron — only spawned by the orchestrator
}
async fn run(&self, _ctx: &JobContext, _log: &mut JobLog) -> anyhow::Result<()> {
Ok(())
}
}
// ---------------------------------------------------------------------------
// Prepared file — metadata extracted, ready for LLM
// ---------------------------------------------------------------------------
struct PreparedFile {
review: PendingReview,
filename: String,
raw_meta: RawMetadata,
hints: PathHints,
context: serde_json::Value,
}
// ---------------------------------------------------------------------------
// Group reviews by parent folder
// ---------------------------------------------------------------------------
fn group_reviews_by_folder(
reviews: &[PendingReview],
inbox_dir: &str,
) -> Vec<(String, Vec<PendingReview>)> {
let inbox = Path::new(inbox_dir);
let mut map: HashMap<String, Vec<PendingReview>> = HashMap::new();
for r in reviews {
let path = Path::new(r.input_path_str());
let folder = path.parent().unwrap_or(path);
let rel = folder.strip_prefix(inbox).unwrap_or(folder);
let key = rel.to_string_lossy().to_string();
map.entry(key).or_default().push(r.clone());
}
let mut groups: Vec<(String, Vec<PendingReview>)> = map.into_iter().collect();
groups.sort_by(|a, b| a.0.cmp(&b.0));
// Sort files within each group by path
for (_, reviews) in &mut groups {
reviews.sort_by(|a, b| a.input_path_str().cmp(b.input_path_str()));
}
groups
}
// ---------------------------------------------------------------------------
// Process one folder batch
// ---------------------------------------------------------------------------
async fn process_folder_batch(
db: &Database,
config: &AppConfig,
pool: &sqlx::PgPool,
folder_rel: &str,
reviews: Vec<PendingReview>,
orch_log: &mut JobLog,
) -> (u64, u64) {
let inbox_path = Path::new(&config.agent_inbox_dir);
let file_count = reviews.len();
// Create a single JobRun for the folder batch
let trigger_label = if folder_rel.is_empty() {
format!("batch({})", file_count)
} else {
let short = truncate_path(folder_rel, 20);
format!("{short}({})", file_count)
};
let mut run = match JobRun::create_running(db, "file_process", &trigger_label).await {
Ok(r) => r,
Err(e) => {
orch_log.error(&format!("Failed to create batch JobRun: {e}"));
return (0, file_count as u64);
}
};
let batch_start = std::time::Instant::now();
let mut log = JobLog::with_live_flush(pool.clone(), run.id_val());
log.info(&format!(
"Folder batch: \"{folder_rel}\"{file_count} file(s)"
));
// Phase 1: Prepare all files (extract metadata, parse hints)
log.info("Phase 1: extracting metadata...");
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(file_count);
let mut failed_reviews: Vec<PendingReview> = Vec::new();
for mut review in reviews {
let input_path_str = review.input_path_str().to_owned();
let file_path = Path::new(&input_path_str);
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_owned();
// Set status → processing
let _ = review.set_processing(db).await;
// Parse context_json
let context: serde_json::Value = review
.context_json
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
// Extract metadata (with 60s timeout)
let path_for_meta = file_path.to_path_buf();
let meta_future = tokio::task::spawn_blocking(move || {
crate::agent::metadata::extract(&path_for_meta)
});
let raw_meta = match tokio::time::timeout(
std::time::Duration::from_secs(60),
meta_future,
).await {
Ok(Ok(Ok(m))) => m,
Ok(Ok(Err(e))) => {
let msg = format!("{filename}: metadata error: {e}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
Ok(Err(e)) => {
let msg = format!("{filename}: metadata panic: {e}");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
Err(_) => {
let msg = format!("{filename}: metadata timeout (60s)");
log.error(&msg);
let _ = review.set_failed(db, &msg).await;
failed_reviews.push(review);
continue;
}
};
// Parse path hints
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
let hints = crate::agent::path_hints::parse(relative);
prepared.push(PreparedFile {
review,
filename,
raw_meta,
hints,
context,
});
}
log.info(&format!(
"Phase 1 done: {} prepared, {} failed metadata",
prepared.len(),
failed_reviews.len(),
));
if prepared.is_empty() {
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_completed(db, duration_ms, &log.output()).await;
return (0, failed_reviews.len() as u64);
}
// Phase 2: RAG lookup (collect unique artist/album queries from all files)
log.info("Phase 2: RAG lookup...");
let mut artist_queries: Vec<String> = Vec::new();
let mut album_queries: Vec<String> = Vec::new();
for p in &prepared {
let artist_q = p.raw_meta.artist.as_deref()
.or(p.hints.artist.as_deref())
.unwrap_or("")
.to_owned();
if !artist_q.is_empty() && !artist_queries.contains(&artist_q) {
artist_queries.push(artist_q);
}
let album_q = p.raw_meta.album.as_deref()
.or(p.hints.album.as_deref())
.unwrap_or("")
.to_owned();
if !album_q.is_empty() && !album_queries.contains(&album_q) {
album_queries.push(album_q);
}
}
// Lookup all unique artist queries
let mut all_similar_artists = Vec::new();
for q in &artist_queries {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_artists(pool, q, 5),
).await {
Ok(Ok(results)) => {
for a in results {
if !all_similar_artists.iter().any(|x: &crate::agent::dto::SimilarArtist| x.id == a.id) {
all_similar_artists.push(a);
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG artist lookup timed out for \"{q}\"")),
}
}
let mut all_similar_releases = Vec::new();
for q in &album_queries {
match tokio::time::timeout(
std::time::Duration::from_secs(30),
crate::agent::rag::find_similar_releases(pool, q, 5),
).await {
Ok(Ok(results)) => {
for r in results {
if !all_similar_releases.iter().any(|x: &crate::agent::dto::SimilarRelease| x.id == r.id) {
all_similar_releases.push(r);
}
}
}
Ok(Err(e)) => log.warn(&format!("RAG release lookup failed for \"{q}\": {e}")),
Err(_) => log.warn(&format!("RAG release lookup timed out for \"{q}\"")),
}
}
log.info(&format!(
"Phase 2 done: {} similar artists, {} similar releases",
all_similar_artists.len(),
all_similar_releases.len(),
));
// Phase 3: Build folder context and call batch LLM
log.info("Phase 3: calling LLM (batch)...");
// Build folder context from the first file's folder
let folder_ctx = {
let first_path = Path::new(prepared[0].review.input_path_str());
let folder = first_path.parent().unwrap_or(first_path);
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if AUDIO_EXTENSIONS.contains(&ext.as_str()) {
Some(name)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
folder_files.sort();
let track_count = folder_files.len();
FolderContext {
folder_path: folder_rel.to_owned(),
folder_files,
track_count,
}
};
// Build batch input
let batch_files: Vec<BatchFileInput> = prepared.iter().map(|p| {
BatchFileInput {
filename: p.filename.clone(),
raw: RawMetadata {
title: p.raw_meta.title.clone(),
artist: p.raw_meta.artist.clone(),
album: p.raw_meta.album.clone(),
track_number: p.raw_meta.track_number,
year: p.raw_meta.year,
genre: p.raw_meta.genre.clone(),
duration_secs: p.raw_meta.duration_secs,
},
hints: PathHints {
title: p.hints.title.clone(),
artist: p.hints.artist.clone(),
album: p.hints.album.clone(),
year: p.hints.year,
track_number: p.hints.track_number,
},
}
}).collect();
let system_prompt = include_str!("../../prompts/normalize_batch.txt");
let context_limit = config.agent_context_limit;
let llm_result = crate::agent::normalize::normalize_batch(
&config.agent_llm_url,
&config.agent_llm_model,
&config.agent_llm_auth,
system_prompt,
context_limit,
batch_files,
&all_similar_artists,
&all_similar_releases,
Some(&folder_ctx),
).await;
let batch_result = match llm_result {
Ok(r) => r,
Err(e) => {
let err_msg = format!("Batch LLM call failed: {e}");
log.error(&err_msg);
// Mark all files as failed
for mut p in prepared {
let _ = p.review.set_failed(db, &err_msg).await;
}
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
let duration_ms = batch_start.elapsed().as_millis() as i64;
let _ = run.set_failed(db, duration_ms, &log.output(), &err_msg).await;
return (0, total_fail_count);
}
};
log.info(&format!(
"Phase 3 done: LLM returned {} results in {}ms (model={}, tokens={}/{})",
batch_result.results.len(),
batch_result.duration_ms,
batch_result.model,
batch_result.prompt_tokens,
batch_result.completion_tokens,
));
// Phase 4: Match results to files and finalize
log.info("Phase 4: finalizing...");
// Build lookup map: filename → NormalizedFields
let result_map: HashMap<String, NormalizedFields> = batch_result.results
.into_iter()
.collect();
let llm_model = &batch_result.model;
let prompt_per_file = batch_result.prompt_tokens / prepared.len().max(1) as u64;
let completion_per_file = batch_result.completion_tokens / prepared.len().max(1) as u64;
let duration_per_file = batch_result.duration_ms as i64 / prepared.len().max(1) as i64;
let mut ok_count = 0u64;
let mut fail_count = failed_reviews.len() as u64;
for mut p in prepared {
let filename = &p.filename;
let normalized = match result_map.get(filename) {
Some(n) => n,
None => {
let msg = format!("LLM returned no result for \"{filename}\"");
log.error(&msg);
let _ = p.review.set_failed(db, &msg).await;
fail_count += 1;
continue;
}
};
// Record processing stats
let _ = ProcessingStats::create(
db,
p.review.id_val(),
llm_model,
duration_per_file,
prompt_per_file as i64,
completion_per_file as i64,
).await;
let result_json = serde_json::to_string(normalized).unwrap_or_default();
let confidence = normalized.confidence.unwrap_or(0.0);
let feat = if normalized.featured_artists.is_empty() {
String::new()
} else {
format!(" feat=[{}]", normalized.featured_artists.join(", "))
};
log.info(&format!(
"{filename}: artist={} | album={} | title={} | track={} | year={} | conf={}{}",
normalized.artist.as_deref().unwrap_or("-"),
normalized.album.as_deref().unwrap_or("-"),
normalized.title.as_deref().unwrap_or("-"),
normalized.track_number.map_or("-".into(), |n| n.to_string()),
normalized.year.map_or("-".into(), |y| y.to_string()),
confidence,
feat,
));
p.review.result_json = Some(result_json);
let _ = p.review.save(db).await;
let input_path_str = p.review.input_path_str().to_owned();
if confidence >= config.agent_confidence_threshold {
match finalize_approved(
db, pool, config, &input_path_str, normalized, &p.context,
&config.agent_storage_dir, Some(llm_model),
).await {
Ok(()) => {
let _ = p.review.set_auto_approved(db).await;
ok_count += 1;
}
Err(e) => {
let msg = format!("{filename}: finalize failed: {e}");
log.error(&msg);
let _ = p.review.set_failed(db, &msg).await;
fail_count += 1;
}
}
} else {
p.review.status = cot::db::LimitedString::new("pending").unwrap();
p.review.updated_at = cot::db::LimitedString::new(
&chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
).unwrap();
let _ = p.review.save(db).await;
log.info(&format!(
"{filename}: manual review (confidence {confidence} < {})",
config.agent_confidence_threshold,
));
ok_count += 1; // Not a failure, just needs review
}
}
let duration_ms = batch_start.elapsed().as_millis() as i64;
if fail_count == 0 {
let _ = run.set_completed(db, duration_ms, &log.output()).await;
} else {
let msg = format!("{fail_count} file(s) failed");
let _ = run.set_failed(db, duration_ms, &log.output(), &msg).await;
}
(ok_count, fail_count)
}
// ---------------------------------------------------------------------------
// Finalization (called on approve or auto-approve)
// ---------------------------------------------------------------------------
pub async fn finalize_approved(
db: &cot::db::Database,
pool: &sqlx::PgPool,
_config: &crate::config::AppConfig,
input_path_str: &str,
normalized: &NormalizedFields,
context: &serde_json::Value,
storage_dir_str: &str,
model_name: Option<&str>,
) -> anyhow::Result<()> {
let artist_name = normalized.artist.as_deref().unwrap_or("Unknown Artist");
let release_title = normalized.album.as_deref().unwrap_or("Unknown Release");
let track_title = normalized.title.as_deref().unwrap_or("Unknown Title");
let release_type = normalized.release_type.as_deref().unwrap_or("album");
let year = normalized.year;
let track_number = normalized.track_number;
let artist = find_or_create_artist(db, artist_name, model_name).await?;
let release = find_or_create_release(db, release_title, release_type, year, model_name).await?;
// Link ReleaseArtist
let existing_links = ReleaseArtist::find_by_release(db, release.id_val())
.await
.unwrap_or_default();
let already_linked = existing_links
.iter()
.any(|l| l.artist_id() == artist.id_val());
if !already_linked {
let position = existing_links.len() as i32;
let mut link = ReleaseArtist {
id: cot::db::Auto::auto(),
release_id: release.id_val(),
artist_id: artist.id_val(),
position,
};
link.insert(db)
.await
.map_err(|e| anyhow::anyhow!("failed to link release-artist: {e}"))?;
}
let sha256 = context
.get("sha256")
.and_then(|v| v.as_str())
.unwrap_or("");
let file_size = context
.get("file_size")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let duration_secs = context
.get("duration_secs")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let source_path = Path::new(input_path_str);
let original_filename = source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let ext = source_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("flac");
let mime_type = match ext.to_lowercase().as_str() {
"mp3" => "audio/mpeg",
"flac" => "audio/flac",
"ogg" | "opus" => "audio/ogg",
"aac" | "m4a" => "audio/mp4",
"wav" => "audio/wav",
"aiff" | "aif" => "audio/aiff",
_ => "application/octet-stream",
};
let track_num = track_number.unwrap_or(0);
let dest_filename = if track_num > 0 {
format!(
"{:02} - {}.{}",
track_num,
sanitize_filename(track_title),
ext
)
} else {
format!("{}.{}", sanitize_filename(track_title), ext)
};
let storage_dir = Path::new(storage_dir_str);
let storage_path = if source_path.exists() {
match mover::move_to_storage(
storage_dir,
artist_name,
release_title,
&dest_filename,
source_path,
)
.await?
{
mover::MoveOutcome::Moved(p) => p.to_string_lossy().to_string(),
mover::MoveOutcome::Merged(p) => p.to_string_lossy().to_string(),
}
} else {
storage_dir
.join(sanitize_filename(artist_name))
.join(sanitize_filename(release_title))
.join(&dest_filename)
.to_string_lossy()
.to_string()
};
let media_file = MediaFile::create(
db,
"audio",
&storage_path,
original_filename,
mime_type,
file_size,
sha256,
Some(ext),
None,
None,
None,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
let track = Track::create(
db,
track_title,
release.id_val(),
track_number,
None,
duration_secs,
media_file.id_val(),
year,
model_name,
)
.await
.map_err(|e| anyhow::anyhow!("failed to create track: {e}"))?;
TrackArtist::create(db, track.id_val(), artist.id_val(), "main", 0)
.await
.map_err(|e| anyhow::anyhow!("failed to link track-artist: {e}"))?;
for (i, feat_name) in normalized.featured_artists.iter().enumerate() {
let feat_artist = find_or_create_artist(db, feat_name, model_name).await?;
let _ = TrackArtist::create(
db,
track.id_val(),
feat_artist.id_val(),
"featuring",
(i + 1) as i32,
)
.await;
}
// Cover art: if the release has no cover yet, try to find one
if release.cover_file_id.is_none() {
let source_folder = Path::new(input_path_str)
.parent()
.unwrap_or(Path::new("."));
// Collect audio files in the same folder to try embedded extraction
let audio_files_in_folder: Vec<std::path::PathBuf> = std::fs::read_dir(source_folder)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
AUDIO_EXTENSIONS.contains(&ext.as_str())
})
.map(|e| e.path())
.collect()
})
.unwrap_or_default();
match crate::agent::cover_art::find_best_cover(source_folder, &audio_files_in_folder).await
{
Some(cover) => {
let source_desc = match &cover.source {
crate::agent::cover_art::CoverSource::FolderFile(p) => {
format!("folder file: {}", p.display())
}
crate::agent::cover_art::CoverSource::Embedded(p) => {
format!("embedded in: {}", p.display())
}
};
match crate::agent::cover_art::save_cover_to_storage(
db,
pool,
storage_dir_str,
artist_name,
release_title,
&cover,
)
.await
{
Ok(cover_file_id) => {
let _ = crate::agent::cover_art::assign_cover_to_release(
pool,
release.id_val(),
cover_file_id,
)
.await;
tracing::info!(
release_id = release.id_val(),
cover_file_id,
source = %source_desc,
"Assigned cover art to release"
);
}
Err(e) => {
tracing::warn!(
release_id = release.id_val(),
error = %e,
"Failed to save cover art"
);
}
}
}
None => {
tracing::debug!(
release_id = release.id_val(),
"No cover art found for release"
);
}
}
}
tracing::info!(
track_id = track.id_val(),
artist = artist_name,
release = release_title,
title = track_title,
"Track finalized"
);
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async fn find_or_create_artist(
db: &cot::db::Database,
name: &str,
model_name: Option<&str>,
) -> anyhow::Result<Artist> {
let name_sort = name.trim().to_lowercase();
let all = Artist::list_all(db).await.unwrap_or_default();
for a in &all {
if a.name_sort.as_str() == name_sort {
return Ok(a.clone());
}
}
Artist::create(db, name, model_name)
.await
.map_err(|e| anyhow::anyhow!("failed to create artist: {e}"))
}
async fn find_or_create_release(
db: &cot::db::Database,
title: &str,
release_type: &str,
year: Option<i32>,
model_name: Option<&str>,
) -> anyhow::Result<Release> {
let title_sort = title.trim().to_lowercase();
let all = Release::list_all(db).await.unwrap_or_default();
for r in &all {
if r.title_sort.as_str() == title_sort && r.release_type.as_str() == release_type {
return Ok(r.clone());
}
}
Release::create(db, title, release_type, year, model_name)
.await
.map_err(|e| anyhow::anyhow!("failed to create release: {e}"))
}
async fn cleanup_empty_dirs(dir: &Path) -> bool {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(e) => e,
Err(_) => return false,
};
let mut is_empty = true;
while let Ok(Some(entry)) = entries.next_entry().await {
let ft = match entry.file_type().await {
Ok(ft) => ft,
Err(_) => {
is_empty = false;
continue;
}
};
if ft.is_dir() {
let child_empty = Box::pin(cleanup_empty_dirs(&entry.path())).await;
if child_empty {
let _ = tokio::fs::remove_dir(&entry.path()).await;
} else {
is_empty = false;
}
} else {
is_empty = false;
}
}
is_empty
}
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect::<String>()
.trim()
.to_owned()
}
fn truncate_path(path: &str, max_len: usize) -> String {
if path.len() <= max_len {
path.to_owned()
} else {
format!("...{}", &path[path.len() - (max_len - 3)..])
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod artist_image_backfill;
pub mod artist_track_image_backfill;
pub mod cover_backfill;
pub mod inbox_discover;
pub mod inbox_process;
+72 -23
View File
@@ -1,9 +1,14 @@
mod admin;
mod agent;
mod api;
mod auth;
mod config;
mod i18n;
mod jobs;
mod music;
mod oidc;
mod player;
mod scheduler;
mod user;
use std::sync::Arc;
@@ -12,8 +17,8 @@ use cot::auth::PasswordVerificationResult;
use cot::cli::CliMetadata;
use cot::common_types::Password;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreConfig, SessionStoreTypeConfig,
};
use cot::db::Database;
use cot::form::{Form, FormResult};
@@ -31,8 +36,24 @@ use serde::Deserialize;
use crate::config::AppConfig;
use crate::i18n::{I18n, Translations};
use crate::scheduler::{JobRegistry, SchedulerHandle};
use crate::user::User;
// ---------------------------------------------------------------------------
// Build the job registry
// ---------------------------------------------------------------------------
fn build_registry() -> Arc<JobRegistry> {
let mut registry = JobRegistry::new();
registry.register(jobs::inbox_discover::InboxDiscoverJob);
registry.register(jobs::inbox_process::InboxProcessJob);
registry.register(jobs::inbox_process::FileProcessJob);
registry.register(jobs::cover_backfill::CoverBackfillJob);
registry.register(jobs::artist_image_backfill::ArtistImageBackfillJob);
registry.register(jobs::artist_track_image_backfill::ArtistTrackImageBackfillJob);
Arc::new(registry)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
@@ -42,23 +63,12 @@ async fn index(
db: Database,
i18n: I18n,
) -> cot::Result<cot::response::Response> {
let user = match auth::get_session_user(&session, &db).await {
let _user = match auth::get_session_user(&session, &db).await {
Some(u) => u,
None => return Ok(auth::redirect("/login")),
};
let role_label = match user.role {
auth::Role::Admin => format!(
r#"{} | <a href="/admin/">{}</a>"#,
user.role.code(),
i18n.t.nav_admin
),
_ => user.role.code().to_owned(),
};
Html::new(format!(
"<h1>{}</h1><p>{}</p><p>{}: {}</p>",
i18n.t.index_heading, i18n.t.index_status, user.name, role_label
))
.into_response()
let template = player::PlayerPageTemplate { t: i18n.t };
Html::new(template.render()?).into_response()
}
#[derive(Deserialize)]
@@ -154,7 +164,12 @@ impl App for FuruApp {
get(|| async { Ok::<_, cot::Error>(auth::redirect("/swagger/")) }),
"swagger_redirect",
),
Route::with_handler_and_name("/", index, "index"),
Route::with_handler_and_name("/",
|session: Session, db: Database, i18n: I18n| async move {
index(session, db, i18n).await
},
"index",
),
Route::with_handler_and_name(
"/login",
get({
@@ -236,6 +251,8 @@ impl App for FuruApp {
struct FuruProject {
app_config: Arc<AppConfig>,
registry: Arc<JobRegistry>,
scheduler_handle: Arc<tokio::sync::OnceCell<Arc<SchedulerHandle>>>,
}
impl Project for FuruProject {
@@ -289,6 +306,8 @@ impl Project for FuruProject {
MiddlewareConfig::builder()
.session(
SessionMiddlewareConfig::builder()
.secure(false)
.same_site(SameSite::Lax)
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
@@ -310,14 +329,30 @@ impl Project for FuruProject {
) -> cot::project::RootHandler {
handler
.middleware(StaticFilesMiddleware::from_context(context))
.middleware(
SessionMiddleware::from_context(context)
.same_site(cot::config::SameSite::Lax),
)
.middleware(SessionMiddleware::from_context(context))
.build()
}
fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) {
// Spawn the scheduler in background — it runs independently of HTTP
// requests. The OnceCell ensures it starts exactly once.
let sched_cell = Arc::clone(&self.scheduler_handle);
let sched_config = Arc::clone(&self.app_config);
let sched_registry = Arc::clone(&self.registry);
tokio::spawn(async move {
let _ = sched_cell
.get_or_init(|| async {
match scheduler::start_scheduler(&sched_config, sched_registry).await {
Ok(handle) => handle,
Err(e) => {
tracing::error!("Failed to start scheduler: {e:#}");
panic!("scheduler failed to start: {e}");
}
}
})
.await;
});
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
FuruApp {
@@ -326,10 +361,18 @@ impl Project for FuruProject {
"",
);
apps.register_with_views(
admin::AdminApp::new(Arc::clone(&self.app_config)),
admin::AdminApp::new(
Arc::clone(&self.app_config),
Arc::clone(&self.registry),
Arc::clone(&self.scheduler_handle),
),
"/admin",
);
apps.register_with_views(api::ApiApp, "/api");
apps.register_with_views(
player::PlayerApp::new(Arc::clone(&self.app_config)),
"/api/player",
);
if self.app_config.swagger_enabled {
apps.register_with_views(
cot::openapi::swagger_ui::SwaggerUi::new(),
@@ -362,5 +405,11 @@ fn main() -> impl Project {
tracing::info!("loaded config: {:?}", app_config);
FuruProject { app_config }
let registry = build_registry();
FuruProject {
app_config,
registry,
scheduler_handle: Arc::new(tokio::sync::OnceCell::new()),
}
}
+1443
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -190,6 +190,11 @@ pub async fn oidc_start_handler(
let redirect_url = RedirectUrl::new(redirect_uri_str.clone())
.map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
tracing::info!(
redirect_uri = %redirect_uri_str,
oidc_issuer = %config.oidc_issuer,
"OIDC start: building authorization request",
);
// Build PKCE challenge.
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
@@ -206,6 +211,7 @@ pub async fn oidc_start_handler(
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
tracing::info!(auth_url = %auth_url, "OIDC start: redirecting to provider");
// Store OIDC flow state in the session.
session
+2440
View File
File diff suppressed because it is too large Load Diff
+1321
View File
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.artists_edit_heading }}{% else %}{{ t.artists_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/artists/{{ form_artist_id }}/edit{% else %}/admin/artists/new{% endif %}">
<table>
<tr>
<td><label for="name">{{ t.artists_name }}</label></td>
<td><input name="name" id="name" value="{{ form_name }}" required style="width:100%"></td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% if is_edit %}
<hr style="margin: 2rem 0;">
<h2>{{ t.artists_image }}</h2>
<div id="artist-image-section" style="margin-top: 1rem;">
<!-- Current image -->
<div style="margin-bottom: 1.5rem;">
{% match current_image_url %}
{% when Some with (url) %}
<img id="current-image" src="{{ url }}" alt="" style="max-width: 200px; max-height: 200px; border-radius: 6px; border: 1px solid #ddd;">
<br>
<button type="button" onclick="removeImage()" style="margin-top: .5rem; padding: .3rem 1rem; cursor: pointer;">{{ t.artists_remove_image }}</button>
{% when None %}
<p style="color: #888;">{{ t.artists_no_image }}</p>
{% endmatch %}
</div>
<!-- Upload custom image -->
<div style="margin-bottom: 1.5rem;">
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_upload_image }}</h3>
<input type="file" id="image-upload" accept="image/*" style="margin-bottom: .5rem;">
<br>
<button type="button" id="upload-btn" onclick="uploadImage()" style="padding: .3rem 1rem; cursor: pointer;" disabled>{{ t.artists_upload }}</button>
</div>
<!-- Pick from album covers -->
<div>
<h3 style="font-size: .95rem; margin-bottom: .5rem;">{{ t.artists_pick_cover }}</h3>
<div id="covers-grid" style="display: flex; flex-wrap: wrap; gap: .75rem;">
<p style="color: #888;" id="covers-loading">...</p>
</div>
</div>
</div>
<script>
const artistId = {{ form_artist_id }};
// Enable upload button when file selected
document.getElementById('image-upload').addEventListener('change', function() {
document.getElementById('upload-btn').disabled = !this.files.length;
});
// Upload custom image
function uploadImage() {
const fileInput = document.getElementById('image-upload');
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function() {
const base64 = reader.result.split(',')[1];
fetch('/admin/artists/' + artistId + '/upload-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: base64,
filename: file.name,
mime_type: file.type || 'image/jpeg'
})
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
};
reader.readAsDataURL(file);
}
// Set image from album cover
function setImage(mediaFileId) {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: mediaFileId })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Remove image
function removeImage() {
fetch('/admin/artists/' + artistId + '/set-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ media_file_id: null })
}).then(function(r) {
if (r.ok) location.reload();
else r.text().then(function(t) { alert('Error: ' + t); });
});
}
// Load available covers
fetch('/admin/artists/' + artistId + '/available-covers')
.then(function(r) { return r.json(); })
.then(function(covers) {
const grid = document.getElementById('covers-grid');
grid.innerHTML = '';
if (!covers.length) {
grid.innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
return;
}
covers.forEach(function(c) {
const div = document.createElement('div');
div.style.cssText = 'cursor:pointer; text-align:center;';
div.title = c.release_title;
div.innerHTML = '<img src="' + c.cover_url + '" alt="" style="width:100px; height:100px; object-fit:cover; border-radius:4px; border:2px solid #ddd;">'
+ '<br><small style="font-size:.75rem; color:#666;">' + c.release_title.substring(0, 20) + '</small>';
div.onclick = function() { setImage(c.media_file_id); };
grid.appendChild(div);
});
})
.catch(function() {
document.getElementById('covers-grid').innerHTML = '<p style="color: #888;">{{ t.artists_no_covers }}</p>';
});
</script>
{% endif %}
{% endblock content %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_artists }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.artists_heading }}</h1>
<p style="margin-bottom: 1rem;">
<a href="/admin/artists/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.artists_add }}</a>
</p>
{% if rows.is_empty() %}
<p>{{ t.artists_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.artists_name }}</th>
<th>{{ t.artists_releases }}</th>
<th>{{ t.artists_tracks }}</th>
<th>{{ t.artists_hidden }}</th>
<th>{{ t.artists_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.artist.id_val() }}</td>
<td>{{ row.artist.name_str() }}</td>
<td>
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ row.release_count }}</a>
</td>
<td>{{ row.track_count }}</td>
<td>{{ row.artist.is_hidden() }}</td>
<td>
<a href="/admin/artists/{{ row.artist.id_val() }}/edit">{{ t.artists_edit }}</a>
&nbsp;|&nbsp;
<a href="/admin/releases?artist_id={{ row.artist.id_val() }}">{{ t.artists_view_releases }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/artists/{{ row.artist.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.artists_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.artists_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ job.name_str() }}{% endblock admin_title %}
{% block content %}
<h1>{{ job.name_str() }}</h1>
<table>
<tr><th>{{ t.jobs_description }}</th><td>{{ job.description_str() }}</td></tr>
<tr><th>{{ t.jobs_cron }}</th><td><code>{{ job.cron_expression_str() }}</code></td></tr>
<tr><th>{{ t.jobs_enabled }}</th><td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td></tr>
<tr><th>{{ t.jobs_last_run }}</th><td>{{ job.last_run_at_str() }}</td></tr>
<tr><th>{{ t.jobs_next_run }}</th><td>{{ job.next_run_at_str() }}</td></tr>
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
{% else %}
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
</div>
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
</form>
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
<p>No runs yet.</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.jobs_run_status }}</th>
<th>{{ t.jobs_run_started }}</th>
<th>{{ t.jobs_run_duration }}</th>
<th>{{ t.jobs_run_trigger }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for run in runs %}
<tr>
<td>{{ run.id_val() }}</td>
<td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td>
<td>{{ run.started_at_str() }}</td>
<td>{{ run.duration_display() }}</td>
<td>{{ run.trigger_str() }}</td>
<td><a href="/admin/jobs/{{ job.name_str() }}/runs/{{ run.id_val() }}">{{ t.reviews_view }}</a></td>
</tr>
{% endfor %}
</table>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs">&larr; {{ t.jobs_back_to_list }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.jobs_run_detail }} #{{ run.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_run_detail }} #{{ run.id_val() }}</h1>
<table>
<tr><th>{{ t.jobs_run_status }}</th><td><span class="badge {{ run.status_badge_class() }}">{{ run.status_str() }}</span></td></tr>
<tr><th>{{ t.jobs_run_trigger }}</th><td>{{ run.trigger_str() }}</td></tr>
<tr><th>{{ t.jobs_run_started }}</th><td>{{ run.started_at_str() }}</td></tr>
<tr><th>{{ t.jobs_run_duration }}</th><td>{{ run.duration_display() }}</td></tr>
</table>
{% if !run.error_message_str().is_empty() %}
<h2>{{ t.jobs_run_error }}</h2>
<pre style="background:#f8d7da; color:#721c24; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ run.error_message_str() }}</pre>
{% endif %}
{% if !run.log_output_str().is_empty() %}
<h2>{{ t.jobs_run_log }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem; max-height:40em; overflow-y:auto;">{{ run.log_output_str() }}</pre>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/jobs/{{ job_name }}">&larr; {{ t.jobs_back_to_job }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-processing { background: #d1ecf1; color: #0c5460; }
</style>
{% endblock content %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_jobs }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.jobs_heading }}</h1>
<table>
<tr>
<th>{{ t.jobs_name }}</th>
<th>{{ t.jobs_description }}</th>
<th>{{ t.jobs_cron }}</th>
<th>{{ t.jobs_enabled }}</th>
<th>{{ t.jobs_last_run }}</th>
<th>{{ t.jobs_next_run }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for job in jobs %}
<tr>
<td><a href="/admin/jobs/{{ job.name_str() }}">{{ job.name_str() }}</a></td>
<td>{{ job.description_str() }}</td>
<td><code>{{ job.cron_expression_str() }}</code></td>
<td>{% if job.enabled() %}&#9989;{% else %}&#10060;{% endif %}</td>
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
{% else %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock content %}
+6
View File
@@ -3,6 +3,7 @@
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
@@ -37,6 +38,11 @@
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
<a href="/admin/media-files">{{ t.nav_media_files }}</a>
<a href="/admin/jobs">{{ t.nav_jobs }}</a>
<a href="/admin/reviews">{{ t.nav_reviews }}</a>
<a href="/admin/users">{{ t.nav_users }}</a>
<a href="/admin/settings">{{ t.nav_settings }}</a>
<a href="/admin/debug">{{ t.nav_debug }}</a>
+51
View File
@@ -0,0 +1,51 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_media_files }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.media_files_heading }}</h1>
{% if rows.is_empty() %}
<p>{{ t.media_files_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.media_files_filename }}</th>
<th>{{ t.media_files_type }}</th>
<th>{{ t.media_files_format }}</th>
<th>{{ t.media_files_size }}</th>
<th>{{ t.media_files_track }}</th>
<th>{{ t.media_files_path }}</th>
<th>{{ t.media_files_created }}</th>
<th>{{ t.media_files_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.media_file.id_val() }}</td>
<td>{{ row.media_file.original_filename_str() }}</td>
<td>{{ row.media_file.file_type_str() }}</td>
<td>{{ row.media_file.audio_format_str() }}</td>
<td>{{ row.media_file.file_size_display() }}</td>
<td>
{% if row.track_title.is_empty() %}
<span class="badge badge-orphan">{{ t.media_files_orphan }}</span>
{% else %}
{{ row.track_title }}
{% endif %}
</td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ row.media_file.file_path_str() }}">{{ row.media_file.file_path_str() }}</td>
<td>{{ row.media_file.created_at_str() }}</td>
<td>
<form method="post" action="/admin/media-files/{{ row.media_file.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.media_files_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.media_files_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<style>
.badge-orphan { background: #fff3cd; color: #856404; }
</style>
{% endblock content %}
+32
View File
@@ -0,0 +1,32 @@
{% if !agent_enabled %}
<p style="color:#999;">{{ t.settings_agent_status_disabled }}</p>
{% else if agent_llm_url.is_empty() %}
<p style="color:#999;">{{ t.settings_agent_status_no_url }}</p>
{% else if agent_probe.ok %}
<div style="border:1px solid #28a745; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#f0fff0;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#28a745;">{{ t.settings_agent_status_ok }}</p>
{% if !agent_probe.model_intro.is_empty() %}
<blockquote style="border-left:3px solid #28a745; padding-left:.75rem; margin:.5rem 0; color:#333; font-style:italic;">{{ agent_probe.model_intro }}</blockquote>
{% endif %}
<table style="font-size:.85rem; margin-top:.5rem;">
{% if !agent_probe.model_name.is_empty() %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_model_name }}</td><td><code>{{ agent_probe.model_name }}</code></td></tr>
{% endif %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_latency }}</td><td>{{ agent_probe.latency_ms }} ms</td></tr>
{% if let Some(pt) = agent_probe.prompt_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_prompt_tokens }}</td><td>{{ pt }}</td></tr>
{% endif %}
{% if let Some(ct) = agent_probe.completion_tokens %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_completion_tokens }}</td><td>{{ ct }}</td></tr>
{% endif %}
{% if let Some(tps) = agent_probe.tokens_per_sec %}
<tr><td style="padding-right:1rem; color:#666;">{{ t.settings_agent_tokens_per_sec }}</td><td>{{ format!("{:.1}", tps) }}</td></tr>
{% endif %}
</table>
</div>
{% else %}
<div style="border:1px solid #dc3545; border-radius:6px; padding:1rem; margin-bottom:1rem; background:#fff5f5;">
<p style="margin:0 0 .5rem; font-weight:bold; color:#dc3545;">{{ t.settings_agent_status_error }}</p>
<p style="margin:0; font-size:.85rem; color:#666;">{{ agent_probe.error }}</p>
</div>
{% endif %}
+130
View File
@@ -0,0 +1,130 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}{% endblock admin_title %}
{% block content %}
<h1>{% if is_edit %}{{ t.releases_edit_heading }}{% else %}{{ t.releases_new_heading }}{% endif %}</h1>
<form method="post" action="{% if is_edit %}/admin/releases/{{ form_release_id }}/edit{% else %}/admin/releases/new{% endif %}" id="release-form">
<table>
<tr>
<td><label for="title">{{ t.releases_title }}</label></td>
<td><input name="title" id="title" value="{{ form_title }}" required style="width:100%"></td>
</tr>
<tr>
<td><label for="release_type">{{ t.releases_type }}</label></td>
<td>
<select name="release_type" id="release_type" style="width:100%; padding:.4rem;">
{% for rt in release_types %}
<option value="{{ rt.0 }}"{% if form_release_type == rt.0 %} selected{% endif %}>{% if lang_code == "ru" %}{{ rt.2 }}{% else %}{{ rt.1 }}{% endif %} ({{ rt.0 }})</option>
{% endfor %}
</select>
</td>
</tr>
<tr>
<td><label for="year">{{ t.releases_year }}</label></td>
<td><input name="year" id="year" type="number" min="1900" max="2100" value="{{ form_year }}" style="width:100%"></td>
</tr>
<tr>
<td><label>{{ t.releases_artists }}</label></td>
<td>
<div id="artist-tags" style="display:flex; flex-wrap:wrap; gap:.3rem; margin-bottom:.5rem;"></div>
<div style="display:flex; gap:.5rem;">
<input list="artist-list" id="artist-input" placeholder="{{ t.releases_select_artist }}" style="flex:1; padding:.4rem;">
<datalist id="artist-list">
{% for a in artists %}
<option value="{{ a.name_str() }}" data-id="{{ a.id_val() }}"></option>
{% endfor %}
</datalist>
<button type="button" onclick="addArtist()" style="padding:.4rem .8rem;">+</button>
</div>
<input type="hidden" name="artist_id" id="artist-ids-hidden" value="">
</td>
</tr>
</table>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
<script>
(function() {
// Artist data from server
var allArtists = [
{% for a in artists %}
{ id: {{ a.id_val() }}, name: "{{ a.name_str() }}" },
{% endfor %}
];
// Currently selected artist IDs
var selectedIds = [{% for aid in form_artist_ids %}{{ aid }},{% endfor %}];
var tagsContainer = document.getElementById('artist-tags');
var hiddenInput = document.getElementById('artist-ids-hidden');
var artistInput = document.getElementById('artist-input');
function findArtistByName(name) {
var lower = name.toLowerCase().trim();
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].name.toLowerCase() === lower) return allArtists[i];
}
return null;
}
function findArtistById(id) {
for (var i = 0; i < allArtists.length; i++) {
if (allArtists[i].id === id) return allArtists[i];
}
return null;
}
function syncHidden() {
hiddenInput.value = selectedIds.join(',');
}
function renderTags() {
tagsContainer.innerHTML = '';
for (var i = 0; i < selectedIds.length; i++) {
var artist = findArtistById(selectedIds[i]);
if (!artist) continue;
var tag = document.createElement('span');
tag.style.cssText = 'display:inline-flex; align-items:center; gap:.3rem; padding:.2rem .5rem; background:#e9ecef; border-radius:4px; font-size:.85rem;';
tag.textContent = artist.name;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '\u00d7';
btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1rem; color:#c00; padding:0; line-height:1;';
btn.setAttribute('data-id', artist.id);
btn.onclick = function() {
var rid = parseInt(this.getAttribute('data-id'));
selectedIds = selectedIds.filter(function(x) { return x !== rid; });
renderTags();
syncHidden();
};
tag.appendChild(btn);
tagsContainer.appendChild(tag);
}
}
window.addArtist = function() {
var artist = findArtistByName(artistInput.value);
if (!artist) return;
if (selectedIds.indexOf(artist.id) === -1) {
selectedIds.push(artist.id);
renderTags();
syncHidden();
}
artistInput.value = '';
};
// Allow pressing Enter in the artist input to add
artistInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
window.addArtist();
}
});
// Initial render
renderTags();
syncHidden();
})();
</script>
{% endblock content %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_releases }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.releases_heading }}</h1>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:1rem; flex-wrap:wrap;">
<a href="/admin/releases/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.releases_add }}</a>
<form method="get" action="/admin/releases" style="display:flex; gap:.5rem; align-items:center;">
<label for="artist_id" style="font-size:.85rem; color:#555;">{{ t.releases_filter_label }}:</label>
<select name="artist_id" id="artist_id" onchange="this.form.submit()" style="padding:.35rem .5rem; border:1px solid #ccc; border-radius:4px;">
<option value="">{{ t.releases_filter_all }}</option>
{% for a in artists %}
<option value="{{ a.id_val() }}"{% match filter_artist_id %}{% when Some with (fid) %}{% if *fid == a.id_val() %} selected{% endif %}{% when None %}{% endmatch %}>{{ a.name_str() }}</option>
{% endfor %}
</select>
</form>
</div>
{% if rows.is_empty() %}
<p>{{ t.releases_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.releases_title }}</th>
<th>{{ t.releases_artists }}</th>
<th>{{ t.releases_type }}</th>
<th>{{ t.releases_year }}</th>
<th>{{ t.releases_actions }}</th>
</tr>
{% for row in rows %}
<tr>
<td>{{ row.release.id_val() }}</td>
<td>{{ row.release.title_str() }}</td>
<td>{% if row.artist_names.is_empty() %}<span style="color:#999;">{{ t.releases_no_artist }}</span>{% else %}{{ row.artist_names }}{% endif %}</td>
<td><code>{{ row.release.release_type_str() }}</code></td>
<td>{{ row.release.year_display() }}</td>
<td>
<a href="/admin/releases/{{ row.release.id_val() }}/edit">{{ t.releases_edit }}</a>
&nbsp;|&nbsp;
<form method="post" action="/admin/releases/{{ row.release.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.releases_delete_confirm }}')">
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.releases_delete }}</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% endblock content %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "admin/layout.html" %}
{% block admin_title %}Review #{{ review.id_val() }}{% endblock admin_title %}
{% block content %}
<h1>Review #{{ review.id_val() }}</h1>
<table>
<tr><th>{{ t.reviews_status }}</th><td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td></tr>
<tr><th>{{ t.reviews_type }}</th><td>{{ review.review_type_str() }}</td></tr>
<tr><th>{{ t.reviews_input_path }}</th><td style="word-break:break-all;">{{ review.input_path_str() }}</td></tr>
<tr><th>{{ t.reviews_confidence }}</th><td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td></tr>
<tr><th>{{ t.reviews_created }}</th><td>{{ review.created_at_str() }}</td></tr>
{% match stats %}
{% when Some with (s) %}
<tr><th>{{ t.reviews_model }}</th><td>{{ s.model_name_str() }}</td></tr>
<tr><th>{{ t.reviews_llm_duration }}</th><td>{{ s.duration_display() }}</td></tr>
<tr><th>{{ t.reviews_tokens }}</th><td>{{ s.tokens_display() }}</td></tr>
{% when None %}
{% endmatch %}
</table>
{% if !error_message.is_empty() %}
<div style="margin: 1rem 0; padding: 1rem; background: #f8d7da; color: #721c24; border-radius: 6px;">
<strong>{{ t.reviews_error }}:</strong> {{ error_message }}
</div>
{% endif %}
<div style="margin: 1rem 0; display: flex; gap: .5rem;">
{% if review.status_str() == "pending" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/approve" style="display:inline;">
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_approve }}</button>
</form>
<form method="post" action="/admin/reviews/{{ review.id_val() }}/reject" style="display:inline;">
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_reject }}</button>
</form>
{% endif %}
{% if review.status_str() == "failed" || review.status_str() == "processing" %}
<form method="post" action="/admin/reviews/{{ review.id_val() }}/requeue" style="display:inline;" onsubmit="return confirm('{{ t.reviews_requeue_confirm }}');">
<button type="submit" style="padding:.4rem 1rem; background:#17a2b8; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.reviews_requeue }}</button>
</form>
{% endif %}
</div>
{% if !context_pretty.is_empty() %}
<h2>{{ t.reviews_context }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ context_pretty }}</pre>
{% endif %}
{% if !result_pretty.is_empty() %}
<h2>{{ t.reviews_result }}</h2>
<pre style="background:#f4f4f4; padding:1rem; border-radius:6px; overflow-x:auto; font-size:.85rem;">{{ result_pretty }}</pre>
{% endif %}
<p style="margin-top:1rem;"><a href="/admin/reviews">&larr; {{ t.reviews_back_to_list }}</a></p>
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
{% endblock content %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "admin/layout.html" %}
{% block admin_title %}{{ t.nav_reviews }}{% endblock admin_title %}
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
<a href="/admin/reviews?status=rejected" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "rejected" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_rejected }}</a>
<a href="/admin/reviews?status=queued" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "queued" %} #17a2b8; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_queued }}</a>
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
{% if !reviews.is_empty() %}
<span style="flex:1;"></span>
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
</form>
{% endif %}
</div>
{% if reviews.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<table>
<tr>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
<th>{{ t.reviews_tokens }}</th>
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for review in reviews %}
<tr>
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
<td>{{ review.review_type_str() }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&review.id_val()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
<td>{{ s.tokens_display() }}</td>
{% when None %}
<td>-</td>
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
<style>
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
{% endblock content %}
+64
View File
@@ -82,6 +82,70 @@
</tr>
</table>
<h2>{{ t.settings_agent }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_agent_help }}</p>
<table>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
<tr>
<td><label for="agent_enabled">{{ t.settings_agent_enabled }}</label></td>
<td><input type="checkbox" name="agent_enabled" id="agent_enabled" value="on"{% if agent_enabled %} checked{% endif %}></td>
<td><span class="badge badge-{{ agent_enabled_source }}">{{ agent_enabled_source }}</span></td>
</tr>
<tr>
<td><label for="agent_inbox_dir">{{ t.settings_agent_inbox }}</label></td>
<td><input name="agent_inbox_dir" id="agent_inbox_dir" value="{{ agent_inbox_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_inbox_dir_source }}">{{ agent_inbox_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_storage_dir">{{ t.settings_agent_storage }}</label></td>
<td><input name="agent_storage_dir" id="agent_storage_dir" value="{{ agent_storage_dir }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_storage_dir_source }}">{{ agent_storage_dir_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_url">{{ t.settings_agent_llm_url }}</label></td>
<td><input name="agent_llm_url" id="agent_llm_url" value="{{ agent_llm_url }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_url_source }}">{{ agent_llm_url_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_model">{{ t.settings_agent_llm_model }}</label></td>
<td><input name="agent_llm_model" id="agent_llm_model" value="{{ agent_llm_model }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_model_source }}">{{ agent_llm_model_source }}</span></td>
</tr>
<tr>
<td><label for="agent_llm_auth">{{ t.settings_agent_llm_auth }}</label></td>
<td><input name="agent_llm_auth" id="agent_llm_auth" type="password" value="{{ agent_llm_auth }}" style="width:100%"></td>
<td><span class="badge badge-{{ agent_llm_auth_source }}">{{ agent_llm_auth_source }}</span></td>
</tr>
<tr>
<td><label for="agent_confidence_threshold">{{ t.settings_agent_threshold }}</label></td>
<td><input name="agent_confidence_threshold" id="agent_confidence_threshold" value="{{ agent_confidence_threshold }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_confidence_threshold_source }}">{{ agent_confidence_threshold_source }}</span></td>
</tr>
<tr>
<td><label for="agent_context_limit">{{ t.settings_agent_context }}</label></td>
<td><input name="agent_context_limit" id="agent_context_limit" value="{{ agent_context_limit }}" style="width:6em"></td>
<td><span class="badge badge-{{ agent_context_limit_source }}">{{ agent_context_limit_source }}</span></td>
</tr>
<tr>
<td><label for="agent_concurrency">{{ t.settings_agent_concurrency }}</label></td>
<td><input name="agent_concurrency" id="agent_concurrency" value="{{ agent_concurrency }}" type="number" min="1" max="32" style="width:6em"></td>
<td><span class="badge badge-{{ agent_concurrency_source }}">{{ agent_concurrency_source }}</span></td>
</tr>
</table>
<h2>{{ t.settings_agent_status }}</h2>
<div hx-get="/admin/settings/probe" hx-trigger="load" hx-swap="innerHTML">
<p style="color:#999;">
<span class="htmx-indicator" style="display:inline;">
&#9696; {{ t.settings_agent_status_loading }}...
</span>
</p>
</div>
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
</form>
{% endblock content %}
File diff suppressed because it is too large Load Diff