diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9f43494 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# FIX CRLF always when developer has not set +# Linux/Mac: git config --global core.autocrlf input +# Windows: git config --global core.autocrlf true +# Auto detect text files and perform LF normalization +* text=auto +* eol=lf diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..024de92 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: Publish image + +# Build and push the feeder image to the GitHub Container Registry (ghcr.io): +# * every push to main -> the moving `main` tag +# * every `v*` tag -> semver tags (e.g. v1.0.0 -> 1.0.0, 1.0, 1) +# The build context is the repo root (the workspace) so the vendored +# `meta-feeder-sdk` path dependency resolves; the Dockerfile lives under +# feeder-plugin/paper-feeder/. +on: + push: + branches: [main] + tags: ['v*'] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,format=short + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./feeder-plugin/musicbrainz-feeder/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..db8d4b4 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1948 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "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]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "meta-feeder-sdk" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "bytes", + "futures", + "hyper", + "redb", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "musicbrainz-feeder" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64", + "bytes", + "futures", + "meta-feeder-sdk", + "redb", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "wiremock", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redb" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "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", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "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 = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +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", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +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 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9ec23f2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "2" +members = ["feeder-plugin/*"] + +[workspace.package] +edition = "2021" +rust-version = "1.85" +license = "MIT" +repository = "https://github.com/worph/meta-feeder-musicbrainz" diff --git a/README.md b/README.md index 5289aac..eeeaba5 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# meta-feeder-musicbrainz \ No newline at end of file +# meta-feeder-musicbrainz + +MusicBrainz identity cards (album / artist), plus the shared MusicBrainz client used by sibling music feeders. + +**Upstream:** MusicBrainz /ws/2 + Cover Art Archive + ListenBrainz +**Role:** identity + +A MetaMesh gateway feeder — one upstream, one job. Implements `FeederPlugin` +from [`meta-feeder-sdk`](https://github.com/worph/meta-feeder-sdk). + +```bash +cargo build --release --bin musicbrainz-feeder +cargo test +``` + +Scope and naming conventions: `docs/project-architecture/feeder-architecture.md` +in the meta-root. diff --git a/feeder-plugin/musicbrainz-feeder/Cargo.toml b/feeder-plugin/musicbrainz-feeder/Cargo.toml new file mode 100644 index 0000000..a6b466e --- /dev/null +++ b/feeder-plugin/musicbrainz-feeder/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "musicbrainz-feeder" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +# MusicBrainz identity cards (album / artist), plus the shared MusicBrainz client used by sibling music feeders. +# Upstream: MusicBrainz /ws/2 + Cover Art Archive + ListenBrainz + +[lib] +name = "musicbrainz_feeder" +path = "src/lib.rs" + +[[bin]] +name = "musicbrainz-feeder" +path = "src/main.rs" + +[dependencies] +# TEMPORARY: path dep while the SDK's new rev is unpushed. Flip to: +# meta-feeder-sdk = { git = "https://github.com/worph/meta-feeder-sdk", rev = "" } +meta-feeder-sdk = { path = "../../../meta-feeder-sdk" } + +tokio = { version = "1.40", features = ["full"] } +axum = { version = "0.7", features = ["json"] } +anyhow = "1" +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"], default-features = false } +bytes = "1" +futures = "0.3" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +redb = "2" +base64 = "0.22" + +[dev-dependencies] +wiremock = "0.6" +tempfile = "3" +tokio = { version = "1.40", features = ["full", "test-util"] } diff --git a/feeder-plugin/musicbrainz-feeder/Dockerfile b/feeder-plugin/musicbrainz-feeder/Dockerfile new file mode 100644 index 0000000..4442656 --- /dev/null +++ b/feeder-plugin/musicbrainz-feeder/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 +# musicbrainz-feeder sidecar. Multi-stage build; build context is this repo's root. +# meta-feeder-sdk arrives as a git dependency, so no vendored crate is copied +# in — `git` must be present in the builder for cargo to fetch it. + +FROM rust:1.89-slim-bookworm AS builder +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libssl-dev ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml rust-toolchain.toml ./ +COPY feeder-plugin/ feeder-plugin/ + +RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \ + --mount=type=cache,id=musicbrainz-feeder-target,target=/build/target \ + cargo build --release --bin musicbrainz-feeder \ + && cp target/release/musicbrainz-feeder /musicbrainz-feeder + +# ----------------------------------------------------------------------------- +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /musicbrainz-feeder /usr/local/bin/musicbrainz-feeder + +RUN useradd --system --uid 10001 --no-create-home metafeeder \ + && mkdir -p /data/meta-feeder \ + && chown metafeeder:metafeeder /data/meta-feeder + +USER metafeeder +WORKDIR /data/meta-feeder + +EXPOSE 8080 + +ENV META_FEEDER_HTTP_LISTEN=0.0.0.0:8080 \ + META_FEEDER_STATE_DIR=/data/meta-feeder \ + RUST_LOG=info + +ENTRYPOINT ["/usr/local/bin/musicbrainz-feeder"] diff --git a/feeder-plugin/musicbrainz-feeder/src/card.rs b/feeder-plugin/musicbrainz-feeder/src/card.rs new file mode 100644 index 0000000..98b5720 --- /dev/null +++ b/feeder-plugin/musicbrainz-feeder/src/card.rs @@ -0,0 +1,1008 @@ +//! The music card model and its projection to a `DiscoveryRecord`. +//! +//! A **card** identifies a *work* — an album, an artist — not a file. It +//! carries the display pair the quality gate needs (title + cover) plus an +//! **id bag** of every cross-source identifier the bridge resolved. Nothing +//! here is a byte locator; the card's own CID is a `0x1007` card-locator +//! derived from `(source, id)` and nothing is ever fetchable by it. +//! +//! The direct counterpart of `meta-feeder-card`'s `card.rs`, and deliberately +//! shaped the same so the two read side by side. +//! +//! ## Two kinds, one address space +//! +//! ⚠ **A music card locator takes no kind prefix**, unlike TMDB's +//! `tv:95479` / `movie:95479`. TMDB needs one because it reuses numeric ids +//! across media types; a MusicBrainz Identifier is a UUID and is unique across +//! every entity type, so `compute_card_cid("musicbrainz", "")` is minted +//! from the bare id. This is the IMDb convention (`METADATA_KEYS.md` §4), and +//! it is why `record_id` is `"musicbrainz:"` with nothing in between — +//! the record id *is* the CID preimage, so inserting a kind would silently +//! re-mint every card address. + +use std::collections::BTreeMap; + +use meta_feeder_sdk::hash::compute_card_cid; +use meta_feeder_sdk::types::DiscoveryRecord; + +use crate::consts::{CAA_BASE, CAA_FRONT_SIZE, MAX_TRACK_COUNT}; +use meta_feeder_sdk::hash::artwork_locator; +use crate::client::{normalise_date, Release}; + +/// The metadata source that published a card. The string form is the CID's +/// source namespace, so **changing it re-mints every card CID from that +/// source** — treat it as a wire constant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardSource { + MusicBrainz, +} + +impl CardSource { + pub fn as_str(self) -> &'static str { + match self { + CardSource::MusicBrainz => "musicbrainz", + } + } +} + +/// Which work a card describes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardKind { + Album, + Artist, +} + +impl CardKind { + /// The `contentKind` facet. Orthogonal to `fileType=card`. + pub fn content_kind(self) -> &'static str { + match self { + CardKind::Album => "album", + CardKind::Artist => "artist", + } + } + + /// Which artwork field this kind writes. An album has a **sleeve**, an + /// artist has a **portrait**, and the registry keeps them apart rather than + /// calling both a `poster` the way the video tier does. + /// + /// The value is a **`url` locator cid**, never a raw URL — see + /// [`artwork_locator`]. See METADATA_KEYS.md §6. + pub fn artwork_field(self) -> &'static str { + match self { + CardKind::Album => "cover", + CardKind::Artist => "photo", + } + } +} + +/// Where a card's cover image comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Cover { + /// A Cover Art Archive front image for a release-group. The endpoint + /// 307-redirects to the archive and **404s when nothing is illustrated**, + /// which is how a coverless work ends up gated out — see [`Card::to_record`]. + ReleaseGroupFront { release_group_mbid: String }, + /// A specific CAA image somebody already resolved for us (ListenBrainz + /// returns these). Strictly better than the front endpoint: it names an + /// image **known to exist**, so it cannot 404 into a hidden card. + Resolved { release_mbid: String, caa_id: i64 }, +} + +impl Cover { + pub fn url(&self) -> String { + match self { + Cover::ReleaseGroupFront { release_group_mbid } => { + format!("{CAA_BASE}/release-group/{release_group_mbid}/{CAA_FRONT_SIZE}") + } + Cover::Resolved { + release_mbid, + caa_id, + } => format!("{CAA_BASE}/release/{release_mbid}/{caa_id}-500.jpg"), + } + } +} + +/// A resolved work. +/// +/// Immutable by contract, exactly as a TMDB card is: no listen counts, no +/// popularity, no rating — nothing that drifts without the work changing. +/// `METADATA_KEYS.md` rule #4 forbids those on the shared hash anyway, and a +/// card is the one record type several peers derive independently at the same +/// CID, so a drifting value would make two peers disagree forever. +#[derive(Debug, Clone)] +pub struct Card { + pub source: CardSource, + pub kind: CardKind, + /// The bare MBID. Half of the card CID's preimage — see [`Card::record_id`]. + pub mbid: String, + + // -- display ------------------------------------------------------------ + pub title: String, + /// Credited album artist (albums only). The *display* string, join phrases + /// included. + pub album_artist: Option, + /// Every credited artist name, for the `artists/{name}` key-set. + pub artists: Vec, + /// The primary artist's own MBID, when the credit resolved one. + pub artist_mbid: Option, + pub cover: Option, + /// MusicBrainz has no synopsis field. Its `disambiguation` ("live album", + /// "1997 remaster") is the nearest thing and is what fills + /// `description/eng` when present. The album gate deliberately does **not** + /// require it — see `meta-listen/ARCHITECTURE.md` §6. + pub disambiguation: Option, + pub genres: Vec, + /// Variable-precision MusicBrainz date, normalised on projection. + pub release_date: Option, + + // -- structure (albums, detail path only) -------------------------------- + pub track_count: Option, + pub disc_count: Option, + pub label: Option, + pub catalog_number: Option, + /// `Album` / `Single` / `EP` / … Kept for the artist page's discography + /// grouping; not a `contentKind` (the work is an `album` either way). + pub primary_type: Option, + pub secondary_types: Vec, + /// The **canonical track list**, resolved on the detail path only. + /// + /// This is what lets a consumer say "12 tracks, 9 playable" truthfully and + /// name the three it cannot get. Without it an album page can only render + /// the releases it happened to find, which silently redefines the album as + /// whatever is available — the failure mode the whole card tier exists to + /// avoid. + pub tracks: Vec, + /// Official places to *listen elsewhere* — MusicBrainz's `streaming` and + /// `free streaming` artist relations (Spotify, Tidal, Apple, Deezer, …), + /// measured present in the delegated-playback study §4.2. + /// + /// Projected as `ext-play` (`0x1009`) locators in [`Card::to_record`], which + /// is where the reasoning for keeping them out of `cids/*` lives. + pub streaming_links: Vec, +} + +/// One entry of an album card's canonical track list. +/// +/// Carries no CID and no availability: it says *what the album contains*, not +/// what anyone can fetch. Joining it to real releases is the consumer's job. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalTrack { + /// 1-based position within its disc. + pub position: u32, + /// 1-based disc index. + pub disc: u32, + pub title: String, + /// Milliseconds, when anyone has timed it. + pub length_ms: Option, + pub mb_recording_id: Option, + pub isrc: Option, +} + +impl Card { + /// The feeder-scoped record id, `"musicbrainz:"` — also the exact + /// preimage of the card CID, so `compute_outcomes` round-trips a record id + /// straight back to a CID with no lookup. + pub fn record_id(&self) -> String { + format!("{}:{}", self.source.as_str(), self.mbid) + } + + /// This card's `0x1007` locator CID. Deterministic in `(source, mbid)`, so + /// any peer holding the MBID derives the same address offline. + pub fn cid(&self) -> Option { + compute_card_cid(self.source.as_str(), &self.mbid) + } + + /// The release type a discography groups by, folded from MusicBrainz's + /// primary + secondary types into one token. + /// + /// **Secondary types win over the primary.** MusicBrainz says a live album + /// is `primary=Album, secondary=[Live]`, and grouping it under "Albums" + /// buries an artist's studio records under decades of concert recordings. + /// The same reasoning applies to compilations and soundtracks: what the + /// release *is* to a listener is the secondary type whenever there is one. + pub fn release_type(&self) -> Option<&'static str> { + for s in &self.secondary_types { + match s.trim().to_ascii_lowercase().as_str() { + "compilation" => return Some("Compilation"), + "live" => return Some("Live"), + "soundtrack" => return Some("Soundtrack"), + "remix" => return Some("Remix"), + "dj-mix" | "dj mix" => return Some("DJMix"), + _ => {} + } + } + match self.primary_type.as_deref()?.trim().to_ascii_lowercase().as_str() { + "album" => Some("Album"), + "single" => Some("Single"), + "ep" => Some("EP"), + "broadcast" => Some("Broadcast"), + "other" => Some("Other"), + _ => None, + } + } + + /// Would this card actually render? Exactly [`Card::to_record`]'s emit + /// condition, hoisted so a caller can predict the drop *before* projecting. + /// + /// ⚠ **This cannot predict the cover.** Unlike TMDB — whose response says + /// outright whether a cover exists — nothing in a MusicBrainz response + /// reveals whether the Cover Art Archive holds an image, and the fetch that + /// finds out happens in the **gateway core**, long after this feeder has + /// returned. So a card that passes here can still be hidden downstream. See + /// `consts::DISCOVERY_OVERFETCH` for the mitigation and why it is only a + /// mitigation. + pub fn is_displayable(&self) -> bool { + !self.title.trim().is_empty() && self.cover.is_some() + } + + /// Project to the wire record. + /// + /// Returns `None` when the card has no title or no cover source — it would + /// fail the consumer's quality gate and render as a hole in the grid, so + /// the feeder declines to emit it at all rather than shipping an + /// unrenderable card. + /// + /// `query_filters` echoes back every structured filter the query carried, + /// which is **load-bearing**: both the gateway dispatcher and meta-search + /// re-apply `record_matches` to each record on the way out, and a *missing* + /// field fails the match — so an un-echoed `popular` means every card in + /// the row is dropped at one of the two tiers, and the row renders empty + /// with no error anywhere. + pub fn to_record(&self, query_filters: &BTreeMap>) -> Option { + let title = self.title.trim(); + if title.is_empty() { + return None; + } + let cover = self.cover.as_ref()?; + + let mut fields: BTreeMap = BTreeMap::new(); + + // Type axes. `card` on the fileType axis and the work kind on the + // contentKind axis stay orthogonal, so `fileType:card contentKind:album` + // is a well-formed query and routing needs no special case. + fields.insert("fileType".to_string(), "card".to_string()); + fields.insert("contentKind".to_string(), self.kind.content_kind().to_string()); + fields.insert("title".to_string(), title.to_string()); + + // The id bag. Which field carries the card's own MBID depends on what + // the card *is* — an album is addressed by its release-group, an artist + // by its artist id. + match self.kind { + CardKind::Album => { + fields.insert("mbReleaseGroupId".to_string(), self.mbid.clone()); + } + CardKind::Artist => { + fields.insert("mbArtistId".to_string(), self.mbid.clone()); + } + } + if let Some(a) = self.artist_mbid.as_deref().filter(|s| !s.is_empty()) { + fields.insert("mbArtistId".to_string(), a.to_string()); + } + + if let Some(a) = self.album_artist.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + fields.insert("albumArtist".to_string(), a.to_string()); + } + for a in &self.artists { + let a = a.trim(); + if !a.is_empty() { + fields.insert(format!("artists/{a}"), "true".to_string()); + } + } + + if let Some(d) = self.release_date.as_deref().and_then(normalise_date) { + fields.insert("releasedate".to_string(), d); + } + if let Some(n) = self.track_count.filter(|n| *n > 0 && *n <= MAX_TRACK_COUNT) { + fields.insert("trackCount".to_string(), n.to_string()); + } + if let Some(n) = self.disc_count.filter(|n| *n > 0) { + fields.insert("discCount".to_string(), n.to_string()); + } + + // The release *type*, which is what a discography groups by. Written on + // album cards only. + // + // ⚠ Distinct from `contentKind`, which stays `album` for every one of + // these. A single and a live album are both albums as far as the type + // axis is concerned — folding "is it a single?" into `contentKind` + // would make it a routing decision, and meta-search would then have to + // know the music vocabulary to fan a query out. + if self.kind == CardKind::Album { + if let Some(t) = self.release_type() { + fields.insert("releaseType".to_string(), t.to_string()); + } + } + if let Some(l) = self.label.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + fields.insert("label".to_string(), l.to_string()); + } + if let Some(c) = self + .catalog_number + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + fields.insert("catalogNumber".to_string(), c.to_string()); + } + + // MusicBrainz has no synopsis; `disambiguation` is the nearest thing. + // Written under the namespaced key meta-search indexes, not a flat + // `description` (METADATA_KEYS §14.6). + if let Some(d) = self + .disambiguation + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + fields.insert("description/eng".to_string(), d.to_string()); + } + + // Genres as a KEY-SET (`genres/ = "true"`), never the legacy + // comma-joined value. METADATA_KEYS §14.12 forbids new csv-set writers, + // and the reason bites hardest here: a card is the one record type + // multiple peers derive independently at the same CID, so two peers + // resolving different genre sets must union by key-merge rather than + // diverge into two strings that need diffing to reconcile. + for g in &self.genres { + let g = g.trim(); + if !g.is_empty() { + fields.insert(format!("genres/{g}"), "true".to_string()); + } + } + + // The canonical track list, as an INDEXED-LIST (`tracks/{n}/` — + // `METADATA_KEYS.md` "Value formats"). The integer index carries + // position, which is what a set encoding could not express: a track + // list is ordered, and two tracks on the same album can share a title + // (a reprise, a hidden track), so key-set members would collide. + // + // Present only on a card resolved through the DETAIL path. A browse + // card carries none, because filling one would cost an extra + // MusicBrainz request per row entry against a 1 req/s budget — twenty + // seconds for a twenty-card row, past every consumer-side idle cutoff. + // Growth is append-only: the detail resolution adds fields to the same + // CID, it never edits one. + for (i, t) in self.tracks.iter().enumerate() { + fields.insert(format!("tracks/{i}/title"), t.title.clone()); + fields.insert(format!("tracks/{i}/position"), t.position.to_string()); + fields.insert(format!("tracks/{i}/disc"), t.disc.to_string()); + if let Some(ms) = t.length_ms { + fields.insert(format!("tracks/{i}/length"), ms.to_string()); + } + if let Some(id) = &t.mb_recording_id { + fields.insert(format!("tracks/{i}/mbRecordingId"), id.clone()); + } + if let Some(isrc) = &t.isrc { + fields.insert(format!("tracks/{i}/isrc"), isrc.clone()); + } + } + + // The artwork, as a `url` LOCATOR cid — never a raw `*_url` field. + // + // METADATA_KEYS rule #5: *"it does not add a URL field — it emits a + // `url` locator CID"*. The feeder invariant against fetching bytes is + // no obstacle, because the locator is a pure function of the URL + // string: no request, no hash, no blockstore. The gateway core upgrades + // it to a real content cid when it seeds the bytes, and meta-share + // resolves it lazily if the gateway never got there. + // + // **Music vocabulary, not video's.** An album card writes `cover`, an + // artist card `photo`. The images come from the Cover Art Archive and a + // portrait is not a sleeve, so neither is a `poster`. + // + // ⚠ A 404 stays silent from here: the gateway deletes the field, and + // the consumer's gate then hides the card. That is the correct outcome + // (a coverless album card is a hole in a grid), but it means "row came + // back short" and "artwork missing" look identical downstream. + if let Some(locator) = artwork_locator(&cover.url()) { + fields.insert(self.kind.artwork_field().to_string(), locator); + } + + // The bare-CID key-set member. + // + // **Load-bearing, and not merely cosmetic.** The gateway's search path + // persists a hit to meta-core only if it can find a CID on it — no key + // means the record is silently unpersisted. A card is the one record + // type whose CID is *derived* rather than discovered, and + // `compute_outcomes` (the other place it is derived) is not on the + // search path. Without this line a card is never persisted at all, + // which interacts badly with the gateway's search-coverage gate: it + // marks `(upstream, query)` covered whenever the feeder produced + // records and thereafter serves meta-core for an hour — coverage + // marked plus nothing persisted means **an identical search returns + // empty for the rest of the window**. + if let Some(cid) = self.cid() { + fields.insert(format!("cids/{cid}"), "true".to_string()); + } + + // Default provenance. The SDK copy this crate vendors (meta-gateway's) + // has no `stamp_default_source` helper — that lives only in the indexer + // feeder's copy — so it is stamped explicitly here rather than + // diverging the vendored SDK. See `cache.rs` for why the copy is kept + // byte-identical. + fields.insert( + format!("source/gateway:{}", self.source.as_str()), + "true".to_string(), + ); + + // ⚠ **`externalPlay/`, deliberately NOT `cids/`.** + // + // An `ext-play` locator ranks tier 5 — the same tier as the `card` + // locator that *is* this record's address. Putting one in the cid + // key-set would enter it into the canonical election as a peer of the + // card locator, and a tie there resolves on the lexicographically + // smaller cid: the work's own address could flip to a Spotify link, + // silently breaking every `work_key` route pointing at it. + // + // A separate key-set keeps them cid-shaped (rule #5 — never a raw + // `*_url`, which only *looks* transient) without letting them address + // the work. They are link affordances, never versions, and never + // queueable: an external page cannot report `ended`, so a queued one + // would stop playback silently. + for url in &self.streaming_links { + if let Some(cid) = meta_feeder_sdk::hash::compute_ext_play_cid(url) { + fields.insert(format!("externalPlay/{cid}"), "true".to_string()); + } + } + + // The filter echo (see the fn doc). `genres` and `languages` are + // excluded for the same reason the card feeder excludes them: both are + // key-sets, so the flat field this would write is not the shape a + // reader looks for, and the filter *value* is a slug + // (`hip-hop`) rather than a genre name — echoing it would persist a + // slug masquerading as a genre. + for (key, allowed) in query_filters { + if key == "languages" || key == "genres" || fields.contains_key(key) || allowed.is_empty() + { + continue; + } + fields.insert(key.clone(), allowed.join(",")); + } + + Some(DiscoveryRecord { + upstream_id: self.source.as_str().to_string(), + record_id: self.record_id(), + fields, + }) + } + + /// Fold a resolved canonical release into an album card: track/disc + /// counts, label and catalogue number. The detail-path enrichment a browse + /// row deliberately skips. + pub fn with_release_detail(mut self, release: &Release) -> Self { + let tracks: u32 = release + .media + .iter() + .map(|m| { + if m.track_count > 0 { + m.track_count + } else { + m.tracks.len() as u32 + } + }) + .sum(); + if tracks > 0 { + self.track_count = Some(tracks.min(MAX_TRACK_COUNT)); + } + if !release.media.is_empty() { + self.disc_count = Some(release.media.len() as u32); + } + if let Some(li) = release.label_info.first() { + self.label = li.label.as_ref().map(|l| l.name.clone()).filter(|s| !s.is_empty()); + self.catalog_number = li.catalog_number.clone().filter(|s| !s.is_empty()); + } + self.tracks = release + .media + .iter() + .flat_map(|m| { + // A medium's `position` is 1-based, but MusicBrainz omits it on + // single-disc releases — default to disc 1 rather than 0, or + // every track on a one-disc album reports disc 0 and a consumer + // sorting by (disc, track) puts them before disc 1. + let disc = if m.position == 0 { 1 } else { m.position }; + m.tracks.iter().map(move |t| { + let rec = t.recording.as_ref(); + CanonicalTrack { + // Same reasoning: `position` is the reliable ordinal + // (`number` is a printed string — vinyl uses `A1`). + position: t.position, + disc, + title: if t.title.trim().is_empty() { + rec.map(|r| r.title.clone()).unwrap_or_default() + } else { + t.title.clone() + }, + length_ms: t.length.or_else(|| rec.and_then(|r| r.length)), + mb_recording_id: rec.map(|r| r.id.clone()).filter(|s| !s.is_empty()), + isrc: rec.and_then(|r| r.isrcs.first().cloned()), + } + }) + }) + .filter(|t| !t.title.trim().is_empty()) + .collect(); + self + } +} + +/// Split a card `record_id` (`"musicbrainz:"`) back into +/// `(source, mbid)` — the two halves of the CID preimage. +/// +/// ⚠ `split_once`, not `rsplit_once`: an MBID contains hyphens but never a +/// colon, so the *first* colon is the source boundary. +pub fn split_record_id(record_id: &str) -> Option<(&str, &str)> { + record_id + .split_once(':') + .filter(|(s, id)| !s.is_empty() && !id.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const RG: &str = "f5093c06-23e3-404f-aeaa-40f72885ee3a"; + const AR: &str = "561d854a-6a28-4aa7-8c99-323e6ce46c2a"; + + fn album() -> Card { + Card { + source: CardSource::MusicBrainz, + kind: CardKind::Album, + mbid: RG.to_string(), + title: "Kind of Blue".to_string(), + album_artist: Some("Miles Davis".to_string()), + artists: vec!["Miles Davis".to_string()], + artist_mbid: Some(AR.to_string()), + cover: Some(Cover::ReleaseGroupFront { + release_group_mbid: RG.to_string(), + }), + disambiguation: None, + genres: vec!["Jazz".to_string(), "Modal Jazz".to_string()], + release_date: Some("1959-08-17".to_string()), + track_count: None, + disc_count: None, + label: None, + catalog_number: None, + primary_type: Some("Album".to_string()), + secondary_types: vec![], + tracks: Vec::new(), + streaming_links: Vec::new(), + } + } + + #[test] + fn album_projects_the_expected_field_set() { + let r = album().to_record(&BTreeMap::new()).expect("record"); + assert_eq!(r.upstream_id, "musicbrainz"); + assert_eq!(r.record_id, format!("musicbrainz:{RG}")); + assert_eq!(r.fields.get("fileType").map(String::as_str), Some("card")); + assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("album")); + assert_eq!(r.fields.get("title").map(String::as_str), Some("Kind of Blue")); + assert_eq!(r.fields.get("mbReleaseGroupId").map(String::as_str), Some(RG)); + assert_eq!(r.fields.get("mbArtistId").map(String::as_str), Some(AR)); + assert_eq!( + r.fields.get("albumArtist").map(String::as_str), + Some("Miles Davis") + ); + assert_eq!( + r.fields.get("releasedate").map(String::as_str), + Some("1959-08-17") + ); + // Genres are a key-set, never a comma-joined value. + assert_eq!(r.fields.get("genres/Jazz").map(String::as_str), Some("true")); + assert!(r.fields.get("genres").is_none()); + // Provenance is always stamped. + assert_eq!( + r.fields.get("source/gateway:musicbrainz").map(String::as_str), + Some("true") + ); + } + + /// The `cids/` member is what makes the card persistable at all — + /// without it the gateway silently drops it and the search-coverage gate + /// then serves an empty result for an hour. + #[test] + fn album_stamps_its_own_locator_as_a_cid_member() { + let c = album(); + let cid = c.cid().expect("locator"); + let r = c.to_record(&BTreeMap::new()).expect("record"); + assert_eq!(r.fields.get(&format!("cids/{cid}")).map(String::as_str), Some("true")); + } + + /// ⚠ The no-kind-prefix rule. If this ever fails, every music card address + /// on the network has been re-minted and nothing resolves to what it used + /// to. + #[test] + fn the_locator_preimage_is_the_bare_mbid() { + let c = album(); + assert_eq!(c.record_id(), format!("musicbrainz:{RG}")); + assert_eq!( + c.cid(), + compute_card_cid("musicbrainz", RG), + "the card CID must be minted from the BARE mbid — no album:/artist: prefix" + ); + // And an artist card with the same id space does not collide, because + // MBIDs are globally unique across entity types. + assert_ne!(compute_card_cid("musicbrainz", RG), compute_card_cid("musicbrainz", AR)); + } + + /// The filter echo — an un-echoed filter means the record is dropped by + /// `record_matches` at the gateway or at meta-search, and the row renders + /// empty with no error. + #[test] + fn structured_filters_are_echoed_back_except_the_key_sets() { + let mut filters = BTreeMap::new(); + filters.insert("popular".to_string(), vec!["true".to_string()]); + filters.insert("genres".to_string(), vec!["jazz".to_string()]); + filters.insert("languages".to_string(), vec!["eng".to_string()]); + let r = album().to_record(&filters).expect("record"); + assert_eq!(r.fields.get("popular").map(String::as_str), Some("true")); + // The slug must NOT be persisted as if it were a genre name. + assert_eq!(r.fields.get("genres").map(String::as_str), None); + assert_eq!(r.fields.get("languages").map(String::as_str), None); + assert_eq!(r.fields.get("genres/Jazz").map(String::as_str), Some("true")); + } + + /// The echo must never overwrite a field the card itself resolved. + #[test] + fn the_echo_does_not_clobber_a_resolved_field() { + let mut filters = BTreeMap::new(); + filters.insert("contentKind".to_string(), vec!["album".to_string()]); + filters.insert("title".to_string(), vec!["something else".to_string()]); + let r = album().to_record(&filters).expect("record"); + assert_eq!(r.fields.get("title").map(String::as_str), Some("Kind of Blue")); + assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("album")); + } + + #[test] + fn a_coverless_or_titleless_card_is_not_emitted() { + let mut c = album(); + c.cover = None; + assert!(c.to_record(&BTreeMap::new()).is_none()); + + let mut c = album(); + c.title = " ".to_string(); + assert!(c.to_record(&BTreeMap::new()).is_none()); + } + + #[test] + fn artist_cards_are_addressed_by_their_artist_mbid() { + let c = Card { + kind: CardKind::Artist, + mbid: AR.to_string(), + title: "Miles Davis".to_string(), + album_artist: None, + artist_mbid: None, + ..album() + }; + let r = c.to_record(&BTreeMap::new()).expect("record"); + assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("artist")); + assert_eq!(r.fields.get("mbArtistId").map(String::as_str), Some(AR)); + assert!(r.fields.get("mbReleaseGroupId").is_none()); + assert!(r.fields.get("albumArtist").is_none()); + } + + #[test] + fn a_resolved_cover_beats_the_front_endpoint() { + let front = Cover::ReleaseGroupFront { + release_group_mbid: RG.to_string(), + }; + assert!(front.url().ends_with(&format!("/release-group/{RG}/front-500"))); + + let resolved = Cover::Resolved { + release_mbid: "rel-1".to_string(), + caa_id: 42, + }; + assert!(resolved.url().ends_with("/release/rel-1/42-500.jpg")); + } + + #[test] + fn release_detail_folds_in_counts_label_and_catalogue() { + use crate::client::{Label, LabelInfo, Medium, Release, Track}; + let release = Release { + id: "rel".into(), + title: "Kind of Blue".into(), + media: vec![ + Medium { + position: 1, + track_count: 5, + tracks: vec![Track::default(); 5], + ..Default::default() + }, + Medium { + position: 2, + track_count: 3, + tracks: vec![Track::default(); 3], + ..Default::default() + }, + ], + label_info: vec![LabelInfo { + catalog_number: Some("CL 1355".into()), + label: Some(Label { + name: "Columbia".into(), + }), + }], + ..Default::default() + }; + let c = album().with_release_detail(&release); + assert_eq!(c.track_count, Some(8)); + assert_eq!(c.disc_count, Some(2)); + assert_eq!(c.label.as_deref(), Some("Columbia")); + assert_eq!(c.catalog_number.as_deref(), Some("CL 1355")); + } + + /// The medium's **declared** `track-count` wins over how many tracks were + /// inlined. The album genuinely has that many; a truncated response should + /// not shorten it. This is what lets a consumer say "12 tracks, 9 playable" + /// truthfully rather than quietly claiming the album is 9 tracks long. + #[test] + fn a_declared_track_count_beats_the_inlined_track_list() { + use crate::client::{Medium, Release, Track}; + let release = Release { + media: vec![Medium { + position: 1, + track_count: 12, + tracks: vec![Track::default(); 2], + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(album().with_release_detail(&release).track_count, Some(12)); + } + + /// The canonical track list is what lets a consumer say "12 tracks, 9 + /// playable" and *name* the three it cannot get. It is an indexed-list + /// because a track list is ordered and two tracks on one album can share a + /// title (a reprise, a hidden track) — key-set members would collide. + #[test] + fn the_canonical_track_list_rides_out_as_an_indexed_list() { + use crate::client::{Medium, Recording, Release, Track}; + let release = Release { + media: vec![ + Medium { + position: 1, + track_count: 2, + tracks: vec![ + Track { + position: 1, + title: "So What".into(), + length: Some(545_000), + recording: Some(Recording { + id: "rec-1".into(), + isrcs: vec!["USSM15900001".into()], + ..Default::default() + }), + ..Default::default() + }, + Track { + position: 2, + title: "Freddie Freeloader".into(), + ..Default::default() + }, + ], + ..Default::default() + }, + Medium { + position: 2, + track_count: 1, + tracks: vec![Track { + position: 1, + title: "Blue in Green".into(), + ..Default::default() + }], + ..Default::default() + }, + ], + ..Default::default() + }; + let c = album().with_release_detail(&release); + assert_eq!(c.tracks.len(), 3); + let r = c.to_record(&BTreeMap::new()).expect("record"); + assert_eq!(r.fields["tracks/0/title"], "So What"); + assert_eq!(r.fields["tracks/0/position"], "1"); + assert_eq!(r.fields["tracks/0/disc"], "1"); + assert_eq!(r.fields["tracks/0/length"], "545000"); + assert_eq!(r.fields["tracks/0/isrc"], "USSM15900001"); + assert_eq!(r.fields["tracks/0/mbRecordingId"], "rec-1"); + // A track with no timing simply omits the field rather than claiming 0. + assert!(!r.fields.contains_key("tracks/1/length")); + // Disc 2's track keeps its own disc index, and its position restarts — + // which is why (disc, position) is the sort key, not position alone. + assert_eq!(r.fields["tracks/2/disc"], "2"); + assert_eq!(r.fields["tracks/2/position"], "1"); + } + + /// ⚠ A single-disc release omits the medium position, and defaulting it to + /// 0 puts every track *before* disc 1 in any (disc, position) sort. + #[test] + fn a_medium_with_no_position_is_disc_one() { + use crate::client::{Medium, Release, Track}; + let release = Release { + media: vec![Medium { + position: 0, + track_count: 1, + tracks: vec![Track { + position: 1, + title: "Only Track".into(), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + let c = album().with_release_detail(&release); + assert_eq!(c.tracks[0].disc, 1); + } + + /// ⚠ A live album is `primary=Album, secondary=[Live]`. Grouping it under + /// "Albums" buries an artist's studio records under decades of concert + /// recordings, so the secondary type wins. + #[test] + fn a_secondary_release_type_outranks_the_primary() { + let live = Card { + primary_type: Some("Album".into()), + secondary_types: vec!["Live".into()], + ..album() + }; + assert_eq!(live.release_type(), Some("Live")); + + let comp = Card { + primary_type: Some("Album".into()), + secondary_types: vec!["Compilation".into()], + ..album() + }; + assert_eq!(comp.release_type(), Some("Compilation")); + + // A plain studio album keeps its primary type. + assert_eq!(album().release_type(), Some("Album")); + + let single = Card { + primary_type: Some("Single".into()), + secondary_types: vec![], + ..album() + }; + assert_eq!(single.release_type(), Some("Single")); + } + + /// `releaseType` must not leak onto an artist card, and must never be + /// confused with `contentKind` — which stays `album` for every one of them. + #[test] + fn release_type_is_an_album_only_field_beside_content_kind() { + let r = Card { + primary_type: Some("Single".into()), + ..album() + } + .to_record(&BTreeMap::new()) + .expect("record"); + assert_eq!(r.fields["releaseType"], "Single"); + assert_eq!(r.fields["contentKind"], "album", "the routing axis is unchanged"); + + let artist = Card { + kind: CardKind::Artist, + mbid: AR.to_string(), + primary_type: Some("Group".into()), + ..album() + } + .to_record(&BTreeMap::new()) + .expect("record"); + assert!(!artist.fields.contains_key("releaseType")); + } + + /// A browse card carries no track list — filling one would cost an extra + /// MusicBrainz request per row entry against a 1 req/s budget. + #[test] + fn a_browse_card_emits_no_track_fields() { + let r = album().to_record(&BTreeMap::new()).expect("record"); + assert!( + !r.fields.keys().any(|k| k.starts_with("tracks/")), + "a browse card must stay cheap" + ); + } + + /// …but a medium that declares nothing falls back to what it did inline, + /// rather than reporting zero tracks. + #[test] + fn an_undeclared_track_count_falls_back_to_the_inlined_list() { + use crate::client::{Medium, Release, Track}; + let release = Release { + media: vec![Medium { + position: 1, + track_count: 0, + tracks: vec![Track::default(); 3], + ..Default::default() + }], + ..Default::default() + }; + assert_eq!(album().with_release_detail(&release).track_count, Some(3)); + } + + /// An MBID is full of hyphens but never a colon — the source boundary is + /// the FIRST colon, and splitting from the right would shred the id. + #[test] + fn record_ids_split_on_the_first_colon() { + assert_eq!( + split_record_id(&format!("musicbrainz:{RG}")), + Some(("musicbrainz", RG)) + ); + assert!(split_record_id("nocolon").is_none()); + assert!(split_record_id(":empty-source").is_none()); + assert!(split_record_id("empty-id:").is_none()); + } +} + +#[cfg(test)] +mod ext_play_tests { + use super::*; + + fn artist_card_with_links(links: &[&str]) -> Card { + Card { + source: CardSource::MusicBrainz, + kind: CardKind::Artist, + mbid: "056e4f3e-d505-4dad-8ec1-d04f521cbb56".into(), + title: "Daft Punk".into(), + album_artist: None, + artists: vec!["Daft Punk".into()], + artist_mbid: None, + cover: Some(Cover::ReleaseGroupFront { release_group_mbid: "rg".into() }), + disambiguation: None, + genres: vec![], + release_date: None, + track_count: None, + disc_count: None, + label: None, + catalog_number: None, + primary_type: None, + secondary_types: vec![], + tracks: Vec::new(), + streaming_links: links.iter().map(|s| s.to_string()).collect(), + } + } + + /// **The hazard this key-set exists to avoid.** + /// + /// An `ext-play` locator ranks tier 5, the same tier as the `card` locator + /// that addresses the work. In `cids/*` it would be a peer in the canonical + /// election, and a tie resolves on the lexicographically smaller cid — so + /// the artist's own address could flip to a Spotify link and break every + /// route pointing at it. + #[test] + fn streaming_links_never_enter_the_cid_keyset() { + let rec = artist_card_with_links(&["https://open.spotify.com/artist/4tZwfgrHOc3mvqYlEYSvVi"]) + .to_record(&Default::default()) + .expect("record"); + let ext = meta_feeder_sdk::hash::compute_ext_play_cid( + "https://open.spotify.com/artist/4tZwfgrHOc3mvqYlEYSvVi", + ) + .expect("mints"); + assert!( + rec.fields.contains_key(&format!("externalPlay/{ext}")), + "the link is emitted" + ); + // The record legitimately carries its OWN card locator under `cids/` — + // that is its address. What must never appear there is the ext-play + // locator, which would make a Spotify link a candidate for the + // canonical election alongside it. + assert!( + !rec.fields.contains_key(&format!("cids/{ext}")), + "an ext-play locator must never be a cid the work can be addressed by" + ); + assert_eq!( + rec.fields.keys().filter(|k| k.starts_with("cids/")).count(), + 1, + "exactly one cid: the card locator" + ); + } + + #[test] + fn a_card_with_no_links_emits_no_key_set() { + let rec = artist_card_with_links(&[]).to_record(&Default::default()).expect("record"); + assert!(!rec.fields.keys().any(|k| k.starts_with("externalPlay/"))); + } + + /// Non-http payloads must never mint a locator: the client hands this + /// string to an anchor or a new tab. + #[test] + fn a_non_http_link_is_dropped() { + let rec = artist_card_with_links(&["javascript:alert(1)"]) + .to_record(&Default::default()) + .expect("record"); + assert!(!rec.fields.keys().any(|k| k.starts_with("externalPlay/"))); + } +} diff --git a/feeder-plugin/musicbrainz-feeder/src/client.rs b/feeder-plugin/musicbrainz-feeder/src/client.rs new file mode 100644 index 0000000..ffdc22a --- /dev/null +++ b/feeder-plugin/musicbrainz-feeder/src/client.rs @@ -0,0 +1,840 @@ +//! MusicBrainz web-service v2 client. +//! +//! The music domain's TMDB. Every call is gated by a shared 1 req/s +//! [`RateBudget`] and every MBID-keyed response is cached permanently — an +//! MBID names an immutable entity, so there is nothing to revalidate. +//! +//! ## One call, complete cards +//! +//! The same principle the card feeder documents: a MusicBrainz **search or +//! browse response already carries everything a renderable card needs** — +//! `id`, `title`, `first-release-date`, and the `artist-credit` (with the +//! artist's own MBID). So a browse row costs *one* request, not one plus +//! twenty by-id lookups. +//! +//! That matters far more here than it does for TMDB. At 1 req/s a +//! per-item detail fetch would make a 20-card row take twenty seconds, which +//! is past every consumer-side idle cutoff — the row would come back empty, +//! not slow. The price is a thinner card (no track count, no label), and those +//! are exactly the fields nobody reads until the card is clicked, at which +//! point [`MusicBrainzClient::tracklist`] fetches them. + +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use crate::consts::{ + MB_API_BASE, MB_PAGE_SIZE, MB_RATE_LIMIT_DEFAULT_RETRY_SECS, MB_TIMEOUT_SECS, USER_AGENT, +}; +use meta_feeder_sdk::budget::{Lease, RateBudget}; + +// --------------------------------------------------------------------------- +// Wire shapes +// --------------------------------------------------------------------------- + +/// An artist credit entry. MusicBrainz models a credit as an ordered list of +/// `{artist, joinphrase}` so `"Miles Davis feat. John Coltrane"` round-trips; +/// the flattened display string is rebuilt by [`ArtistCredit::render`]. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ArtistCredit { + #[serde(default)] + pub name: String, + #[serde(default)] + pub joinphrase: String, + #[serde(default)] + pub artist: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct CreditedArtist { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, +} + +/// Flatten a credit list into its display string, honouring join phrases. +pub fn render_credit(credits: &[ArtistCredit]) -> String { + let mut out = String::new(); + for c in credits { + out.push_str(&c.name); + out.push_str(&c.joinphrase); + } + out.trim().to_string() +} + +/// The **primary** artist of a credit list — the first entry with a resolved +/// artist MBID. This is what an album card is filed under; the full rendered +/// credit is what gets displayed. +pub fn primary_artist(credits: &[ArtistCredit]) -> Option<&CreditedArtist> { + credits + .iter() + .filter_map(|c| c.artist.as_ref()) + .find(|a| !a.id.is_empty()) +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Tag { + #[serde(default)] + pub name: String, + #[serde(default)] + pub count: i64, +} + +/// A release-group: the **work**, an album across all its pressings. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ReleaseGroup { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + /// `Album`, `Single`, `EP`, `Broadcast`, `Other`. Absent on sparse entries. + #[serde(rename = "primary-type", default)] + pub primary_type: Option, + /// `Compilation`, `Live`, `Soundtrack`, `Remix`, … Multiple allowed. + #[serde(rename = "secondary-types", default)] + pub secondary_types: Vec, + /// `YYYY`, `YYYY-MM` or `YYYY-MM-DD` — MusicBrainz dates are + /// variable-precision and a consumer must not assume full ISO. + #[serde(rename = "first-release-date", default)] + pub first_release_date: Option, + #[serde(rename = "artist-credit", default)] + pub artist_credit: Vec, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub genres: Vec, + #[serde(default)] + pub disambiguation: Option, + /// Lucene relevance, 0–100, present only on *search* responses. + /// + /// ⚠ **Saturated, and therefore nearly useless alone.** Measured on + /// `query=kind of blue`: six release-groups scored exactly 100 — TQ, Swiss + /// Blues Authority, 大山百合香, Hellberg, Various Artists, and Miles Davis + /// *sixth*. An exact title match scores 100 whether the album is famous or + /// was uploaded once. See [`rank_release_groups`]. + #[serde(default)] + pub score: i32, + /// Number of releases (pressings, editions, reissues) in this group. + /// + /// **This is the popularity signal MusicBrainz does not otherwise have.** + /// A work that has been pressed 135 times is the one people mean; a work + /// with one release is somebody's upload. Measured on the same query: + /// Miles Davis 135, every other score-100 hit exactly 1. + #[serde(default)] + pub count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Artist { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + /// `Person`, `Group`, `Orchestra`, `Choir`, `Character`, `Other`. + #[serde(rename = "type", default)] + pub artist_type: Option, + #[serde(default)] + pub country: Option, + #[serde(default)] + pub disambiguation: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub genres: Vec, +} + +/// One track on a medium. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Track { + #[serde(default)] + pub id: String, + /// The printed track number — **a string, not an integer**: vinyl uses + /// `A1`/`B2` and classical releases use `1.i`. `position` is the reliable + /// ordinal. + #[serde(default)] + pub number: Option, + #[serde(default)] + pub position: u32, + #[serde(default)] + pub title: String, + /// Milliseconds. Absent on tracks nobody has timed. + #[serde(default)] + pub length: Option, + #[serde(default)] + pub recording: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Recording { + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub length: Option, + #[serde(default)] + pub isrcs: Vec, +} + +/// One physical/logical medium (a disc) within a release. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct Medium { + #[serde(default)] + pub position: u32, + #[serde(default)] + pub format: Option, + #[serde(rename = "track-count", default)] + pub track_count: u32, + #[serde(default)] + pub tracks: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct LabelInfo { + #[serde(rename = "catalog-number", default)] + pub catalog_number: Option, + #[serde(default)] + pub label: Option