diff --git a/.gitea/workflows/deploy-music.yml b/.gitea/workflows/deploy-music.yml index f8e59d8..904ae15 100644 --- a/.gitea/workflows/deploy-music.yml +++ b/.gitea/workflows/deploy-music.yml @@ -19,6 +19,7 @@ jobs: APP: music NS: cube-music IMAGE: registry.famzheng.me/mochi/music + CHORD_IMAGE: registry.famzheng.me/mochi/music-chord steps: - uses: actions/checkout@v4 @@ -38,13 +39,19 @@ jobs: npm ci npm run build - - name: Build & push image + - name: Build & push images env: REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin + # main app docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" . docker push "$IMAGE:${{ steps.tag.outputs.sha }}" + # chord-fetcher sidecar (python + chromium) + docker build -f "apps/$APP/chord/Dockerfile" \ + -t "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}" \ + "apps/$APP/chord" + docker push "$CHORD_IMAGE:${{ steps.tag.outputs.sha }}" - name: Initialize K8s resources run: | @@ -52,5 +59,7 @@ jobs: - name: Roll out to k3s run: | - kubectl -n "$NS" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}" - kubectl -n "$NS" rollout status "deploy/$APP" --timeout=120s + kubectl -n "$NS" set image "deploy/$APP" \ + "$APP=$IMAGE:${{ steps.tag.outputs.sha }}" \ + "chord-fetcher=$CHORD_IMAGE:${{ steps.tag.outputs.sha }}" + kubectl -n "$NS" rollout status "deploy/$APP" --timeout=300s diff --git a/Cargo.lock b/Cargo.lock index 69b951e..3e21f73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,12 +96,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" @@ -124,6 +136,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cube" version = "0.1.0" @@ -143,6 +161,17 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -159,7 +188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -228,6 +257,33 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -315,6 +371,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -323,13 +396,140 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", ] [[package]] @@ -338,6 +538,18 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -361,6 +573,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -376,6 +594,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -421,7 +645,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -447,6 +671,7 @@ version = "0.1.0" dependencies = [ "axum", "cube-core", + "reqwest", "rusqlite", "serde", "serde_json", @@ -462,7 +687,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -512,6 +737,24 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -521,6 +764,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -530,6 +828,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -556,6 +889,58 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -570,6 +955,47 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -711,7 +1137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -720,6 +1146,18 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -736,6 +1174,40 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "thread_local" @@ -746,6 +1218,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -760,7 +1257,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -774,6 +1271,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -818,12 +1325,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -916,6 +1425,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicase" version = "2.9.0" @@ -928,6 +1443,30 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -946,18 +1485,138 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -967,6 +1626,170 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -987,6 +1810,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index dbade88..4e7e540 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } [profile.release] opt-level = "z" diff --git a/apps/music/Cargo.toml b/apps/music/Cargo.toml index 60ec3b0..7a34143 100644 --- a/apps/music/Cargo.toml +++ b/apps/music/Cargo.toml @@ -16,3 +16,4 @@ tracing = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } rusqlite = { workspace = true } +reqwest = { workspace = true } diff --git a/apps/music/chord/Dockerfile b/apps/music/chord/Dockerfile new file mode 100644 index 0000000..28bcc83 --- /dev/null +++ b/apps/music/chord/Dockerfile @@ -0,0 +1,24 @@ +# music chord-fetcher sidecar +# 抓 yopu.co 截图的 selenium 服务,跟 music 主容器同 pod 共享 PVC。 + +FROM python:3.11-slim-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium chromium-driver fonts-noto-cjk ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ENV CHROME_BIN=/usr/bin/chromium +ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver +ENV PYTHONUNBUFFERED=1 + +RUN pip install --no-cache-dir \ + selenium==4.27.1 \ + pillow==11.0.0 \ + fastapi==0.115.6 \ + uvicorn==0.34.0 + +WORKDIR /app +COPY yopu.py chord_server.py ./ + +EXPOSE 8001 +CMD ["uvicorn", "chord_server:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/apps/music/chord/chord_server.py b/apps/music/chord/chord_server.py new file mode 100644 index 0000000..f809a1f --- /dev/null +++ b/apps/music/chord/chord_server.py @@ -0,0 +1,127 @@ +""" +chord-fetcher sidecar 的 HTTP service。 + +跟 music 主容器同 pod,监听 :8001。被 music backend 通过 localhost 调用。 +worker 单线程串行(chromium 一次跑一个,省资源),文件落 /data/chord-fetch/{piece_id}.png。 +""" + +import json +import logging +import queue +import threading +import os +from pathlib import Path +from typing import Optional + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse + +import yopu + +logging.basicConfig(level=logging.INFO, + format='%(asctime)s %(levelname)s %(name)s: %(message)s') +logger = logging.getLogger('chord-server') + +OUT_DIR = Path(os.getenv('CHORD_OUT_DIR', '/data/chord-fetch')) +OUT_DIR.mkdir(parents=True, exist_ok=True) + +app = FastAPI() + + +# in-memory job state. piece_id -> {status, error, query} +state: dict[int, dict] = {} +state_lock = threading.Lock() +job_q: queue.Queue = queue.Queue() + + +def out_path(piece_id: int) -> Path: + return OUT_DIR / f"{piece_id}.png" + + +def worker(): + while True: + piece_id, query = job_q.get() + with state_lock: + state[piece_id] = {'status': 'processing', 'error': '', 'query': query} + logger.info("[piece=%d] start fetch query=%r", piece_id, query) + try: + ok, msg = yopu.fetch_chord_chart(query, str(out_path(piece_id))) + with state_lock: + if ok: + state[piece_id] = {'status': 'completed', 'error': '', 'query': query} + logger.info("[piece=%d] completed: %s", piece_id, msg) + else: + state[piece_id] = {'status': 'failed', 'error': msg, 'query': query} + logger.warning("[piece=%d] failed: %s", piece_id, msg) + except Exception as e: + logger.exception("[piece=%d] worker crash", piece_id) + with state_lock: + state[piece_id] = {'status': 'failed', 'error': str(e), 'query': query} + finally: + job_q.task_done() + + +threading.Thread(target=worker, daemon=True).start() + + +@app.get('/healthz') +def healthz(): + return {'ok': True} + + +@app.post('/fetch') +def fetch(piece_id: int, query: str): + """加入 fetch 队列。query 一般是 ' '。 + 幂等:已 completed 且文件还在,直接返回 completed。""" + if piece_id <= 0 or not query.strip(): + raise HTTPException(400, 'piece_id / query required') + + with state_lock: + cur = state.get(piece_id, {}) + if cur.get('status') == 'completed' and out_path(piece_id).exists(): + return {'status': 'completed'} + if cur.get('status') in ('pending', 'processing'): + return {'status': cur['status']} + state[piece_id] = {'status': 'pending', 'error': '', 'query': query} + + job_q.put((piece_id, query)) + return {'status': 'pending'} + + +@app.get('/status/{piece_id}') +def status(piece_id: int): + with state_lock: + cur = state.get(piece_id, {}) + file_exists = out_path(piece_id).exists() + if cur.get('status') == 'completed' and not file_exists: + return {'status': 'failed', 'error': 'png 文件丢了'} + if not cur and file_exists: + return {'status': 'completed'} + return { + 'status': cur.get('status', 'none'), + 'error': cur.get('error', ''), + 'query': cur.get('query', ''), + 'file_exists': file_exists, + } + + +@app.get('/image/{piece_id}') +def image(piece_id: int): + p = out_path(piece_id) + if not p.exists(): + raise HTTPException(404, 'not found') + return FileResponse(p, media_type='image/png') + + +@app.delete('/state/{piece_id}') +def reset(piece_id: int): + """music backend import 完后清状态 + 删 png(防 PVC 越积越多)。""" + with state_lock: + state.pop(piece_id, None) + p = out_path(piece_id) + if p.exists(): + try: + p.unlink() + except Exception as e: + logger.warning("[piece=%d] cleanup unlink: %s", piece_id, e) + return {'ok': True} diff --git a/apps/music/chord/yopu.py b/apps/music/chord/yopu.py new file mode 100644 index 0000000..e1dcd5d --- /dev/null +++ b/apps/music/chord/yopu.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +yopu.co 和弦谱抓取(v2) + +跟旧 guitar 版相比,UI 改了:现在是分立的 row: +- "谱面样式" → 选 "功能谱" +- "和弦样式" → 选 "级数名" +- "和弦图" → 默认(不动) + +抓取流程: + 1. /explore#q=<query> 搜索 + 2. 找第一个含「和弦谱」字样的结果 → 进 /view/<id> + 3. 在 row label = X 的行里,点 button.option 文本 = Y + 4. 撑开 div.sheet-container 容器把 overflow / max-height 砍掉,让全部内容渲染 + 5. 截图整个 container element + 6. PIL 裁白边 + padding,存 PNG +""" + +import os +import time +import logging +from pathlib import Path +from urllib.parse import quote, urlparse, urljoin + +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.chrome.options import Options +from selenium.common.exceptions import TimeoutException +from PIL import Image + +logger = logging.getLogger(__name__) + + +def setup_driver(window="1920,5000"): + o = Options() + o.add_argument('--headless=new') + o.add_argument('--no-sandbox') + o.add_argument('--disable-dev-shm-usage') + o.add_argument('--disable-gpu') + o.add_argument(f'--window-size={window}') + o.add_argument('--lang=zh-CN') + o.add_argument('user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36') + o.add_experimental_option('prefs', {'intl.accept_languages': 'zh-CN,zh,en-US,en'}) + + service = None + if cdp := os.getenv('CHROMEDRIVER_PATH'): + service = Service(cdp) + if cb := os.getenv('CHROME_BIN'): + o.binary_location = cb + return webdriver.Chrome(service=service, options=o) + + +def find_first_chord_chart(driver, search_url): + """在搜索页找第一个「和弦谱」结果,返回 view url 和 title。""" + logger.info("loading search: %s", search_url) + driver.get(search_url) + time.sleep(3) + + chord_links = driver.execute_script(""" + var hits = []; + var posts = document.querySelectorAll('a.post-main'); + for (var i = 0; i < posts.length; i++) { + var info = posts[i].querySelector('.one-line-info'); + var t = info ? (info.textContent || info.innerText || '') : ''; + if (t.indexOf('和弦') >= 0 && t.indexOf('谱') >= 0) { + hits.push({ + href: posts[i].href, + title: (posts[i].querySelector('.title-line .title, .title') || {}).textContent || '', + text: t.trim(), + }); + } + } + return hits; + """) + + if not chord_links: + logger.warning("no '和弦谱' hits in search results") + return None + first = chord_links[0] + href = first['href'] + if href.startswith('/'): + p = urlparse(search_url) + href = f"{p.scheme}://{p.netloc}{href}" + elif not href.startswith('http'): + href = urljoin(search_url, href) + logger.info("matched: %s — %s", first.get('title'), href) + return {'url': href, 'title': first.get('title') or '', 'text': first.get('text') or ''} + + +def select_option_in_row(driver, row_label, button_text, timeout=10): + """在 label 含 row_label 的 row 里,点 button.option 文本含 button_text 的按钮。 + 返回 True 表示点了;False 表示找不到(不算错误,可能是 UI 文案变了)。""" + wait = WebDriverWait(driver, timeout) + try: + row = wait.until(EC.presence_of_element_located(( + By.XPATH, + f"//div[contains(@class, 'row')][.//div[contains(@class, 'label') " + f"and contains(normalize-space(.), '{row_label}')]]" + ))) + except TimeoutException: + logger.warning("row '%s' not found", row_label) + return False + + buttons = row.find_elements(By.CSS_SELECTOR, "button.option, button") + for btn in buttons: + txt = (btn.text or '').strip() + if button_text in txt: + try: + driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", btn) + time.sleep(0.3) + btn.click() + logger.info("clicked '%s' in row '%s'", button_text, row_label) + time.sleep(1.2) + return True + except Exception as e: + logger.warning("click failed in row '%s' / '%s': %s", row_label, button_text, e) + return False + logger.warning("button '%s' not found in row '%s' (had: %s)", + button_text, row_label, [(b.text or '').strip() for b in buttons]) + return False + + +def expand_sheet_container(driver, container): + """把 sheet-container 跟它的祖先一起把 overflow / max-height 拆掉, + 让 scrollHeight 全暴露,截图能拿到完整谱面。""" + return driver.execute_script(""" + var c = arguments[0]; + var origStyle = c.getAttribute('style') || ''; + var modified = []; + var node = c; + while (node && node !== document.body) { + var cs = window.getComputedStyle(node); + if (cs.overflow === 'hidden' || cs.overflow === 'auto' + || cs.overflowY === 'hidden' || cs.overflowY === 'auto' + || cs.maxHeight !== 'none') { + modified.push({ el: node, orig: node.getAttribute('style') || '' }); + node.style.overflow = 'visible'; + node.style.overflowY = 'visible'; + node.style.maxHeight = 'none'; + node.style.height = 'auto'; + } + node = node.parentElement; + } + c.style.overflow = 'visible'; + c.style.maxHeight = 'none'; + c.style.height = 'auto'; + c.style.minHeight = c.scrollHeight + 'px'; + c.offsetHeight; // force reflow + c.setAttribute('data-orig-style', origStyle); + window.__yopuModified = modified; + return { scrollHeight: c.scrollHeight, modified: modified.length }; + """, container) + + +def crop_white(path, pad_top=20, pad_bottom=50, pad_left=20, pad_right=20, white_th=250): + """裁掉四边的白边,加点 padding。""" + img = Image.open(path) + w, h = img.size + if img.mode != 'RGB': + img = img.convert('RGB') + px = img.load() + + def row_white_ratio(y): + wp = 0 + for x in range(w): + r, g, b = px[x, y] + if r > white_th and g > white_th and b > white_th: + wp += 1 + return wp / w + + def col_white_ratio(x, y0, y1): + wp = 0 + rng = max(1, y1 - y0) + for y in range(y0, y1): + r, g, b = px[x, y] + if r > white_th and g > white_th and b > white_th: + wp += 1 + return wp / rng + + top = 0 + for y in range(h): + if row_white_ratio(y) < 0.99: + top = y + break + bottom = h + for y in range(h - 1, -1, -1): + if row_white_ratio(y) < 0.99: + bottom = y + 1 + break + if top >= bottom: + return # all white, give up + + left = 0 + for x in range(w): + if col_white_ratio(x, top, bottom) < 0.99: + left = x + break + right = w + for x in range(w - 1, -1, -1): + if col_white_ratio(x, top, bottom) < 0.99: + right = x + 1 + break + if left >= right: + return + + box = ( + max(0, left - pad_left), + max(0, top - pad_top), + min(w, right + pad_right), + min(h, bottom + pad_bottom), + ) + img.crop(box).save(path, 'PNG') + logger.info("cropped to %s", box) + + +def fetch_chord_chart(query: str, output_path: str, *, + sheet_style: str = '功能谱', + chord_style: str = '级数名', + verbose: bool = False) -> tuple[bool, str]: + """ + 搜 yopu.co、进 view 页、按 row 选样式、截图。 + 返回 (ok, msg)。msg 在失败时是错误说明。 + """ + if verbose: + logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') + else: + logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') + + search_url = f"https://yopu.co/explore#q={quote(query)}" + driver = None + try: + driver = setup_driver() + result = find_first_chord_chart(driver, search_url) + if not result: + return False, '未找到和弦谱' + view_url = result['url'] + + logger.info("loading view: %s", view_url) + driver.get(view_url) + time.sleep(3) + + # 选样式(写死的 MVP 组合) + select_option_in_row(driver, '谱面样式', sheet_style) + select_option_in_row(driver, '和弦样式', chord_style) + + # 等内容刷新 + time.sleep(1.5) + + wait = WebDriverWait(driver, 15) + sheet = wait.until(EC.presence_of_element_located( + (By.CSS_SELECTOR, "div.sheet-container") + )) + + driver.execute_script("arguments[0].scrollIntoView(true);", sheet) + time.sleep(0.5) + + dims = expand_sheet_container(driver, sheet) + logger.debug("expanded scrollHeight=%s, modified=%s ancestors", dims['scrollHeight'], dims['modified']) + time.sleep(1.5) + + # incrButton:放大字号 / chord size,跟旧版一样点 3 次 + try: + buttons = driver.find_elements(By.CSS_SELECTOR, "button.incrButton") + if buttons: + driver.execute_script("arguments[0].scrollIntoView({block: 'center'});", buttons[0]) + time.sleep(0.3) + for _ in range(3): + buttons[0].click() + time.sleep(0.4) + except Exception as e: + logger.warning("incrButton failed: %s", e) + + time.sleep(1.0) + + # 滚 sheet 内部回到顶部,截整个 container + driver.execute_script("arguments[0].scrollTop = 0;", sheet) + time.sleep(0.4) + + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + sheet.screenshot(str(out)) + if not out.exists() or out.stat().st_size < 100: + return False, '截图为空' + logger.info("screenshot: %s (%d bytes)", out, out.stat().st_size) + + try: + crop_white(str(out)) + except Exception as e: + logger.warning("crop failed: %s", e) + + return True, str(out) + except Exception as e: + logger.error("fetch failed: %s", e, exc_info=True) + return False, str(e) + finally: + if driver: + try: + driver.quit() + except Exception: + pass diff --git a/apps/music/frontend/src/lib/api.js b/apps/music/frontend/src/lib/api.js index 002ec06..cf9a710 100644 --- a/apps/music/frontend/src/lib/api.js +++ b/apps/music/frontend/src/lib/api.js @@ -57,3 +57,11 @@ export function deleteAttachment(id) { export function attachmentUrl(id) { return `/api/attachments/${id}` } + +export function chordFetch(pieceId) { + return fetch(`/api/pieces/${pieceId}/chord/fetch`, { method: 'POST' }).then(jsonOrThrow) +} + +export function chordStatus(pieceId) { + return fetch(`/api/pieces/${pieceId}/chord/status`).then(jsonOrThrow) +} diff --git a/apps/music/frontend/src/views/PlayerView.vue b/apps/music/frontend/src/views/PlayerView.vue index b819707..0cfed45 100644 --- a/apps/music/frontend/src/views/PlayerView.vue +++ b/apps/music/frontend/src/views/PlayerView.vue @@ -103,6 +103,29 @@ :alt="att.filename" class="sheet-img" /> + <!-- 吉他谱专属:没图时给个自动抓取按钮 --> + <div + v-if="activeTab === 'chord' && roleAttachments('chord').length === 0" + class="auto-fetch" + > + <p v-if="chordState === 'idle'" class="hint-line"> + 从 yopu.co 抓 <b>功能谱 + 级数名</b>。 + </p> + <p v-else-if="chordState === 'pending' || chordState === 'processing'" class="hint-line"> + 正在抓取,浏览器后台跑 chromium 截图,约 30-60s… + </p> + <p v-else-if="chordState === 'failed'" class="hint-line err"> + 抓取失败:{{ chordError }} + </p> + <button + class="btn-fetch" + :disabled="chordState === 'pending' || chordState === 'processing'" + @click="startChordFetch" + > + <span v-if="chordState === 'pending' || chordState === 'processing'" class="spin">⏳</span> + <span v-else>🎸 自动抓取吉他谱</span> + </button> + </div> </div> <!-- PDF --> @@ -182,6 +205,8 @@ import { patchPiece, recordPlay, attachmentUrl as attUrl, + chordFetch, + chordStatus, } from '../lib/api.js' import { parseLrc } from '../lib/lrc.js' @@ -211,6 +236,12 @@ let notesTimer = null let randomSeed = Math.random() let lastReportedId = null +// chord auto-fetch state +const chordState = ref('idle') // idle | pending | processing | completed | failed +const chordError = ref('') +let chordPollTimer = null +let chordPollStarted = 0 + const lyricsLines = computed(() => parseLrc(selected.value?.lyrics || '')) const activeLyricIdx = computed(() => { @@ -240,16 +271,14 @@ const tabs = computed(() => { if (!selected.value) return [] const list = [] if (selected.value.lyrics) list.push({ key: 'lyrics', label: '歌词', count: 0 }) - const chord = roleAttachments('chord').length - if (chord) list.push({ key: 'chord', label: '吉他谱', count: chord }) + // 吉他谱 tab 永远给(没图时显示自动抓取按钮) + list.push({ key: 'chord', label: '吉他谱', count: roleAttachments('chord').length }) const num = roleAttachments('numbered').length if (num) list.push({ key: 'numbered', label: '简谱', count: num }) const staff = roleAttachments('staff').length if (staff) list.push({ key: 'staff', label: '五线谱', count: staff }) if (pdfAttachments.value.length) list.push({ key: 'pdf', label: '乐谱 PDF', count: pdfAttachments.value.length }) if (videoAttachments.value.length) list.push({ key: 'video', label: '视频', count: videoAttachments.value.length }) - // 没歌词也至少给一个 fallback tab - if (list.length === 0) list.push({ key: 'lyrics', label: '歌词', count: 0 }) return list }) @@ -332,6 +361,10 @@ async function loadPieces() { async function loadPiece(id) { selected.value = null notesDraft.value = '' + // 切歌时清空 chord state(避免 polling 漂到新曲目) + stopChordPoll() + chordState.value = 'idle' + chordError.value = '' if (!id) return try { const p = await getPiece(id) @@ -460,6 +493,69 @@ function setTab(k) { activeTab.value = k } +async function startChordFetch() { + if (!selectedId.value) return + chordState.value = 'pending' + chordError.value = '' + try { + const r = await chordFetch(selectedId.value) + if (r.status === 'completed') { + // 已经有谱(或刚 import):刷新 piece + await reloadPiece() + chordState.value = 'completed' + return + } + chordState.value = r.status || 'pending' + chordPollStarted = Date.now() + if (chordPollTimer) clearInterval(chordPollTimer) + chordPollTimer = setInterval(pollChord, 3000) + } catch (e) { + chordState.value = 'failed' + chordError.value = e.message || String(e) + } +} + +async function pollChord() { + if (!selectedId.value) { stopChordPoll(); return } + // 90s 超时保护 + if (Date.now() - chordPollStarted > 90_000) { + stopChordPoll() + chordState.value = 'failed' + chordError.value = '抓取超时(>90s),可能 yopu 限流或 selector 失效' + return + } + try { + const r = await chordStatus(selectedId.value) + chordState.value = r.status || 'pending' + chordError.value = r.error || '' + if (r.status === 'completed') { + stopChordPoll() + await reloadPiece() + } else if (r.status === 'failed') { + stopChordPoll() + } + } catch (e) { + // 暂时性错误就不立即放弃,下一轮再试 + chordError.value = e.message || String(e) + } +} + +function stopChordPoll() { + if (chordPollTimer) { + clearInterval(chordPollTimer) + chordPollTimer = null + } +} + +async function reloadPiece() { + if (!selectedId.value) return + try { + const fresh = await getPiece(selectedId.value) + // 保留正在播的 audio.src 不动 + selected.value = fresh + } catch {} +} + // notes auto-save function onNotesInput() { if (!selectedId.value) return @@ -523,6 +619,7 @@ onMounted(async () => { onBeforeUnmount(() => { document.removeEventListener('keydown', onKeyDown) if (notesTimer) clearTimeout(notesTimer) + stopChordPoll() }) </script> @@ -784,6 +881,35 @@ onBeforeUnmount(() => { background: #fff; } +.auto-fetch { + margin-top: 40px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +} +.auto-fetch .hint-line { + color: var(--text-mute); + font-size: 14px; + line-height: 1.6; +} +.auto-fetch .hint-line b { color: var(--accent); } +.auto-fetch .hint-line.err { color: var(--accent-red); } +.btn-fetch { + background: var(--accent-strong); + color: #fff; + padding: 12px 22px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + transition: background 0.15s, transform 0.05s; +} +.btn-fetch:hover:not(:disabled) { background: var(--accent); } +.btn-fetch:active:not(:disabled) { transform: scale(0.97); } +.btn-fetch .spin { display: inline-block; animation: spin-anim 1.5s linear infinite; } +@keyframes spin-anim { to { transform: rotate(360deg); } } + .pdf-box { display: flex; flex-direction: column; gap: 16px; } .pdf-frame { width: 100%; diff --git a/apps/music/k8s/all.yaml b/apps/music/k8s/all.yaml index b0d178e..0ed0252 100644 --- a/apps/music/k8s/all.yaml +++ b/apps/music/k8s/all.yaml @@ -51,6 +51,8 @@ spec: value: /data/app.db - name: BLOBS_DIR value: /data/blobs + - name: CHORD_URL + value: http://localhost:8001 readinessProbe: httpGet: path: /healthz @@ -73,6 +75,38 @@ spec: volumeMounts: - name: data mountPath: /data + - name: chord-fetcher + image: registry.famzheng.me/mochi/music-chord:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8001 + name: chord + env: + - name: CHORD_OUT_DIR + value: /data/chord-fetch + readinessProbe: + httpGet: + path: /healthz + port: chord + initialDelaySeconds: 3 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: chord + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: 50m + memory: 256Mi + # chromium 内存峰值很容易飙到 ~600MB + limits: + cpu: 2000m + memory: 1Gi + volumeMounts: + - name: data + mountPath: /data volumes: - name: data persistentVolumeClaim: diff --git a/apps/music/src/main.rs b/apps/music/src/main.rs index 6f65ca0..9d67f1e 100644 --- a/apps/music/src/main.rs +++ b/apps/music/src/main.rs @@ -37,6 +37,9 @@ const REQUEST_BYTES: usize = 5 * 1024 * 1024 * 1024; // 5 GiB / 单次上传 struct AppState { db: Arc<Mutex<Connection>>, blobs_dir: PathBuf, + /// 同 pod 的 chord-fetcher sidecar root(默认 http://localhost:8001)。 + chord_url: String, + http: reqwest::Client, } #[tokio::main] @@ -82,9 +85,18 @@ async fn main() -> std::io::Result<()> { .expect("init schema"); tracing::info!(%db_path, blobs = %blobs_dir.display(), "music ready"); + let chord_url = + std::env::var("CHORD_URL").unwrap_or_else(|_| "http://localhost:8001".into()); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .expect("build reqwest client"); + let state = AppState { db: Arc::new(Mutex::new(conn)), blobs_dir, + chord_url, + http, }; let api = Router::new() @@ -94,6 +106,8 @@ async fn main() -> std::io::Result<()> { get(get_piece).patch(patch_piece).delete(delete_piece), ) .route("/pieces/:id/play", post(record_play)) + .route("/pieces/:id/chord/fetch", post(chord_fetch)) + .route("/pieces/:id/chord/status", get(chord_status)) .route( "/pieces/:id/attachments", post(upload_attachments).layer(DefaultBodyLimit::max(REQUEST_BYTES)), @@ -417,6 +431,143 @@ async fn delete_piece( Ok(JsonResp(json!({ "ok": true }))) } +// ---------- handlers: chord auto-fetch ---------- + +/// `POST /api/pieces/:id/chord/fetch` — 触发 sidecar 抓取 yopu 和弦谱。 +/// 已经有 chord attachment 的曲目直接返回 completed。 +async fn chord_fetch( + State(s): State<AppState>, + Path(piece_id): Path<i64>, +) -> Result<JsonResp<Value>, AppError> { + let (title, artist, has_chord) = chord_piece_meta(&s, piece_id)?; + if has_chord { + return Ok(JsonResp(json!({ "status": "completed", "reason": "已有吉他谱" }))); + } + let query = match artist.as_deref() { + Some(a) if !a.is_empty() => format!("{a} {title}"), + _ => title, + }; + let url = format!("{}/fetch", s.chord_url); + let resp = s + .http + .post(&url) + .query(&[("piece_id", piece_id.to_string()), ("query", query)]) + .send() + .await + .map_err(|e| AppError::sidecar(format!("post fetch: {e}")))?; + if !resp.status().is_success() { + let st = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::sidecar(format!("sidecar {st}: {body}"))); + } + let body: Value = resp + .json() + .await + .map_err(|e| AppError::sidecar(format!("decode: {e}")))?; + Ok(JsonResp(body)) +} + +/// `GET /api/pieces/:id/chord/status` — 查询抓取状态。完成时把 png import 成 attachment。 +async fn chord_status( + State(s): State<AppState>, + Path(piece_id): Path<i64>, +) -> Result<JsonResp<Value>, AppError> { + let (_title, _artist, has_chord) = chord_piece_meta(&s, piece_id)?; + if has_chord { + return Ok(JsonResp(json!({ "status": "completed", "imported": true }))); + } + + let url = format!("{}/status/{}", s.chord_url, piece_id); + let resp = s + .http + .get(&url) + .send() + .await + .map_err(|e| AppError::sidecar(format!("get status: {e}")))?; + if !resp.status().is_success() { + return Err(AppError::sidecar(format!("sidecar status: {}", resp.status()))); + } + let body: Value = resp + .json() + .await + .map_err(|e| AppError::sidecar(format!("decode: {e}")))?; + + let st = body.get("status").and_then(|v| v.as_str()).unwrap_or("none"); + let file_exists = body + .get("file_exists") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if st == "completed" && file_exists { + let attachment_id = import_chord_png(&s, piece_id).await?; + // 通知 sidecar 清掉 state + 文件,避免重复 import + let _ = s + .http + .delete(format!("{}/state/{}", s.chord_url, piece_id)) + .send() + .await; + return Ok(JsonResp(json!({ + "status": "completed", + "imported": true, + "attachment_id": attachment_id, + }))); + } + + Ok(JsonResp(body)) +} + +fn chord_piece_meta( + s: &AppState, + piece_id: i64, +) -> Result<(String, Option<String>, bool), AppError> { + let conn = s.db.lock().unwrap(); + let row: Option<(String, Option<String>)> = conn + .query_row( + "SELECT title, artist FROM pieces WHERE id = ?1", + params![piece_id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + let (title, artist) = row.ok_or(AppError::NotFound)?; + let has_chord: bool = conn + .query_row( + "SELECT 1 FROM attachments + WHERE piece_id = ?1 AND kind = 'image' AND role = 'chord' LIMIT 1", + params![piece_id], + |_| Ok(true), + ) + .optional()? + .unwrap_or(false); + Ok((title, artist, has_chord)) +} + +async fn import_chord_png(s: &AppState, piece_id: i64) -> Result<i64, AppError> { + let src = std::path::PathBuf::from(format!("/data/chord-fetch/{piece_id}.png")); + let bytes = tokio::fs::metadata(&src).await.map_err(AppError::Io)?; + let size = bytes.len() as i64; + + let attachment_id = { + let conn = s.db.lock().unwrap(); + conn.execute( + "INSERT INTO attachments + (piece_id, kind, role, mime, filename, size_bytes, sort_order) + VALUES (?1, 'image', 'chord', 'image/png', 'chord.png', ?2, + COALESCE((SELECT MAX(sort_order) FROM attachments WHERE piece_id = ?1), 0) + 1)", + params![piece_id, size], + )?; + conn.last_insert_rowid() + }; + + let dst = s.blobs_dir.join(attachment_id.to_string()); + if let Err(e) = tokio::fs::copy(&src, &dst).await { + // 失败回滚 db 行 + let conn = s.db.lock().unwrap(); + let _ = conn.execute("DELETE FROM attachments WHERE id = ?1", params![attachment_id]); + return Err(AppError::Io(e)); + } + Ok(attachment_id) +} + // ---------- handlers: attachments ---------- /// `POST /api/pieces/:id/attachments?role=chord|numbered|staff` — multipart 流式上传。 @@ -652,12 +803,16 @@ enum AppError { NotFound, Db(rusqlite::Error), Io(std::io::Error), + Sidecar(String), } impl AppError { fn bad_request(msg: impl Into<String>) -> Self { Self::BadRequest(msg.into()) } + fn sidecar(msg: impl Into<String>) -> Self { + Self::Sidecar(msg.into()) + } } impl From<rusqlite::Error> for AppError { @@ -679,6 +834,10 @@ impl IntoResponse for AppError { tracing::error!(error = %e, "io error"); (StatusCode::INTERNAL_SERVER_ERROR, "io error").into_response() } + Self::Sidecar(msg) => { + tracing::warn!(error = %msg, "chord sidecar"); + (StatusCode::BAD_GATEWAY, msg).into_response() + } } } }