diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a6b07d4f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +config +data +library +docs +python/.pytest_cache +python/dist +python/tests +**/__pycache__ +**/*.pyc diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml new file mode 100644 index 00000000..f4c4b3ec --- /dev/null +++ b/.github/workflows/python.yml @@ -0,0 +1,47 @@ +name: Python + +on: + push: + branches: ["main", "agent/python-migration-beta"] + tags: ["v*.*.*"] + pull_request: + branches: ["main"] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: python/pyproject.toml + - name: Install + run: python -m pip install -e "./python[dev]" + - name: Lint + run: | + ruff check python/src python/tests + ruff format --check python/src python/tests + - name: Test + run: python -m pytest -q python/tests + - name: Compile + run: python -m compileall -q python/src + + wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install build + - run: python -m build python + - uses: actions/upload-artifact@v4 + with: + name: python-package + path: python/dist/* diff --git a/.github/workflows/windows-publish.yml b/.github/workflows/windows-publish.yml deleted file mode 100644 index b97e2f75..00000000 --- a/.github/workflows/windows-publish.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Windows - -on: - push: - branches: [ "main", "windows" ] - # Publish semver tags as releases. - tags: [ 'v*.*.*' ] - pull_request: - branches: [ "main" ] - -permissions: - contents: write - -jobs: - build-and-upload: - name: Build and upload - runs-on: ${{ matrix.os }} - - strategy: - matrix: - include: - - build: windows-msvc - os: windows-latest - target: x86_64-pc-windows-msvc - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - uses: shogo82148/actions-setup-perl@v1 - with: - perl-version: "5.32" - distribution: strawberry - - - name: Test - run: cargo test -p mlm - - - name: Build - run: | - cargo install cargo-packager - cargo packager --release - - - name: Prepare files - shell: bash - run: | - ls -al target - ls -al target/* - ls -al target/*/* - binary_name="mlm" - version="${GITHUB_REF#refs/tags/v}" - version="${version#refs/heads/}" - app="$binary_name-$version.exe" - installer="$binary_name-$version-x64-setup.exe" - echo "APP=$app" >> $GITHUB_ENV - echo "INSTALLER=$installer" >> $GITHUB_ENV - mv "target/release/${binary_name}_"*"-setup.exe" "$installer" - mv "target/release/$binary_name.exe" "$app" - - - name: Upload app - uses: actions/upload-artifact@v4 - with: - name: ${{ env.APP }} - path: ${{ env.APP }} - - - name: Upload installer - uses: actions/upload-artifact@v4 - with: - name: ${{ env.INSTALLER }} - path: ${{ env.INSTALLER }} - - - name: Create release - if: github.ref_type == 'tag' - uses: softprops/action-gh-release@v2 - with: - files: | - ${{ env.INSTALLER }} diff --git a/.gitignore b/.gitignore index 74d73b8a..b14812c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,24 @@ /target +/target-*/ node_modules docs/book config.toml data.db data.db.* +__pycache__/ +.pytest_cache/ +*.py[cod] +*.egg-info/ +dist/ +.venv/ + + +# Local legacy-export sources retained while the Python-only branch is prepared. +/Cargo.toml +/Cargo.lock +/mlm_db/ +/mlm_mam/ +/mlm_parse/ +/server/ +/package.json +/pnpm-lock.yaml diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index a9f7bb6d..00000000 --- a/Cargo.lock +++ /dev/null @@ -1,3880 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "askama" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" -dependencies = [ - "askama_derive", - "itoa", - "percent-encoding", - "serde", - "serde_json", -] - -[[package]] -name = "askama_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" -dependencies = [ - "askama_parser", - "basic-toml", - "memchr", - "proc-macro2", - "pulldown-cmark 0.13.0", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn", -] - -[[package]] -name = "askama_parser" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" -dependencies = [ - "memchr", - "serde", - "serde_derive", - "winnow", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "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.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-extra" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" -dependencies = [ - "axum", - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "serde", - "serde_html_form", - "serde_path_to_error", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "basic-toml" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" -dependencies = [ - "serde", -] - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[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.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytemuck" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "camino" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", -] - -[[package]] -name = "cc" -version = "1.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" -dependencies = [ - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation", - "core-graphics", - "foreign-types 0.5.0", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation", - "core-graphics-types", - "libc", - "objc", -] - -[[package]] -name = "conv" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ff10625fd0ac447827aa30ea8b861fead473bb60aeb73af6c1c58caf0d1299" -dependencies = [ - "custom_derive", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "percent-encoding", - "time", - "version_check", -] - -[[package]] -name = "cookie_store" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" -dependencies = [ - "cookie", - "document-features", - "idna", - "log", - "publicsuffix", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "custom_derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8ae57c4978a2acd8b869ce6b9ca1dfe817bff704c220209fdef2c0b75a01b9" - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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 = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.60.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" -dependencies = [ - "litrs", -] - -[[package]] -name = "dtoa" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "ego-tree" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embed-resource" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.2", - "vswhom", - "winreg", -] - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "error-chain" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "figment" -version = "0.10.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" -dependencies = [ - "atomic", - "pear", - "serde", - "toml 0.8.23", - "uncased", - "version_check", -] - -[[package]] -name = "file-id" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[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 = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[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 = "getopts" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "h2" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever", - "match_token", -] - -[[package]] -name = "htmlentity" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd54ae4f69adcc1a43637dcff230852832c3ad50df31a90e0cb5f001dd441359" -dependencies = [ - "anyhow", - "lazy_static", - "thiserror 1.0.69", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[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.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.5.10", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lava_torrent" -version = "0.11.1" -source = "git+https://github.com/StirlingMouse/lava_torrent.git#9d8ff9304cc7419c6b6b6274daca653b629f0ace" -dependencies = [ - "conv", - "itertools 0.10.5", - "num_cpus", - "percent-encoding", - "rayon", - "sha1", - "thiserror 1.0.69", -] - -[[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.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - -[[package]] -name = "libredox" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360e552c93fa0e8152ab463bc4c4837fce76a225df11dfaeea66c313de5e61f7" -dependencies = [ - "bitflags 2.9.1", - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "litrs" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "matchr" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec453904a69e1e27cbfdf87a1759aa1f1351593b3730f6da3ac35438b9b4d7e" - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "mlm" -version = "0.4.9" -dependencies = [ - "anyhow", - "askama", - "axum", - "axum-extra", - "bytes", - "cookie", - "dirs", - "embed-resource", - "figment", - "file-id", - "futures", - "htmlentity", - "itertools 0.14.0", - "lava_torrent", - "log", - "matchr", - "mlm_db", - "mlm_mam", - "mlm_parse", - "native_db", - "native_model", - "once_cell", - "open", - "openssl", - "qbit", - "quick-xml", - "regex", - "reqwest", - "reqwest_cookie_store", - "sanitize-filename", - "scraper", - "serde", - "serde-nested-json", - "serde_derive", - "serde_json", - "sublime_fuzzy", - "thiserror 2.0.17", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "toml 0.8.23", - "tower", - "tower-http", - "tracing", - "tracing-appender", - "tracing-panic", - "tracing-subscriber", - "tray-item", - "unidecode", - "urlencoding", - "uuid", - "winsafe", -] - -[[package]] -name = "mlm_db" -version = "0.1.0" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "matchr", - "mlm_parse", - "native_db", - "native_model", - "nom", - "once_cell", - "regex", - "serde", - "serde_derive", - "time", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "mlm_mam" -version = "0.1.0" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "itertools 0.14.0", - "mlm_db", - "mlm_parse", - "native_db", - "native_model", - "once_cell", - "reqwest", - "reqwest_cookie_store", - "serde", - "serde-nested-json", - "serde_derive", - "serde_json", - "thiserror 2.0.17", - "time", - "tokio", - "tracing", -] - -[[package]] -name = "mlm_parse" -version = "0.1.0" -dependencies = [ - "anyhow", - "htmlentity", - "once_cell", - "regex", - "unidecode", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "native_db" -version = "0.8.2" -source = "git+https://github.com/StirlingMouse/native_db.git?branch=0.8.x#cddaafe0f6863146fd0879cae88fdb613bc50b47" -dependencies = [ - "native_db_macro", - "native_model", - "redb 1.5.1", - "redb 2.6.3", - "semver", - "serde", - "skeptic", - "thiserror 1.0.69", -] - -[[package]] -name = "native_db_macro" -version = "0.8.2" -source = "git+https://github.com/StirlingMouse/native_db.git?branch=0.8.x#cddaafe0f6863146fd0879cae88fdb613bc50b47" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "native_model" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c10f4542302b7fa69ef18b49d93106e27f20b59d695555121d9ed22fe5d716a8" -dependencies = [ - "anyhow", - "bincode", - "native_model_macro", - "serde", - "skeptic", - "thiserror 1.0.69", - "zerocopy", -] - -[[package]] -name = "native_model_macro" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f385f3d57adaea8d8868e65a0bc821bcb8ba2228bbf87a1c3c6144ac48f3791" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[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 = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", -] - -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "open" -version = "5.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-src" -version = "300.5.1+3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "padlock" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c10569378a1dacd9f30dbe7ae49e054d2c45dc2f8ee49899903e09c3924e8b6f" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - -[[package]] -name = "psl-types" -version = "2.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" - -[[package]] -name = "publicsuffix" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" -dependencies = [ - "idna", - "psl-types", -] - -[[package]] -name = "pulldown-cmark" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" -dependencies = [ - "bitflags 2.9.1", - "memchr", - "unicase", -] - -[[package]] -name = "pulldown-cmark" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" -dependencies = [ - "bitflags 2.9.1", - "memchr", - "unicase", -] - -[[package]] -name = "qbit" -version = "0.2.0" -source = "git+https://github.com/CampingAvocado/qbittorrent-webui-api.git#f5e037370008324a6d1dc7c07d4e5e7b52d2c726" -dependencies = [ - "bytes", - "derive_builder", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "tokio", - "url", -] - -[[package]] -name = "quick-xml" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redb" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd7f82ecd6ba647a39dd1a7172b8a1cd9453c0adee6da20cb553d83a9a460fa5" -dependencies = [ - "libc", -] - -[[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.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "redox_users" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.17", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.13", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "reqwest" -version = "0.12.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" -dependencies = [ - "base64", - "bytes", - "cookie", - "cookie_store", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "reqwest_cookie_store" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9" -dependencies = [ - "bytes", - "cookie_store", - "reqwest", - "url", -] - -[[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.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" -dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls" -version = "0.23.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "sanitize-filename" -version = "0.7.0-beta" -source = "git+https://github.com/StirlingMouse/sanitize-filename.git#2861a903d9e79d5cb2f613af4a282ce8a336b6e9" - -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scraper" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527e65d9d888567588db4c12da1087598d0f6f8b346cc2c5abc91f05fc2dffe2" -dependencies = [ - "cssparser", - "ego-tree", - "getopts", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" -dependencies = [ - "bitflags 2.9.1", - "cssparser", - "derive_more", - "fxhash", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" -dependencies = [ - "serde", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-nested-json" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4668ab4f388de185ef4de91b06ac01d52b0eedb5778cf4bcf11763d14a955b81" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_html_form" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" -dependencies = [ - "form_urlencoded", - "indexmap", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" -dependencies = [ - "itoa", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" -dependencies = [ - "serde", -] - -[[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 = "servo_arc" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "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 = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" -dependencies = [ - "libc", -] - -[[package]] -name = "siphasher" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" - -[[package]] -name = "skeptic" -version = "0.13.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" -dependencies = [ - "bytecount", - "cargo_metadata", - "error-chain", - "glob", - "pulldown-cmark 0.9.6", - "tempfile", - "walkdir", -] - -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "sublime_fuzzy" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7986063f7c0ab374407e586d7048a3d5aac94f103f751088bf398e07cd5400" - -[[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.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.9.1", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" -dependencies = [ - "fastrand", - "getrandom 0.3.3", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[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.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[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", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" - -[[package]] -name = "time-macros" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "slab", - "socket2 0.6.0", - "tokio-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - -[[package]] -name = "tokio-util" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit", -] - -[[package]] -name = "toml" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "toml_writer" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags 2.9.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "iri-string", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[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.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" -dependencies = [ - "crossbeam-channel", - "thiserror 1.0.69", - "time", - "tracing-subscriber", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -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-panic" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bf1298a179837099f9309243af3b554e840f7f67f65e9f55294913299bd4cc5" -dependencies = [ - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "time", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "tray-item" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59d4bd406170690dc30eabb3badc67a085beaf9b2c3b1923afcc9c26a2191353" -dependencies = [ - "cocoa", - "core-graphics", - "libc", - "objc", - "objc-foundation", - "objc_id", - "padlock", - "windows-sys 0.52.0", -] - -[[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.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unicode-width" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" - -[[package]] -name = "unidecode" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402bb19d8e03f1d1a7450e2bd613980869438e0666331be3e073089124aa1adc" - -[[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.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" -dependencies = [ - "getrandom 0.3.3", - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[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 = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-registry" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - -[[package]] -name = "winnow" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "winsafe" -version = "0.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c46f2d48eb39642e0f57a74637e041c69af704c6a8afaab03558201f64d8870" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index d0cbb5c4..00000000 --- a/Cargo.toml +++ /dev/null @@ -1,4 +0,0 @@ -[workspace] -resolver = "3" -members = ["server", "mlm_db", "mlm_parse", "mlm_mam"] - diff --git a/Dockerfile b/Dockerfile index 71bbc8cd..f4b8969d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,55 +1,16 @@ -# syntax=docker/dockerfile:1.3-labs +FROM python:3.13-slim -# The above line is so we can use can use heredocs in Dockerfiles. No more && and \! -# https://www.docker.com/blog/introduction-to-heredocs-in-dockerfiles/ +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + MLM_CONFIG_FILE=/config/config.toml \ + MLM_DB_FILE=/data/data.sqlite3 -FROM rust:1.91 AS build - -RUN cargo new --lib app/mlm_db -RUN cargo new --lib app/mlm_mam -RUN cargo new --lib app/mlm_parse -RUN cargo new --bin app/server - -# Capture dependencies -COPY Cargo.toml Cargo.lock /app/ -COPY mlm_db/Cargo.toml /app/mlm_db/ -COPY mlm_mam/Cargo.toml /app/mlm_mam/ -COPY mlm_parse/Cargo.toml /app/mlm_parse/ -COPY server/Cargo.toml /app/server/ - -# This step compiles only our dependencies and saves them in a layer. This is the most impactful time savings -# Note the use of --mount=type=cache. On subsequent runs, we'll have the crates already downloaded WORKDIR /app -RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked cargo build --release - -# Copy our sources -COPY ./mlm_db /app/mlm_db -COPY ./mlm_mam /app/mlm_mam -COPY ./mlm_parse /app/mlm_parse -COPY ./server /app/server - -# A bit of magic here! -# * We're mounting that cache again to use during the build, otherwise it's not present and we'll have to download those again - bad! -# * EOF syntax is neat but not without its drawbacks. We need to `set -e`, otherwise a failing command is going to continue on -# * Rust here is a bit fiddly, so we'll touch the files (even though we copied over them) to force a new build -RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked < +Install Python 3.11 or newer, clone this repository, then run: -After starting MLM a small mouse icon will be placed in your tray icons. From here you can access the Web UI, the config file, and the logs directory. +```powershell +python -m pip install -e .\python +mlm-python run --config .\config.toml --database .\data.sqlite3 +``` + +Open for the web UI. If using the Windows qBittorrent version, also remember to enable its Web UI under settings. @@ -17,3 +21,6 @@ password = "qbittorent password" ``` Make sure the port number (8080) matches the port configured in qBittorrent, as well as the username and password. Or leave those out if "Bypass authentication for clients on localhost" is checked. + +For migration from the old application database, see +[`python/README.md`](../../python/README.md). diff --git a/mlm_db/Cargo.toml b/mlm_db/Cargo.toml deleted file mode 100644 index a39f6887..00000000 --- a/mlm_db/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "mlm_db" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0.100" -itertools = "0.14.0" -# native_db = "0.8.2" -native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } -native_model = "0.4.20" -matchr = "0.2.5" -mlm_parse = { path = "../mlm_parse" } -nom = "8.0.0" -once_cell = "1.21.3" -regex = "1.12.2" -serde = "1.0.136" -serde_derive = "1.0.136" -time = { version = "0.3.41", features = [ - "formatting", - "local-offset", - "macros", - "serde", -] } -tokio = { version = "1.45.1", features = ["full"] } -tracing = "0.1" -uuid = { version = "1.17.0", features = ["serde", "v4"] } diff --git a/mlm_db/src/impls.rs b/mlm_db/src/impls.rs deleted file mode 100644 index 433ea964..00000000 --- a/mlm_db/src/impls.rs +++ /dev/null @@ -1,170 +0,0 @@ -pub mod categories; -pub mod flags; -pub mod language; -pub mod meta; -pub mod old_categories; -pub mod series; -pub mod size; - -use std::{fmt, str::FromStr}; - -use matchr::score; -use serde::{Deserialize, Deserializer}; -use time::UtcDateTime; - -use crate::{ - Event, EventType, ListItem, OldDbMainCat, Series, Timestamp, Torrent, TorrentCost, TorrentMeta, - TorrentStatus, Uuid, VipStatus, -}; - -pub fn parse<'de, T, D>(deserializer: D) -> Result -where - T: FromStr, - D: Deserializer<'de>, -{ - let v: String = Deserialize::deserialize(deserializer)?; - v.parse().map_err(serde::de::Error::custom) -} - -pub fn parse_opt<'de, T, D>(deserializer: D) -> Result, D::Error> -where - T: FromStr, - D: Deserializer<'de>, -{ - let v: Option = Deserialize::deserialize(deserializer)?; - v.map(|v| v.parse().map_err(serde::de::Error::custom)) - .transpose() -} - -pub fn parse_vec<'de, T, D>(deserializer: D) -> Result, D::Error> -where - T: FromStr, - D: Deserializer<'de>, -{ - let v: Vec = Deserialize::deserialize(deserializer)?; - v.into_iter() - .map(|v| v.parse().map_err(serde::de::Error::custom)) - .collect() -} - -impl Torrent { - pub fn matches(&self, other: &Torrent) -> bool { - // if self.hash == other.hash { return true }; - if self.title_search != other.title_search { - return false; - }; - self.meta.matches(&other.meta) - } -} - -impl Event { - pub fn new(torrent_id: Option, mam_id: Option, event: EventType) -> Self { - Self { - id: Uuid::new(), - torrent_id, - mam_id, - created_at: Timestamp::now(), - event, - } - } -} - -impl ListItem { - pub fn want_audio(&self) -> bool { - if self.marked_done_at.is_some() { - return false; - } - let have_audio = self - .audio_torrent - .as_ref() - .is_some_and(|t| t.status != TorrentStatus::Wanted); - let have_ebook = self - .ebook_torrent - .as_ref() - .is_some_and(|t| t.status != TorrentStatus::Wanted); - - self.allow_audio - && !have_audio - && self - .prefer_format - .is_none_or(|f| f == OldDbMainCat::Audio || !have_ebook) - } - - pub fn want_ebook(&self) -> bool { - if self.marked_done_at.is_some() { - return false; - } - let have_audio = self - .audio_torrent - .as_ref() - .is_some_and(|t| t.status != TorrentStatus::Wanted); - let have_ebook = self - .ebook_torrent - .as_ref() - .is_some_and(|t| t.status != TorrentStatus::Wanted); - - self.allow_ebook - && !have_ebook - && self - .prefer_format - .is_none_or(|f| f == OldDbMainCat::Ebook || !have_audio) - } - - pub fn matches(&self, meta: &TorrentMeta) -> bool { - if score(&self.title, &meta.title) < 80 { - return false; - } - - let authors = self - .authors - .iter() - .map(|a| a.to_lowercase()) - .collect::>(); - - meta.authors - .iter() - .map(|a| a.to_lowercase()) - .any(|a| authors.iter().any(|b| score(b, &a) > 90)) - } -} - -impl VipStatus { - pub fn is_vip(&self) -> bool { - match self { - VipStatus::NotVip => false, - VipStatus::Permanent => true, - VipStatus::Temp(date) => date > &UtcDateTime::now().date(), - } - } -} - -impl TorrentCost { - pub fn as_str(&self) -> &'static str { - match self { - TorrentCost::GlobalFreeleech => "free", - TorrentCost::PersonalFreeleech => "PF", - TorrentCost::Vip => "VIP", - TorrentCost::UseWedge => "wedge", - TorrentCost::TryWedge => "try wedge", - TorrentCost::Ratio => "ratio", - } - } -} - -impl fmt::Display for VipStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - VipStatus::NotVip => write!(f, "not VIP"), - VipStatus::Permanent => write!(f, "VIP"), - VipStatus::Temp(date) => write!(f, "VIP (expires {date})"), - } - } -} - -pub fn format_serie(series: &Series) -> String { - if series.entries.0.is_empty() { - series.name.clone() - } else { - format!("{} #{}", series.name, series.entries) - } -} diff --git a/mlm_db/src/impls/categories.rs b/mlm_db/src/impls/categories.rs deleted file mode 100644 index c2f714e1..00000000 --- a/mlm_db/src/impls/categories.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::str::FromStr; - -use crate::{Category, MainCat, MediaType, OldMainCat}; - -impl MediaType { - pub fn from_id(id: u8) -> Option { - match id { - 1 => Some(MediaType::Audiobook), - 2 => Some(MediaType::Ebook), - 3 => Some(MediaType::Musicology), - 4 => Some(MediaType::Radio), - 5 => Some(MediaType::Manga), - 6 => Some(MediaType::ComicBook), - 7 => Some(MediaType::PeriodicalEbook), - 8 => Some(MediaType::PeriodicalAudiobook), - _ => None, - } - } - - pub fn from_main_cat_id(main_cat: u8) -> Option { - match main_cat { - 13 => Some(MediaType::Audiobook), - 14 => Some(MediaType::Ebook), - 15 => Some(MediaType::Musicology), - 16 => Some(MediaType::Radio), - _ => None, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - MediaType::Audiobook => "Audiobook", - MediaType::Ebook => "Ebook", - MediaType::Musicology => "Musicology", - MediaType::Radio => "Radio", - MediaType::Manga => "Manga", - MediaType::ComicBook => "Comic Book / Graphic Novel", - MediaType::PeriodicalEbook => "Periodical Ebook", - MediaType::PeriodicalAudiobook => "Periodical Audiobook", - } - } - - #[allow(dead_code)] - pub fn as_id(&self) -> u8 { - match self { - MediaType::Audiobook => 1, - MediaType::Ebook => 2, - MediaType::Musicology => 3, - MediaType::Radio => 4, - MediaType::Manga => 5, - MediaType::ComicBook => 6, - MediaType::PeriodicalEbook => 7, - MediaType::PeriodicalAudiobook => 8, - } - } -} - -impl std::fmt::Display for MediaType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl FromStr for MediaType { - type Err = String; - - fn from_str(value: &str) -> Result { - match value.to_lowercase().as_str() { - "audiobook" => Ok(MediaType::Audiobook), - "ebook" => Ok(MediaType::Ebook), - "musicology" => Ok(MediaType::Musicology), - "radio" => Ok(MediaType::Radio), - "manga" => Ok(MediaType::Manga), - "comic book / graphic novel" => Ok(MediaType::ComicBook), - "comic book" => Ok(MediaType::ComicBook), - "comics" => Ok(MediaType::ComicBook), - "graphic novel" => Ok(MediaType::ComicBook), - "periodical_ebook" => Ok(MediaType::PeriodicalEbook), - "periodical ebook" => Ok(MediaType::PeriodicalEbook), - "periodical_audiobook" => Ok(MediaType::PeriodicalAudiobook), - "periodical audiobook" => Ok(MediaType::PeriodicalAudiobook), - _ => Err(format!("Unknown media type: {}", value)), - } - } -} - -impl From for MediaType { - fn from(value: OldMainCat) -> Self { - match value { - OldMainCat::Audio => MediaType::Audiobook, - OldMainCat::Ebook => MediaType::Ebook, - OldMainCat::Musicology => MediaType::Musicology, - OldMainCat::Radio => MediaType::Radio, - } - } -} - -impl MainCat { - pub fn from_id(id: u8) -> Option { - match id { - 1 => Some(MainCat::Fiction), - 2 => Some(MainCat::Nonfiction), - _ => None, - } - } - - pub fn as_str(&self) -> &'static str { - match self { - MainCat::Fiction => "Fiction", - MainCat::Nonfiction => "Nonfiction", - } - } - - #[allow(dead_code)] - pub fn as_id(&self) -> u8 { - match self { - MainCat::Fiction => 1, - MainCat::Nonfiction => 2, - } - } -} - -impl std::fmt::Display for MainCat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl Category { - pub fn from_id(id: u8) -> Option { - match id { - 1 => Some(Category::Action), - 2 => Some(Category::Art), - 3 => Some(Category::Biographical), - 4 => Some(Category::Business), - 5 => Some(Category::Comedy), - 6 => Some(Category::CompleteEditionsMusic), - 7 => Some(Category::Computer), - 8 => Some(Category::Crafts), - 9 => Some(Category::Crime), - 10 => Some(Category::Dramatization), - 11 => Some(Category::Education), - 12 => Some(Category::FactualNews), - 13 => Some(Category::Fantasy), - 14 => Some(Category::Food), - 15 => Some(Category::Guitar), - 16 => Some(Category::Health), - 17 => Some(Category::Historical), - 18 => Some(Category::Home), - 19 => Some(Category::Horror), - 20 => Some(Category::Humor), - 21 => Some(Category::IndividualSheet), - 22 => Some(Category::Instructional), - 23 => Some(Category::Juvenile), - 24 => Some(Category::Language), - 25 => Some(Category::Lgbt), - 26 => Some(Category::LickLibraryLTP), - 27 => Some(Category::LickLibraryTechniques), - 28 => Some(Category::LiteraryClassics), - 29 => Some(Category::LitRPG), - 30 => Some(Category::Math), - 31 => Some(Category::Medicine), - 32 => Some(Category::Music), - 33 => Some(Category::MusicBook), - 34 => Some(Category::Mystery), - 35 => Some(Category::Nature), - 36 => Some(Category::Paranormal), - 37 => Some(Category::Philosophy), - 38 => Some(Category::Poetry), - 39 => Some(Category::Politics), - 40 => Some(Category::Reference), - 41 => Some(Category::Religion), - 42 => Some(Category::Romance), - 43 => Some(Category::Rpg), - 44 => Some(Category::Science), - 45 => Some(Category::ScienceFiction), - 46 => Some(Category::SelfHelp), - 47 => Some(Category::SheetCollection), - 48 => Some(Category::SheetCollectionMP3), - 49 => Some(Category::Sports), - 50 => Some(Category::Technology), - 51 => Some(Category::Thriller), - 52 => Some(Category::Travel), - 53 => Some(Category::UrbanFantasy), - 54 => Some(Category::Western), - 55 => Some(Category::YoungAdult), - 56 => Some(Category::Superheroes), - 57 => Some(Category::LiteraryFiction), - 58 => Some(Category::ProgressionFantasy), - 59 => Some(Category::ContemporaryFiction), - 60 => Some(Category::DramaPlays), - id => Some(Category::Unknown(id)), - } - } - - pub fn as_str(&self) -> &'static str { - match self { - // Since there is no replacement for general fiction, literary fiction is being used - // instead. - Category::LiteraryFiction => "General Fiction", - Category::ContemporaryFiction => "Contemporary", - _ => self.as_raw_str(), - } - } - - pub fn as_raw_str(&self) -> &'static str { - match self { - Category::Action => "Action/Adventure", - Category::Art => "Art/Photography", - Category::Biographical => "Biographical", - Category::Business => "Business/Money", - Category::Comedy => "Comedy", - Category::CompleteEditionsMusic => "Complete Editions - Music", - Category::Computer => "Computer/Internet", - Category::Crafts => "Crafts", - Category::Crime => "Crime", - Category::Dramatization => "Dramatization/Full Cast", - Category::Education => "Education/Textbook", - Category::FactualNews => "Factual News/Current Events", - Category::Fantasy => "Fantasy", - Category::Food => "Food/Wine", - Category::Guitar => "Guitar/Bass Tabs", - Category::Health => "Health/Fitness/Diet", - Category::Historical => "Historical", - Category::Home => "Home/Garden", - Category::Horror => "Horror", - Category::Humor => "Humor", - Category::IndividualSheet => "Individual Sheet", - Category::Instructional => "Instructional", - Category::Juvenile => "Juvenile", - Category::Language => "Language", - Category::Lgbt => "LGBTQIA+", - Category::LickLibraryLTP => "Lick Library - LTP/Jam With", - Category::LickLibraryTechniques => "Lick Library - Techniques/QL", - Category::LiteraryClassics => "Literary Classics", - Category::LitRPG => "LitRPG", - Category::Math => "Math", - Category::Medicine => "Medicine/Psychology", - Category::Music => "Music", - Category::MusicBook => "Music Book", - Category::Mystery => "Mystery", - Category::Nature => "Nature", - Category::Paranormal => "Paranormal", - Category::Philosophy => "Philosophy", - Category::Poetry => "Poetry", - Category::Politics => "Politics/Sociology", - Category::Reference => "Reference", - Category::Religion => "Religion/Spirituality", - Category::Romance => "Romance", - Category::Rpg => "RPG", - Category::Science => "Science", - Category::ScienceFiction => "Science Fiction", - Category::SelfHelp => "Self-Help", - Category::SheetCollection => "Sheet Collection", - Category::SheetCollectionMP3 => "Sheet Collection MP3", - Category::Sports => "Sports/Hobbies", - Category::Technology => "Technology", - Category::Thriller => "Thriller/Suspense", - Category::Travel => "Travel", - Category::UrbanFantasy => "Urban Fantasy", - Category::Western => "Western", - Category::YoungAdult => "Young Adult", - Category::Superheroes => "Superheroes", - Category::LiteraryFiction => "Literary Fiction", - Category::ProgressionFantasy => "Progression Fantasy", - Category::ContemporaryFiction => "Contemporary Fiction", - Category::DramaPlays => "Drama/Plays", - Category::Unknown(61) => "Occult / Metaphysical Practices", - Category::Unknown(62) => "Slice of Life", - Category::Unknown(63) => "Unknown Category (id: 63)", - Category::Unknown(64) => "Unknown Category (id: 64)", - Category::Unknown(65) => "Unknown Category (id: 65)", - Category::Unknown(66) => "Unknown Category (id: 66)", - Category::Unknown(67) => "Unknown Category (id: 67)", - Category::Unknown(68) => "Unknown Category (id: 68)", - Category::Unknown(69) => "Unknown Category (id: 69)", - Category::Unknown(_) => "Unknown Category", - } - } - - #[allow(dead_code)] - pub fn as_id(&self) -> u8 { - match self { - Category::Action => 1, - Category::Art => 2, - Category::Biographical => 3, - Category::Business => 4, - Category::Comedy => 5, - Category::CompleteEditionsMusic => 6, - Category::Computer => 7, - Category::Crafts => 8, - Category::Crime => 9, - Category::Dramatization => 10, - Category::Education => 11, - Category::FactualNews => 12, - Category::Fantasy => 13, - Category::Food => 14, - Category::Guitar => 15, - Category::Health => 16, - Category::Historical => 17, - Category::Home => 18, - Category::Horror => 19, - Category::Humor => 20, - Category::IndividualSheet => 21, - Category::Instructional => 22, - Category::Juvenile => 23, - Category::Language => 24, - Category::Lgbt => 25, - Category::LickLibraryLTP => 26, - Category::LickLibraryTechniques => 27, - Category::LiteraryClassics => 28, - Category::LitRPG => 29, - Category::Math => 30, - Category::Medicine => 31, - Category::Music => 32, - Category::MusicBook => 33, - Category::Mystery => 34, - Category::Nature => 35, - Category::Paranormal => 36, - Category::Philosophy => 37, - Category::Poetry => 38, - Category::Politics => 39, - Category::Reference => 40, - Category::Religion => 41, - Category::Romance => 42, - Category::Rpg => 43, - Category::Science => 44, - Category::ScienceFiction => 45, - Category::SelfHelp => 46, - Category::SheetCollection => 47, - Category::SheetCollectionMP3 => 48, - Category::Sports => 49, - Category::Technology => 50, - Category::Thriller => 51, - Category::Travel => 52, - Category::UrbanFantasy => 53, - Category::Western => 54, - Category::YoungAdult => 55, - Category::Superheroes => 56, - Category::LiteraryFiction => 57, - Category::ProgressionFantasy => 58, - Category::ContemporaryFiction => 59, - Category::DramaPlays => 60, - Category::Unknown(id) => *id, - } - } -} - -impl std::fmt::Display for Category { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} diff --git a/mlm_db/src/impls/flags.rs b/mlm_db/src/impls/flags.rs deleted file mode 100644 index d2325579..00000000 --- a/mlm_db/src/impls/flags.rs +++ /dev/null @@ -1,142 +0,0 @@ -use std::collections::HashMap; - -use crate::{FlagBits, Flags}; - -impl Flags { - pub fn from_bitfield(field: u8) -> Flags { - Flags { - crude_language: Some(field & (1 << 1) > 0), - violence: Some(field & (1 << 2) > 0), - some_explicit: Some(field & (1 << 3) > 0), - explicit: Some(field & (1 << 4) > 0), - abridged: Some(field & (1 << 5) > 0), - lgbt: Some(field & (1 << 6) > 0), - } - } - - pub fn as_search_bitfield(&self) -> (bool, Vec) { - let shows = self.fields().filter(|f| f.unwrap_or(false)).count(); - let hides = self.fields().filter(|f| f.is_some_and(|f| !f)).count(); - let is_hide = hides > shows; - let mut field = vec![]; - if self.crude_language.is_some_and(|f| f != is_hide) { - field.push(1 << 1); - } - if self.violence.is_some_and(|f| f != is_hide) { - field.push(1 << 2); - } - if self.some_explicit.is_some_and(|f| f != is_hide) { - field.push(1 << 3); - } - if self.explicit.is_some_and(|f| f != is_hide) { - field.push(1 << 4); - } - if self.abridged.is_some_and(|f| f != is_hide) { - field.push(1 << 5); - } - if self.lgbt.is_some_and(|f| f != is_hide) { - field.push(1 << 6); - } - (is_hide, field) - } - - pub fn as_bitfield(&self) -> u8 { - let mut field = 0; - if self.crude_language.unwrap_or_default() { - field += 1 << 1; - } - if self.violence.unwrap_or_default() { - field += 1 << 2; - } - if self.some_explicit.unwrap_or_default() { - field += 1 << 3; - } - if self.explicit.unwrap_or_default() { - field += 1 << 4; - } - if self.abridged.unwrap_or_default() { - field += 1 << 5; - } - if self.lgbt.unwrap_or_default() { - field += 1 << 6; - } - field - } - - pub fn matches(&self, other: &Flags) -> bool { - self.fields() - .zip(other.fields()) - .all(|(t, o)| t.is_none_or(|_| t == o)) - } - - fn fields(&self) -> impl Iterator> { - [ - self.crude_language, - self.violence, - self.some_explicit, - self.explicit, - self.abridged, - self.lgbt, - ] - .into_iter() - } -} - -impl From for FlagBits { - fn from(value: Flags) -> Self { - FlagBits::new(value.as_bitfield()) - } -} - -impl From for Flags { - fn from(value: FlagBits) -> Self { - Flags::from_bitfield(value.0) - } -} -impl TryFrom> for Flags { - type Error = String; - - fn try_from(value: HashMap) -> Result { - let mut flags = Flags::default(); - for (key, value) in value.into_iter() { - match key.to_lowercase().as_str() { - "crude" => flags.crude_language = Some(value), - "language" => flags.crude_language = Some(value), - "crude language" => flags.crude_language = Some(value), - "violence" => flags.violence = Some(value), - "some explicit" => flags.some_explicit = Some(value), - "explicit" => flags.explicit = Some(value), - "abridged" => flags.abridged = Some(value), - "lgbt" => flags.lgbt = Some(value), - _ => return Err(format!("invalid flag {key}")), - } - } - Ok(flags) - } -} - -impl std::fmt::Display for Flags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut flags = vec![]; - if self.crude_language == Some(true) { - flags.push("crude language"); - } - if self.violence == Some(true) { - flags.push("violence"); - } - if self.some_explicit == Some(true) { - flags.push("some explicit"); - } - if self.explicit == Some(true) { - flags.push("explicit"); - } - if self.abridged == Some(true) { - flags.push("abridged"); - } - if self.lgbt == Some(true) { - flags.push("lgbt"); - } - write!(f, "{}", flags.join(", "))?; - Ok(()) - } -} diff --git a/mlm_db/src/impls/language.rs b/mlm_db/src/impls/language.rs deleted file mode 100644 index c979576b..00000000 --- a/mlm_db/src/impls/language.rs +++ /dev/null @@ -1,377 +0,0 @@ -use std::str::FromStr; - -use crate::Language; - -impl Language { - pub fn from_id(id: u8) -> Option { - match id { - 1 => Some(Language::English), - 17 => Some(Language::Afrikaans), - 32 => Some(Language::Arabic), - 35 => Some(Language::Bengali), - 51 => Some(Language::Bosnian), - 18 => Some(Language::Bulgarian), - 6 => Some(Language::Burmese), - 44 => Some(Language::Cantonese), - 19 => Some(Language::Catalan), - 2 => Some(Language::Chinese), - 49 => Some(Language::Croatian), - 20 => Some(Language::Czech), - 21 => Some(Language::Danish), - 22 => Some(Language::Dutch), - 61 => Some(Language::Estonian), - 39 => Some(Language::Farsi), - 23 => Some(Language::Finnish), - 36 => Some(Language::French), - 37 => Some(Language::German), - 26 => Some(Language::Greek), - 59 => Some(Language::GreekAncient), - 3 => Some(Language::Gujarati), - 27 => Some(Language::Hebrew), - 8 => Some(Language::Hindi), - 28 => Some(Language::Hungarian), - 63 => Some(Language::Icelandic), - 53 => Some(Language::Indonesian), - 56 => Some(Language::Irish), - 43 => Some(Language::Italian), - 38 => Some(Language::Japanese), - 12 => Some(Language::Javanese), - 5 => Some(Language::Kannada), - 41 => Some(Language::Korean), - 50 => Some(Language::Lithuanian), - 46 => Some(Language::Latin), - 62 => Some(Language::Latvian), - 33 => Some(Language::Malay), - 58 => Some(Language::Malayalam), - 57 => Some(Language::Manx), - 9 => Some(Language::Marathi), - 48 => Some(Language::Norwegian), - 45 => Some(Language::Polish), - 34 => Some(Language::Portuguese), - 52 => Some(Language::BrazilianPortuguese), - 14 => Some(Language::Punjabi), - 30 => Some(Language::Romanian), - 16 => Some(Language::Russian), - 24 => Some(Language::ScottishGaelic), - 60 => Some(Language::Sanskrit), - 31 => Some(Language::Serbian), - 54 => Some(Language::Slovenian), - 4 => Some(Language::Spanish), - 55 => Some(Language::CastilianSpanish), - 40 => Some(Language::Swedish), - 29 => Some(Language::Tagalog), - 11 => Some(Language::Tamil), - 10 => Some(Language::Telugu), - 7 => Some(Language::Thai), - 42 => Some(Language::Turkish), - 25 => Some(Language::Ukrainian), - 15 => Some(Language::Urdu), - 13 => Some(Language::Vietnamese), - 47 => Some(Language::Other), - _ => None, - } - } - - pub fn to_id(self) -> u8 { - match self { - Language::English => 1, - Language::Afrikaans => 17, - Language::Arabic => 32, - Language::Bengali => 35, - Language::Bosnian => 51, - Language::Bulgarian => 18, - Language::Burmese => 6, - Language::Cantonese => 44, - Language::Catalan => 19, - Language::Chinese => 2, - Language::Croatian => 49, - Language::Czech => 20, - Language::Danish => 21, - Language::Dutch => 22, - Language::Estonian => 61, - Language::Farsi => 39, - Language::Finnish => 23, - Language::French => 36, - Language::German => 37, - Language::Greek => 26, - Language::GreekAncient => 59, - Language::Gujarati => 3, - Language::Hebrew => 27, - Language::Hindi => 8, - Language::Hungarian => 28, - Language::Icelandic => 63, - Language::Indonesian => 53, - Language::Irish => 56, - Language::Italian => 43, - Language::Japanese => 38, - Language::Javanese => 12, - Language::Kannada => 5, - Language::Korean => 41, - Language::Lithuanian => 50, - Language::Latin => 46, - Language::Latvian => 62, - Language::Malay => 33, - Language::Malayalam => 58, - Language::Manx => 57, - Language::Marathi => 9, - Language::Norwegian => 48, - Language::Polish => 45, - Language::Portuguese => 34, - Language::BrazilianPortuguese => 52, - Language::Punjabi => 14, - Language::Romanian => 30, - Language::Russian => 16, - Language::ScottishGaelic => 24, - Language::Sanskrit => 60, - Language::Serbian => 31, - Language::Slovenian => 54, - Language::Spanish => 4, - Language::CastilianSpanish => 55, - Language::Swedish => 40, - Language::Tagalog => 29, - Language::Tamil => 11, - Language::Telugu => 10, - Language::Thai => 7, - Language::Turkish => 42, - Language::Ukrainian => 25, - Language::Urdu => 15, - Language::Vietnamese => 13, - Language::Other => 47, - } - } - - pub fn to_str(self) -> &'static str { - match self { - Language::English => "English", - Language::Afrikaans => "Afrikaans", - Language::Arabic => "Arabic", - Language::Bengali => "Bengali", - Language::Bosnian => "Bosnian", - Language::Bulgarian => "Bulgarian", - Language::Burmese => "Burmese", - Language::Cantonese => "Cantonese", - Language::Catalan => "Catalan", - Language::Chinese => "Chinese", - Language::Croatian => "Croatian", - Language::Czech => "Czech", - Language::Danish => "Danish", - Language::Dutch => "Dutch", - Language::Estonian => "Estonian", - Language::Farsi => "Farsi", - Language::Finnish => "Finnish", - Language::French => "French", - Language::German => "German", - Language::Greek => "Greek", - Language::GreekAncient => "Ancient Greek", - Language::Gujarati => "Gujarati", - Language::Hebrew => "Hebrew", - Language::Hindi => "Hindi", - Language::Hungarian => "Hungarian", - Language::Icelandic => "Icelandic", - Language::Indonesian => "Indonesian", - Language::Irish => "Irish", - Language::Italian => "Italian", - Language::Japanese => "Japanese", - Language::Javanese => "Javanese", - Language::Kannada => "Kannada", - Language::Korean => "Korean", - Language::Lithuanian => "Lithuanian", - Language::Latin => "Latin", - Language::Latvian => "Latvian", - Language::Malay => "Malay", - Language::Malayalam => "Malayalam", - Language::Manx => "Manx", - Language::Marathi => "Marathi", - Language::Norwegian => "Norwegian", - Language::Polish => "Polish", - Language::Portuguese => "Portuguese", - Language::BrazilianPortuguese => "Brazilian", - Language::Punjabi => "Punjabi", - Language::Romanian => "Romanian", - Language::Russian => "Russian", - Language::ScottishGaelic => "Scottish", - Language::Sanskrit => "Sanskrit", - Language::Serbian => "Serbian", - Language::Slovenian => "Slovenian", - Language::Spanish => "Spanish", - Language::CastilianSpanish => "Castilian", - Language::Swedish => "Swedish", - Language::Tagalog => "Tagalog", - Language::Tamil => "Tamil", - Language::Telugu => "Telugu", - Language::Thai => "Thai", - Language::Turkish => "Turkish", - Language::Ukrainian => "Ukrainian", - Language::Urdu => "Urdu", - Language::Vietnamese => "Vietnamese", - Language::Other => "Other", - } - } - - pub fn all() -> Vec { - vec![ - Language::English, - Language::Afrikaans, - Language::Arabic, - Language::Bengali, - Language::Bosnian, - Language::Bulgarian, - Language::Burmese, - Language::Cantonese, - Language::Catalan, - Language::Chinese, - Language::Croatian, - Language::Czech, - Language::Danish, - Language::Dutch, - Language::Estonian, - Language::Farsi, - Language::Finnish, - Language::French, - Language::German, - Language::Greek, - Language::GreekAncient, - Language::Gujarati, - Language::Hebrew, - Language::Hindi, - Language::Hungarian, - Language::Icelandic, - Language::Indonesian, - Language::Irish, - Language::Italian, - Language::Japanese, - Language::Javanese, - Language::Kannada, - Language::Korean, - Language::Lithuanian, - Language::Latin, - Language::Latvian, - Language::Malay, - Language::Malayalam, - Language::Manx, - Language::Marathi, - Language::Norwegian, - Language::Polish, - Language::Portuguese, - Language::BrazilianPortuguese, - Language::Punjabi, - Language::Romanian, - Language::Russian, - Language::ScottishGaelic, - Language::Sanskrit, - Language::Serbian, - Language::Slovenian, - Language::Spanish, - Language::CastilianSpanish, - Language::Swedish, - Language::Tagalog, - Language::Tamil, - Language::Telugu, - Language::Thai, - Language::Turkish, - Language::Ukrainian, - Language::Urdu, - Language::Vietnamese, - Language::Other, - ] - } -} - -impl std::fmt::Display for Language { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.to_str()) - } -} - -impl FromStr for Language { - type Err = String; - - fn from_str(value: &str) -> Result { - let l = match value.to_lowercase().as_str() { - "english" => Some(Language::English), - "afrikaans" => Some(Language::Afrikaans), - "arabic" => Some(Language::Arabic), - "bengali" => Some(Language::Bengali), - "bosnian" => Some(Language::Bosnian), - "bulgarian" => Some(Language::Bulgarian), - "burmese" => Some(Language::Burmese), - "cantonese" => Some(Language::Cantonese), - "catalan" => Some(Language::Catalan), - "chinese" => Some(Language::Chinese), - "croatian" => Some(Language::Croatian), - "czech" => Some(Language::Czech), - "danish" => Some(Language::Danish), - "dutch" => Some(Language::Dutch), - "estonian" => Some(Language::Estonian), - "farsi" => Some(Language::Farsi), - "finnish" => Some(Language::Finnish), - "french" => Some(Language::French), - "german" => Some(Language::German), - "greek" => Some(Language::Greek), - "ancient greek" => Some(Language::GreekAncient), - "greek ancient" => Some(Language::GreekAncient), - "greek, ancient" => Some(Language::GreekAncient), - "gujarati" => Some(Language::Gujarati), - "hebrew" => Some(Language::Hebrew), - "hindi" => Some(Language::Hindi), - "hungarian" => Some(Language::Hungarian), - "icelandic" => Some(Language::Icelandic), - "indonesian" => Some(Language::Indonesian), - "irish" => Some(Language::Irish), - "italian" => Some(Language::Italian), - "japanese" => Some(Language::Japanese), - "javanese" => Some(Language::Javanese), - "kannada" => Some(Language::Kannada), - "korean" => Some(Language::Korean), - "lithuanian" => Some(Language::Lithuanian), - "latin" => Some(Language::Latin), - "latvian" => Some(Language::Latvian), - "malay" => Some(Language::Malay), - "malayalam" => Some(Language::Malayalam), - "manx" => Some(Language::Manx), - "marathi" => Some(Language::Marathi), - "norwegian" => Some(Language::Norwegian), - "polish" => Some(Language::Polish), - "portuguese" => Some(Language::Portuguese), - "bp" => Some(Language::BrazilianPortuguese), - "brazilian" => Some(Language::BrazilianPortuguese), - "brazilian portuguese" => Some(Language::BrazilianPortuguese), - "brazilian portuguese (bp)" => Some(Language::BrazilianPortuguese), - "punjabi" => Some(Language::Punjabi), - "romanian" => Some(Language::Romanian), - "russian" => Some(Language::Russian), - "scottish" => Some(Language::ScottishGaelic), - "scottish gaelic" => Some(Language::ScottishGaelic), - "gaelic" => Some(Language::ScottishGaelic), - "sanskrit" => Some(Language::Sanskrit), - "serbian" => Some(Language::Serbian), - "slovenian" => Some(Language::Slovenian), - "spanish" => Some(Language::Spanish), - "castilian" => Some(Language::CastilianSpanish), - "castilian spanish" => Some(Language::CastilianSpanish), - "swedish" => Some(Language::Swedish), - "tagalog" => Some(Language::Tagalog), - "tamil" => Some(Language::Tamil), - "telugu" => Some(Language::Telugu), - "thai" => Some(Language::Thai), - "turkish" => Some(Language::Turkish), - "ukrainian" => Some(Language::Ukrainian), - "urdu" => Some(Language::Urdu), - "vietnamese" => Some(Language::Vietnamese), - "other" => Some(Language::Other), - _ => None, - }; - match l { - Some(l) => Ok(l), - None => Err(format!("invalid language {value}")), - } - } -} - -impl TryFrom for Language { - type Error = String; - - fn try_from(value: String) -> Result { - value.parse() - } -} diff --git a/mlm_db/src/impls/meta.rs b/mlm_db/src/impls/meta.rs deleted file mode 100644 index 619ff2f0..00000000 --- a/mlm_db/src/impls/meta.rs +++ /dev/null @@ -1,248 +0,0 @@ -use std::fmt; - -use itertools::Itertools as _; - -use crate::{ - Flags, MediaType, MetadataSource, OldCategory, TorrentMeta, TorrentMetaDiff, TorrentMetaField, - VipStatus, impls::format_serie, -}; - -impl TorrentMeta { - pub fn matches(&self, other: &TorrentMeta) -> bool { - self.media_type.matches(other.media_type) - && self.language == other.language - && (self.edition.is_none() == other.edition.is_none() - || self.edition.as_ref().is_some_and(|this| { - other - .edition - .as_ref() - .is_some_and(|that| this.1 == that.1 && (this.1 != 0 || this.0 == that.0)) - })) - && self.authors.iter().any(|a| other.authors.contains(a)) - && ((self.narrators.is_empty() && other.narrators.is_empty()) - || self.narrators.iter().any(|a| other.narrators.contains(a))) - } - - pub fn cat_name(&self) -> &str { - match self.cat { - Some(OldCategory::Audio(cat)) => cat.to_str(), - Some(OldCategory::Ebook(cat)) => cat.to_str(), - Some(OldCategory::Musicology(cat)) => cat.to_str(), - Some(OldCategory::Radio(cat)) => cat.to_str(), - None => "N/A", - } - } - - pub fn diff(&self, other: &TorrentMeta) -> Vec { - let mut diff = vec![]; - if self.mam_id != other.mam_id { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::MamId, - from: self.mam_id.to_string(), - to: other.mam_id.to_string(), - }); - } - if self.vip_status != other.vip_status - // If we go from exired temp vip to not vip, do not write a diff - && !(self - .vip_status - .as_ref() - .is_some_and(|s| !s.is_vip()) - && other.vip_status == Some(VipStatus::NotVip)) - { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Vip, - from: self - .vip_status - .as_ref() - .map(|vip_status| vip_status.to_string()) - .unwrap_or_default(), - to: other - .vip_status - .as_ref() - .map(|vip_status| vip_status.to_string()) - .unwrap_or_default(), - }); - } - if self.cat != other.cat { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Cat, - from: self - .cat - .as_ref() - .map(|cat| cat.to_string()) - .unwrap_or_default(), - to: other - .cat - .as_ref() - .map(|cat| cat.to_string()) - .unwrap_or_default(), - }); - } - if self.media_type != other.media_type { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::MediaType, - from: self.media_type.to_string(), - to: other.media_type.to_string(), - }); - } - if self.main_cat != other.main_cat { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::MainCat, - from: self.main_cat.map(|c| c.to_string()).unwrap_or_default(), - to: other.main_cat.map(|c| c.to_string()).unwrap_or_default(), - }); - } - if self.categories != other.categories { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Categories, - from: self - .categories - .iter() - .map(|cat| cat.as_raw_str().to_string()) - .join(", "), - to: other - .categories - .iter() - .map(|cat| cat.as_raw_str().to_string()) - .join(", "), - }); - } - if self.language != other.language { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Language, - from: self - .language - .map(|language| language.to_str().to_string()) - .unwrap_or_default(), - to: other - .language - .map(|language| language.to_str().to_string()) - .unwrap_or_default(), - }); - } - if self.flags != other.flags { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Flags, - from: self - .flags - .map(|flags| format!("{}", Flags::from(flags))) - .unwrap_or_default(), - to: other - .flags - .map(|flags| format!("{}", Flags::from(flags))) - .unwrap_or_default(), - }); - } - if self.filetypes != other.filetypes { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Filetypes, - from: self.filetypes.join(", ").to_string(), - to: other.filetypes.join(", ").to_string(), - }); - } - if self.size != other.size { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Size, - from: self.size.to_string(), - to: other.size.to_string(), - }); - } - if self.title != other.title { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Title, - from: self.title.to_string(), - to: other.title.to_string(), - }); - } - if self.edition != other.edition { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Edition, - from: self - .edition - .as_ref() - .map(|e| e.0.to_string()) - .unwrap_or_default(), - to: other - .edition - .as_ref() - .map(|e| e.0.to_string()) - .unwrap_or_default(), - }); - } - if self.authors != other.authors { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Authors, - from: self.authors.join(", ").to_string(), - to: other.authors.join(", ").to_string(), - }); - } - if self.narrators != other.narrators { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Narrators, - from: self.narrators.join(", ").to_string(), - to: other.narrators.join(", ").to_string(), - }); - } - if self.series != other.series { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Series, - from: self.series.iter().map(format_serie).join(", ").to_string(), - to: other.series.iter().map(format_serie).join(", ").to_string(), - }); - } - if self.source != other.source { - diff.push(TorrentMetaDiff { - field: TorrentMetaField::Source, - from: self.source.to_string(), - to: other.source.to_string(), - }); - } - diff - } -} - -impl MediaType { - pub fn matches(&self, other: MediaType) -> bool { - match (*self, other) { - (a, b) if a == b => true, - // Due to filetype restrictions, torrents for the same book will end up in different - // media types - (MediaType::Ebook, MediaType::ComicBook) => true, - (MediaType::ComicBook, MediaType::Ebook) => true, - _ => false, - } - } -} - -impl std::fmt::Display for TorrentMetaField { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TorrentMetaField::MamId => write!(f, "mam_id"), - TorrentMetaField::Vip => write!(f, "vip"), - TorrentMetaField::Cat => write!(f, "cat"), - TorrentMetaField::MediaType => write!(f, "media_type"), - TorrentMetaField::MainCat => write!(f, "main_cat"), - TorrentMetaField::Categories => write!(f, "categories"), - TorrentMetaField::Language => write!(f, "language"), - TorrentMetaField::Flags => write!(f, "flags"), - TorrentMetaField::Filetypes => write!(f, "filetypes"), - TorrentMetaField::Size => write!(f, "size"), - TorrentMetaField::Title => write!(f, "title"), - TorrentMetaField::Edition => write!(f, "edition"), - TorrentMetaField::Authors => write!(f, "authors"), - TorrentMetaField::Narrators => write!(f, "narrators"), - TorrentMetaField::Series => write!(f, "series"), - TorrentMetaField::Source => write!(f, "source"), - } - } -} - -impl fmt::Display for MetadataSource { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MetadataSource::Mam => write!(f, "MaM"), - MetadataSource::Manual => write!(f, "Manual"), - } - } -} diff --git a/mlm_db/src/impls/old_categories.rs b/mlm_db/src/impls/old_categories.rs deleted file mode 100644 index 44a673ca..00000000 --- a/mlm_db/src/impls/old_categories.rs +++ /dev/null @@ -1,749 +0,0 @@ -use std::str::FromStr; - -use crate::{ - AudiobookCategory, EbookCategory, MusicologyCategory, OldCategory as Category, OldDbMainCat, - OldMainCat as MainCat, RadioCategory, -}; - -impl MainCat { - pub fn as_id(&self) -> u8 { - match self { - MainCat::Audio => 13, - MainCat::Ebook => 14, - MainCat::Musicology => 15, - MainCat::Radio => 16, - } - } -} - -impl FromStr for OldDbMainCat { - type Err = String; - - fn from_str(value: &str) -> Result { - let l = match value.to_lowercase().as_str() { - "audio" => Some(OldDbMainCat::Audio), - "ebook" => Some(OldDbMainCat::Ebook), - _ => None, - }; - match l { - Some(l) => Ok(l), - None => Err(format!("invalid category {value}")), - } - } -} - -impl TryFrom for OldDbMainCat { - type Error = String; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -impl Category { - pub fn from_one_id(category: u64) -> Option { - AudiobookCategory::from_id(category) - .map(Category::Audio) - .or_else(|| EbookCategory::from_id(category).map(Category::Ebook)) - .or_else(|| MusicologyCategory::from_id(category).map(Category::Musicology)) - .or_else(|| RadioCategory::from_id(category).map(Category::Radio)) - } - - pub fn as_str(&self) -> &'static str { - match self { - Category::Audio(cat) => cat.to_str(), - Category::Ebook(cat) => cat.to_str(), - Category::Musicology(cat) => cat.to_str(), - Category::Radio(cat) => cat.to_str(), - } - } - - pub fn as_main_cat(&self) -> MainCat { - match self { - Category::Audio(_) => MainCat::Audio, - Category::Ebook(_) => MainCat::Ebook, - Category::Musicology(_) => MainCat::Musicology, - Category::Radio(_) => MainCat::Radio, - } - } - - pub fn as_id(&self) -> u8 { - match self { - Category::Audio(cat) => cat.to_id(), - Category::Ebook(cat) => cat.to_id(), - Category::Musicology(cat) => cat.to_id(), - Category::Radio(cat) => cat.to_id(), - } - } -} - -impl std::fmt::Display for Category { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl AudiobookCategory { - pub fn all() -> Vec { - vec![ - AudiobookCategory::ActionAdventure, - AudiobookCategory::Art, - AudiobookCategory::Biographical, - AudiobookCategory::Business, - AudiobookCategory::ComputerInternet, - AudiobookCategory::Crafts, - AudiobookCategory::CrimeThriller, - AudiobookCategory::Fantasy, - AudiobookCategory::Food, - AudiobookCategory::GeneralFiction, - AudiobookCategory::GeneralNonFic, - AudiobookCategory::HistoricalFiction, - AudiobookCategory::History, - AudiobookCategory::HomeGarden, - AudiobookCategory::Horror, - AudiobookCategory::Humor, - AudiobookCategory::Instructional, - AudiobookCategory::Juvenile, - AudiobookCategory::Language, - AudiobookCategory::LiteraryClassics, - AudiobookCategory::MathScienceTech, - AudiobookCategory::Medical, - AudiobookCategory::Mystery, - AudiobookCategory::Nature, - AudiobookCategory::Philosophy, - AudiobookCategory::PolSocRelig, - AudiobookCategory::Recreation, - AudiobookCategory::Romance, - AudiobookCategory::ScienceFiction, - AudiobookCategory::SelfHelp, - AudiobookCategory::TravelAdventure, - AudiobookCategory::TrueCrime, - AudiobookCategory::UrbanFantasy, - AudiobookCategory::Western, - AudiobookCategory::YoungAdult, - ] - } - - pub fn from_str(value: &str) -> Option { - match value.to_lowercase().as_str() { - "action" => Some(AudiobookCategory::ActionAdventure), - "action/adventure" => Some(AudiobookCategory::ActionAdventure), - "art" => Some(AudiobookCategory::Art), - "biographical" => Some(AudiobookCategory::Biographical), - "business" => Some(AudiobookCategory::Business), - "computer" => Some(AudiobookCategory::ComputerInternet), - "internet" => Some(AudiobookCategory::ComputerInternet), - "computer/internet" => Some(AudiobookCategory::ComputerInternet), - "crafts" => Some(AudiobookCategory::Crafts), - "crime/thriller" => Some(AudiobookCategory::CrimeThriller), - "fantasy" => Some(AudiobookCategory::Fantasy), - "food" => Some(AudiobookCategory::Food), - "general fiction" => Some(AudiobookCategory::GeneralFiction), - "general non-fic" => Some(AudiobookCategory::GeneralNonFic), - "general non fic" => Some(AudiobookCategory::GeneralNonFic), - "general nonfic" => Some(AudiobookCategory::GeneralNonFic), - "general non-fiction" => Some(AudiobookCategory::GeneralNonFic), - "general non fiction" => Some(AudiobookCategory::GeneralNonFic), - "general nonfiction" => Some(AudiobookCategory::GeneralNonFic), - "historical fiction" => Some(AudiobookCategory::HistoricalFiction), - "history" => Some(AudiobookCategory::History), - "home" => Some(AudiobookCategory::HomeGarden), - "garden" => Some(AudiobookCategory::HomeGarden), - "home/garden" => Some(AudiobookCategory::HomeGarden), - "horror" => Some(AudiobookCategory::Horror), - "humor" => Some(AudiobookCategory::Humor), - "instructional" => Some(AudiobookCategory::Instructional), - "juvenile" => Some(AudiobookCategory::Juvenile), - "language" => Some(AudiobookCategory::Language), - "classics" => Some(AudiobookCategory::LiteraryClassics), - "literary classics" => Some(AudiobookCategory::LiteraryClassics), - "math" => Some(AudiobookCategory::MathScienceTech), - "science" => Some(AudiobookCategory::MathScienceTech), - "tech" => Some(AudiobookCategory::MathScienceTech), - "math/science/tech" => Some(AudiobookCategory::MathScienceTech), - "medical" => Some(AudiobookCategory::Medical), - "mystery" => Some(AudiobookCategory::Mystery), - "nature" => Some(AudiobookCategory::Nature), - "philosophy" => Some(AudiobookCategory::Philosophy), - "pol" => Some(AudiobookCategory::PolSocRelig), - "soc" => Some(AudiobookCategory::PolSocRelig), - "relig" => Some(AudiobookCategory::PolSocRelig), - "pol/soc/relig" => Some(AudiobookCategory::PolSocRelig), - "recreation" => Some(AudiobookCategory::Recreation), - "romance" => Some(AudiobookCategory::Romance), - "sf" => Some(AudiobookCategory::ScienceFiction), - "science fiction" => Some(AudiobookCategory::ScienceFiction), - "self help" => Some(AudiobookCategory::SelfHelp), - "self-help" => Some(AudiobookCategory::SelfHelp), - "travel" => Some(AudiobookCategory::TravelAdventure), - "travel/adventure" => Some(AudiobookCategory::TravelAdventure), - "true crime" => Some(AudiobookCategory::TrueCrime), - "urban fantasy" => Some(AudiobookCategory::UrbanFantasy), - "western" => Some(AudiobookCategory::Western), - "ya" => Some(AudiobookCategory::YoungAdult), - "young adult" => Some(AudiobookCategory::YoungAdult), - _ => None, - } - } - - pub fn from_id(category: u64) -> Option { - match category { - 39 => Some(AudiobookCategory::ActionAdventure), - 49 => Some(AudiobookCategory::Art), - 50 => Some(AudiobookCategory::Biographical), - 83 => Some(AudiobookCategory::Business), - 51 => Some(AudiobookCategory::ComputerInternet), - 97 => Some(AudiobookCategory::Crafts), - 40 => Some(AudiobookCategory::CrimeThriller), - 41 => Some(AudiobookCategory::Fantasy), - 106 => Some(AudiobookCategory::Food), - 42 => Some(AudiobookCategory::GeneralFiction), - 52 => Some(AudiobookCategory::GeneralNonFic), - 98 => Some(AudiobookCategory::HistoricalFiction), - 54 => Some(AudiobookCategory::History), - 55 => Some(AudiobookCategory::HomeGarden), - 43 => Some(AudiobookCategory::Horror), - 99 => Some(AudiobookCategory::Humor), - 84 => Some(AudiobookCategory::Instructional), - 44 => Some(AudiobookCategory::Juvenile), - 56 => Some(AudiobookCategory::Language), - 45 => Some(AudiobookCategory::LiteraryClassics), - 57 => Some(AudiobookCategory::MathScienceTech), - 85 => Some(AudiobookCategory::Medical), - 87 => Some(AudiobookCategory::Mystery), - 119 => Some(AudiobookCategory::Nature), - 88 => Some(AudiobookCategory::Philosophy), - 58 => Some(AudiobookCategory::PolSocRelig), - 59 => Some(AudiobookCategory::Recreation), - 46 => Some(AudiobookCategory::Romance), - 47 => Some(AudiobookCategory::ScienceFiction), - 53 => Some(AudiobookCategory::SelfHelp), - 89 => Some(AudiobookCategory::TravelAdventure), - 100 => Some(AudiobookCategory::TrueCrime), - 108 => Some(AudiobookCategory::UrbanFantasy), - 48 => Some(AudiobookCategory::Western), - 111 => Some(AudiobookCategory::YoungAdult), - _ => None, - } - } - - pub fn to_id(self) -> u8 { - match self { - AudiobookCategory::ActionAdventure => 39, - AudiobookCategory::Art => 49, - AudiobookCategory::Biographical => 50, - AudiobookCategory::Business => 83, - AudiobookCategory::ComputerInternet => 51, - AudiobookCategory::Crafts => 97, - AudiobookCategory::CrimeThriller => 40, - AudiobookCategory::Fantasy => 41, - AudiobookCategory::Food => 106, - AudiobookCategory::GeneralFiction => 42, - AudiobookCategory::GeneralNonFic => 52, - AudiobookCategory::HistoricalFiction => 98, - AudiobookCategory::History => 54, - AudiobookCategory::HomeGarden => 55, - AudiobookCategory::Horror => 43, - AudiobookCategory::Humor => 99, - AudiobookCategory::Instructional => 84, - AudiobookCategory::Juvenile => 44, - AudiobookCategory::Language => 56, - AudiobookCategory::LiteraryClassics => 45, - AudiobookCategory::MathScienceTech => 57, - AudiobookCategory::Medical => 85, - AudiobookCategory::Mystery => 87, - AudiobookCategory::Nature => 119, - AudiobookCategory::Philosophy => 88, - AudiobookCategory::PolSocRelig => 58, - AudiobookCategory::Recreation => 59, - AudiobookCategory::Romance => 46, - AudiobookCategory::ScienceFiction => 47, - AudiobookCategory::SelfHelp => 53, - AudiobookCategory::TravelAdventure => 89, - AudiobookCategory::TrueCrime => 100, - AudiobookCategory::UrbanFantasy => 108, - AudiobookCategory::Western => 48, - AudiobookCategory::YoungAdult => 111, - } - } - - pub fn to_str(self) -> &'static str { - match self { - AudiobookCategory::ActionAdventure => "Action/Adventure", - AudiobookCategory::Art => "Art", - AudiobookCategory::Biographical => "Biographical", - AudiobookCategory::Business => "Business", - AudiobookCategory::ComputerInternet => "Computer/Internet", - AudiobookCategory::Crafts => "Crafts", - AudiobookCategory::CrimeThriller => "Crime/Thriller", - AudiobookCategory::Fantasy => "Fantasy", - AudiobookCategory::Food => "Food", - AudiobookCategory::GeneralFiction => "General Fiction", - AudiobookCategory::GeneralNonFic => "General Non-fic", - AudiobookCategory::HistoricalFiction => "Historical Fiction", - AudiobookCategory::History => "History", - AudiobookCategory::HomeGarden => "Home/Garden", - AudiobookCategory::Horror => "Horror", - AudiobookCategory::Humor => "Humor", - AudiobookCategory::Instructional => "Instructional", - AudiobookCategory::Juvenile => "Juvenile", - AudiobookCategory::Language => "Language", - AudiobookCategory::LiteraryClassics => "Literary Classics", - AudiobookCategory::MathScienceTech => "Math/Science/Tech", - AudiobookCategory::Medical => "Medical", - AudiobookCategory::Mystery => "Mystery", - AudiobookCategory::Nature => "Nature", - AudiobookCategory::Philosophy => "Philosophy", - AudiobookCategory::PolSocRelig => "Pol/Soc/Relig", - AudiobookCategory::Recreation => "Recreation", - AudiobookCategory::Romance => "Romance", - AudiobookCategory::ScienceFiction => "Science Fiction", - AudiobookCategory::SelfHelp => "Self-Help", - AudiobookCategory::TravelAdventure => "Travel/Adventure", - AudiobookCategory::TrueCrime => "True Crime", - AudiobookCategory::UrbanFantasy => "Urban Fantasy", - AudiobookCategory::Western => "Western", - AudiobookCategory::YoungAdult => "Young Adult", - } - } -} - -impl TryFrom for AudiobookCategory { - type Error = String; - - fn try_from(value: String) -> Result { - let l = Self::from_str(&value); - match l { - Some(l) => Ok(l), - None => Err(format!("invalid category {value}")), - } - } -} - -impl EbookCategory { - pub fn all() -> Vec { - vec![ - EbookCategory::ActionAdventure, - EbookCategory::Art, - EbookCategory::Biographical, - EbookCategory::Business, - EbookCategory::ComicsGraphicnovels, - EbookCategory::ComputerInternet, - EbookCategory::Crafts, - EbookCategory::CrimeThriller, - EbookCategory::Fantasy, - EbookCategory::Food, - EbookCategory::GeneralFiction, - EbookCategory::GeneralNonFiction, - EbookCategory::HistoricalFiction, - EbookCategory::History, - EbookCategory::HomeGarden, - EbookCategory::Horror, - EbookCategory::Humor, - EbookCategory::IllusionMagic, - EbookCategory::Instructional, - EbookCategory::Juvenile, - EbookCategory::Language, - EbookCategory::LiteraryClassics, - EbookCategory::MagazinesNewspapers, - EbookCategory::MathScienceTech, - EbookCategory::Medical, - EbookCategory::MixedCollections, - EbookCategory::Mystery, - EbookCategory::Nature, - EbookCategory::Philosophy, - EbookCategory::PolSocRelig, - EbookCategory::Recreation, - EbookCategory::Romance, - EbookCategory::ScienceFiction, - EbookCategory::SelfHelp, - EbookCategory::TravelAdventure, - EbookCategory::TrueCrime, - EbookCategory::UrbanFantasy, - EbookCategory::Western, - EbookCategory::YoungAdult, - ] - } - - pub fn from_str(value: &str) -> Option { - match value.to_lowercase().as_str() { - "action" => Some(EbookCategory::ActionAdventure), - "action/adventure" => Some(EbookCategory::ActionAdventure), - "art" => Some(EbookCategory::Art), - "biographical" => Some(EbookCategory::Biographical), - "business" => Some(EbookCategory::Business), - "comics" => Some(EbookCategory::ComicsGraphicnovels), - "graphic novels" => Some(EbookCategory::ComicsGraphicnovels), - "comics/graphic novels" => Some(EbookCategory::ComicsGraphicnovels), - "computer" => Some(EbookCategory::ComputerInternet), - "internet" => Some(EbookCategory::ComputerInternet), - "computer/internet" => Some(EbookCategory::ComputerInternet), - "crafts" => Some(EbookCategory::Crafts), - "crime" => Some(EbookCategory::CrimeThriller), - "thriller" => Some(EbookCategory::CrimeThriller), - "crime/thriller" => Some(EbookCategory::CrimeThriller), - "fantasy" => Some(EbookCategory::Fantasy), - "food" => Some(EbookCategory::Food), - "general fiction" => Some(EbookCategory::GeneralFiction), - "general non-fic" => Some(EbookCategory::GeneralNonFiction), - "general non fic" => Some(EbookCategory::GeneralNonFiction), - "general nonfic" => Some(EbookCategory::GeneralNonFiction), - "general non-fiction" => Some(EbookCategory::GeneralNonFiction), - "general non fiction" => Some(EbookCategory::GeneralNonFiction), - "general nonfiction" => Some(EbookCategory::GeneralNonFiction), - "historical fiction" => Some(EbookCategory::HistoricalFiction), - "history" => Some(EbookCategory::History), - "home" => Some(EbookCategory::HomeGarden), - "garden" => Some(EbookCategory::HomeGarden), - "home/garden" => Some(EbookCategory::HomeGarden), - "horror" => Some(EbookCategory::Horror), - "humor" => Some(EbookCategory::Humor), - "illusion" => Some(EbookCategory::IllusionMagic), - "magic" => Some(EbookCategory::IllusionMagic), - "illusion/magic" => Some(EbookCategory::IllusionMagic), - "instructional" => Some(EbookCategory::Instructional), - "juvenile" => Some(EbookCategory::Juvenile), - "language" => Some(EbookCategory::Language), - "literary classics" => Some(EbookCategory::LiteraryClassics), - "magazines" => Some(EbookCategory::MagazinesNewspapers), - "newspapers" => Some(EbookCategory::MagazinesNewspapers), - "magazines/newspapers" => Some(EbookCategory::MagazinesNewspapers), - "math" => Some(EbookCategory::MathScienceTech), - "science" => Some(EbookCategory::MathScienceTech), - "tech" => Some(EbookCategory::MathScienceTech), - "math/science/tech" => Some(EbookCategory::MathScienceTech), - "medical" => Some(EbookCategory::Medical), - "mixed collections" => Some(EbookCategory::MixedCollections), - "mystery" => Some(EbookCategory::Mystery), - "nature" => Some(EbookCategory::Nature), - "philosophy" => Some(EbookCategory::Philosophy), - "pol" => Some(EbookCategory::PolSocRelig), - "soc" => Some(EbookCategory::PolSocRelig), - "relig" => Some(EbookCategory::PolSocRelig), - "pol/soc/relig" => Some(EbookCategory::PolSocRelig), - "recreation" => Some(EbookCategory::Recreation), - "romance" => Some(EbookCategory::Romance), - "sf" => Some(EbookCategory::ScienceFiction), - "science fiction" => Some(EbookCategory::ScienceFiction), - "self help" => Some(EbookCategory::SelfHelp), - "self-help" => Some(EbookCategory::SelfHelp), - "travel" => Some(EbookCategory::TravelAdventure), - "travel/adventure" => Some(EbookCategory::TravelAdventure), - "true crime" => Some(EbookCategory::TrueCrime), - "urban fantasy" => Some(EbookCategory::UrbanFantasy), - "western" => Some(EbookCategory::Western), - "ya" => Some(EbookCategory::YoungAdult), - "young adult" => Some(EbookCategory::YoungAdult), - _ => None, - } - } - - pub fn from_id(category: u64) -> Option { - match category { - 60 => Some(EbookCategory::ActionAdventure), - 71 => Some(EbookCategory::Art), - 72 => Some(EbookCategory::Biographical), - 90 => Some(EbookCategory::Business), - 61 => Some(EbookCategory::ComicsGraphicnovels), - 73 => Some(EbookCategory::ComputerInternet), - 101 => Some(EbookCategory::Crafts), - 62 => Some(EbookCategory::CrimeThriller), - 63 => Some(EbookCategory::Fantasy), - 107 => Some(EbookCategory::Food), - 64 => Some(EbookCategory::GeneralFiction), - 74 => Some(EbookCategory::GeneralNonFiction), - 102 => Some(EbookCategory::HistoricalFiction), - 76 => Some(EbookCategory::History), - 77 => Some(EbookCategory::HomeGarden), - 65 => Some(EbookCategory::Horror), - 103 => Some(EbookCategory::Humor), - 115 => Some(EbookCategory::IllusionMagic), - 91 => Some(EbookCategory::Instructional), - 66 => Some(EbookCategory::Juvenile), - 78 => Some(EbookCategory::Language), - 67 => Some(EbookCategory::LiteraryClassics), - 79 => Some(EbookCategory::MagazinesNewspapers), - 80 => Some(EbookCategory::MathScienceTech), - 92 => Some(EbookCategory::Medical), - 118 => Some(EbookCategory::MixedCollections), - 94 => Some(EbookCategory::Mystery), - 120 => Some(EbookCategory::Nature), - 95 => Some(EbookCategory::Philosophy), - 81 => Some(EbookCategory::PolSocRelig), - 82 => Some(EbookCategory::Recreation), - 68 => Some(EbookCategory::Romance), - 69 => Some(EbookCategory::ScienceFiction), - 75 => Some(EbookCategory::SelfHelp), - 96 => Some(EbookCategory::TravelAdventure), - 104 => Some(EbookCategory::TrueCrime), - 109 => Some(EbookCategory::UrbanFantasy), - 70 => Some(EbookCategory::Western), - 112 => Some(EbookCategory::YoungAdult), - _ => None, - } - } - - pub fn to_id(self) -> u8 { - match self { - EbookCategory::ActionAdventure => 60, - EbookCategory::Art => 71, - EbookCategory::Biographical => 72, - EbookCategory::Business => 90, - EbookCategory::ComicsGraphicnovels => 61, - EbookCategory::ComputerInternet => 73, - EbookCategory::Crafts => 101, - EbookCategory::CrimeThriller => 62, - EbookCategory::Fantasy => 63, - EbookCategory::Food => 107, - EbookCategory::GeneralFiction => 64, - EbookCategory::GeneralNonFiction => 74, - EbookCategory::HistoricalFiction => 102, - EbookCategory::History => 76, - EbookCategory::HomeGarden => 77, - EbookCategory::Horror => 65, - EbookCategory::Humor => 103, - EbookCategory::IllusionMagic => 115, - EbookCategory::Instructional => 91, - EbookCategory::Juvenile => 66, - EbookCategory::Language => 78, - EbookCategory::LiteraryClassics => 67, - EbookCategory::MagazinesNewspapers => 79, - EbookCategory::MathScienceTech => 80, - EbookCategory::Medical => 92, - EbookCategory::MixedCollections => 118, - EbookCategory::Mystery => 94, - EbookCategory::Nature => 120, - EbookCategory::Philosophy => 95, - EbookCategory::PolSocRelig => 81, - EbookCategory::Recreation => 82, - EbookCategory::Romance => 68, - EbookCategory::ScienceFiction => 69, - EbookCategory::SelfHelp => 75, - EbookCategory::TravelAdventure => 96, - EbookCategory::TrueCrime => 104, - EbookCategory::UrbanFantasy => 109, - EbookCategory::Western => 70, - EbookCategory::YoungAdult => 112, - } - } - - pub fn to_str(self) -> &'static str { - match self { - EbookCategory::ActionAdventure => "Action/Adventure", - EbookCategory::Art => "Art", - EbookCategory::Biographical => "Biographical", - EbookCategory::Business => "Business", - EbookCategory::ComicsGraphicnovels => "Comics/Graphic Novels", - EbookCategory::ComputerInternet => "Computer/Internet", - EbookCategory::Crafts => "Crafts", - EbookCategory::CrimeThriller => "Crime/Thriller", - EbookCategory::Fantasy => "Fantasy", - EbookCategory::Food => "Food", - EbookCategory::GeneralFiction => "General Fiction", - EbookCategory::GeneralNonFiction => "General Non-fic", - EbookCategory::HistoricalFiction => "Historical Fiction", - EbookCategory::History => "History", - EbookCategory::HomeGarden => "Home/Garden", - EbookCategory::Horror => "Horror", - EbookCategory::Humor => "Humor", - EbookCategory::IllusionMagic => "Illusion/Magic", - EbookCategory::Instructional => "Instructional", - EbookCategory::Juvenile => "Juvenile", - EbookCategory::Language => "Language", - EbookCategory::LiteraryClassics => "Literary Classics", - EbookCategory::MagazinesNewspapers => "Magazines/Newspapers", - EbookCategory::MathScienceTech => "Math/Science/Tech", - EbookCategory::Medical => "Medical", - EbookCategory::MixedCollections => "Mixed Collections", - EbookCategory::Mystery => "Mystery", - EbookCategory::Nature => "Nature", - EbookCategory::Philosophy => "Philosophy", - EbookCategory::PolSocRelig => "Pol/Soc/Relig", - EbookCategory::Recreation => "Recreation", - EbookCategory::Romance => "Romance", - EbookCategory::ScienceFiction => "Science Fiction", - EbookCategory::SelfHelp => "Self-Help", - EbookCategory::TravelAdventure => "Travel/Adventure", - EbookCategory::TrueCrime => "True Crime", - EbookCategory::UrbanFantasy => "Urban Fantasy", - EbookCategory::Western => "Western", - EbookCategory::YoungAdult => "Young Adult", - } - } -} - -impl TryFrom for EbookCategory { - type Error = String; - - fn try_from(value: String) -> Result { - let l = Self::from_str(&value); - match l { - Some(l) => Ok(l), - None => Err(format!("invalid category {value}")), - } - } -} - -impl MusicologyCategory { - pub fn all() -> Vec { - vec![ - MusicologyCategory::GuitarBassTabs, - MusicologyCategory::IndividualSheet, - MusicologyCategory::IndividualSheetMP3, - MusicologyCategory::InstructionalBookWithVideo, - MusicologyCategory::InstructionalMediaMusic, - MusicologyCategory::LickLibraryLTPJamWith, - MusicologyCategory::LickLibraryTechniquesQL, - MusicologyCategory::MusicCompleteEditions, - MusicologyCategory::MusicBook, - MusicologyCategory::MusicBookMP3, - MusicologyCategory::SheetCollection, - MusicologyCategory::SheetCollectionMP3, - ] - } - - pub fn from_str(value: &str) -> Option { - match value.to_lowercase().as_str() { - "guitar/bass tabs" => Some(MusicologyCategory::GuitarBassTabs), - "individual sheet" => Some(MusicologyCategory::IndividualSheet), - "individual sheet mp3" => Some(MusicologyCategory::IndividualSheetMP3), - "instructional book with video" => Some(MusicologyCategory::InstructionalBookWithVideo), - "instructional media - music" => Some(MusicologyCategory::InstructionalMediaMusic), - "lick library - ltp/jam with" => Some(MusicologyCategory::LickLibraryLTPJamWith), - "lick library - techniques/ql" => Some(MusicologyCategory::LickLibraryTechniquesQL), - "music - complete editions" => Some(MusicologyCategory::MusicCompleteEditions), - "music book" => Some(MusicologyCategory::MusicBook), - "music book mp3" => Some(MusicologyCategory::MusicBookMP3), - "sheet collection" => Some(MusicologyCategory::SheetCollection), - "sheet collection mp3" => Some(MusicologyCategory::SheetCollectionMP3), - _ => None, - } - } - - pub fn from_id(category: u64) -> Option { - match category { - 19 => Some(MusicologyCategory::GuitarBassTabs), - 20 => Some(MusicologyCategory::IndividualSheet), - 24 => Some(MusicologyCategory::IndividualSheetMP3), - 126 => Some(MusicologyCategory::InstructionalBookWithVideo), - 22 => Some(MusicologyCategory::InstructionalMediaMusic), - 113 => Some(MusicologyCategory::LickLibraryLTPJamWith), - 114 => Some(MusicologyCategory::LickLibraryTechniquesQL), - 17 => Some(MusicologyCategory::MusicCompleteEditions), - 26 => Some(MusicologyCategory::MusicBook), - 27 => Some(MusicologyCategory::MusicBookMP3), - 30 => Some(MusicologyCategory::SheetCollection), - 31 => Some(MusicologyCategory::SheetCollectionMP3), - _ => None, - } - } - - pub fn to_id(self) -> u8 { - match self { - MusicologyCategory::GuitarBassTabs => 19, - MusicologyCategory::IndividualSheet => 20, - MusicologyCategory::IndividualSheetMP3 => 24, - MusicologyCategory::InstructionalBookWithVideo => 126, - MusicologyCategory::InstructionalMediaMusic => 22, - MusicologyCategory::LickLibraryLTPJamWith => 113, - MusicologyCategory::LickLibraryTechniquesQL => 114, - MusicologyCategory::MusicCompleteEditions => 17, - MusicologyCategory::MusicBook => 26, - MusicologyCategory::MusicBookMP3 => 27, - MusicologyCategory::SheetCollection => 30, - MusicologyCategory::SheetCollectionMP3 => 31, - } - } - - pub fn to_str(self) -> &'static str { - match self { - MusicologyCategory::GuitarBassTabs => "Guitar/Bass Tabs", - MusicologyCategory::IndividualSheet => "Individual Sheet", - MusicologyCategory::IndividualSheetMP3 => "Individual Sheet MP3", - MusicologyCategory::InstructionalBookWithVideo => "Instructional Book with Video", - MusicologyCategory::InstructionalMediaMusic => "Instructional Media - Music", - MusicologyCategory::LickLibraryLTPJamWith => "Lick Library - LTP/Jam With", - MusicologyCategory::LickLibraryTechniquesQL => "Lick Library - Techniques/QL", - MusicologyCategory::MusicCompleteEditions => "Music - Complete Editions", - MusicologyCategory::MusicBook => "Music Book", - MusicologyCategory::MusicBookMP3 => "Music Book MP3", - MusicologyCategory::SheetCollection => "Sheet Collection", - MusicologyCategory::SheetCollectionMP3 => "Sheet Collection MP3", - } - } -} - -impl TryFrom for MusicologyCategory { - type Error = String; - - fn try_from(value: String) -> Result { - let l = Self::from_str(&value); - match l { - Some(l) => Ok(l), - None => Err(format!("invalid category {value}")), - } - } -} - -impl RadioCategory { - pub fn all() -> Vec { - vec![ - RadioCategory::Comedy, - RadioCategory::Drama, - RadioCategory::FactualDocumentary, - RadioCategory::Reading, - ] - } - - pub fn from_str(value: &str) -> Option { - match value.to_lowercase().as_str() { - "comedy" => Some(RadioCategory::Comedy), - "drama" => Some(RadioCategory::Drama), - "factual" => Some(RadioCategory::FactualDocumentary), - "documentary" => Some(RadioCategory::FactualDocumentary), - "factual/documentary" => Some(RadioCategory::FactualDocumentary), - "reading" => Some(RadioCategory::Reading), - _ => None, - } - } - - pub fn from_id(category: u64) -> Option { - match category { - 127 => Some(RadioCategory::Comedy), - 130 => Some(RadioCategory::Drama), - 128 => Some(RadioCategory::FactualDocumentary), - 132 => Some(RadioCategory::Reading), - _ => None, - } - } - - pub fn to_id(self) -> u8 { - match self { - RadioCategory::Comedy => 127, - RadioCategory::Drama => 130, - RadioCategory::FactualDocumentary => 128, - RadioCategory::Reading => 132, - } - } - - pub fn to_str(self) -> &'static str { - match self { - RadioCategory::Comedy => "Comedy", - RadioCategory::Drama => "Drama", - RadioCategory::FactualDocumentary => "Factual/Documentary", - RadioCategory::Reading => "Reading", - } - } -} - -impl TryFrom for RadioCategory { - type Error = String; - - fn try_from(value: String) -> Result { - let l = Self::from_str(&value); - match l { - Some(l) => Ok(l), - None => Err(format!("invalid category {value}")), - } - } -} diff --git a/mlm_db/src/impls/series.rs b/mlm_db/src/impls/series.rs deleted file mode 100644 index e27a8128..00000000 --- a/mlm_db/src/impls/series.rs +++ /dev/null @@ -1,250 +0,0 @@ -use std::{cmp::Ordering, str::FromStr}; - -use nom::{ - Finish, IResult, Parser, - branch::alt, - character::{complete::char, digit1, multispace0}, - combinator::{complete, map, map_res, opt, recognize}, - multi::separated_list0, - sequence::delimited, -}; - -use crate::{Series, SeriesEntries, SeriesEntry}; - -impl TryFrom<(String, String)> for Series { - type Error = nom::error::Error; - - fn try_from((name, num): (String, String)) -> Result { - let entries = SeriesEntries::new(series_entries(&num)?); - Ok(Series { name, entries }) - } -} - -impl SeriesEntries { - pub fn contains(&self, num: f32) -> bool { - self.0.iter().any(|s| s.contains(num)) - } -} - -impl std::fmt::Display for SeriesEntries { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut is_first = true; - for entry in &self.0 { - if !is_first { - write!(f, ", ")?; - } - is_first = false; - write!(f, "{entry}")?; - } - Ok(()) - } -} - -impl SeriesEntry { - pub(crate) fn contains(&self, num: f32) -> bool { - match self { - SeriesEntry::Num(n) => *n == num, - SeriesEntry::Range(start, end) => *start <= num && *end >= num, - SeriesEntry::Part(n, _) => *n == num, - } - } -} - -impl std::fmt::Display for SeriesEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SeriesEntry::Num(num) => write!(f, "{num}"), - SeriesEntry::Range(start, end) => write!(f, "{start}-{end}"), - SeriesEntry::Part(entry, part) => write!(f, "{entry}p{part}"), - } - } -} - -#[allow(clippy::non_canonical_partial_ord_impl)] -impl PartialOrd for SeriesEntry { - fn partial_cmp(&self, other: &Self) -> Option { - match (self, other) { - (SeriesEntry::Num(a), SeriesEntry::Num(b)) => a.partial_cmp(b), - (SeriesEntry::Num(a), SeriesEntry::Range(b, _)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Less)) - } - (SeriesEntry::Num(a), SeriesEntry::Part(b, _)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Less)) - } - (SeriesEntry::Range(a, _), SeriesEntry::Num(b)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Greater)) - } - (SeriesEntry::Range(a, ae), SeriesEntry::Range(b, be)) => a - .partial_cmp(b) - .map(|o| o.then(ae.partial_cmp(be).unwrap_or(Ordering::Equal))), - (SeriesEntry::Range(a, _), SeriesEntry::Part(b, _)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Greater)) - } - (SeriesEntry::Part(a, _), SeriesEntry::Num(b)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Greater)) - } - (SeriesEntry::Part(a, _), SeriesEntry::Range(b, _)) => { - a.partial_cmp(b).map(|o| o.then(Ordering::Less)) - } - (SeriesEntry::Part(a, ap), SeriesEntry::Part(b, bp)) => a - .partial_cmp(b) - .map(|o| o.then(ap.partial_cmp(bp).unwrap_or(Ordering::Equal))), - } - } -} - -impl Eq for SeriesEntry {} - -impl Ord for SeriesEntry { - fn cmp(&self, other: &Self) -> Ordering { - self.partial_cmp(other).unwrap_or(Ordering::Equal) - } -} - -fn float(input: &str) -> IResult<&str, f32> { - map_res( - alt(( - recognize((char('.'), decimal)), - recognize((decimal, char('.'), opt(decimal))), - recognize(decimal), - )), - f32::from_str, - ) - .parse_complete(input) -} - -fn decimal(input: &str) -> IResult<&str, &str> { - digit1().parse_complete(input) -} - -fn series_part(input: &str) -> IResult<&str, SeriesEntry> { - map( - (float, multispace0(), char('p'), multispace0(), float), - |(entry, _, _, _, part)| SeriesEntry::Part(entry, part), - ) - .parse_complete(input) -} - -fn series_range(input: &str) -> IResult<&str, SeriesEntry> { - map( - (float, multispace0(), char('-'), multispace0(), float), - |(start, _, _, _, end)| SeriesEntry::Range(start, end), - ) - .parse_complete(input) -} - -fn series_num(input: &str) -> IResult<&str, SeriesEntry> { - map(float, SeriesEntry::Num).parse_complete(input) -} - -fn series_entry(input: &str) -> IResult<&str, SeriesEntry> { - alt((series_part, series_range, series_num)).parse_complete(input) -} - -fn series_entries(input: &str) -> Result, nom::error::Error<&str>> { - if input.is_empty() { - return Ok(vec![]); - } - complete(separated_list0( - char(','), - delimited(opt(multispace0()), series_entry, opt(multispace0())), - )) - .parse_complete(input) - .finish() - .map(|(_, entries)| entries) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_float() { - assert_eq!(decimal("12"), Ok(("", "12"))); - assert_eq!(decimal("1"), Ok(("", "1"))); - assert_eq!(float("1"), Ok(("", 1.0))); - assert_eq!(float("01"), Ok(("", 1.0))); - assert_eq!(float("01."), Ok(("", 1.0))); - assert_eq!(float("01.0"), Ok(("", 1.0))); - assert_eq!(float(".5"), Ok(("", 0.5))); - } - - #[test] - fn test_parse_series_num() { - assert_eq!(series_num("1"), Ok(("", SeriesEntry::Num(1.0)))); - assert_eq!(series_num("01"), Ok(("", SeriesEntry::Num(1.0)))); - assert_eq!(series_num("01."), Ok(("", SeriesEntry::Num(1.0)))); - assert_eq!(series_num("01.0"), Ok(("", SeriesEntry::Num(1.0)))); - assert_eq!(series_num(".5"), Ok(("", SeriesEntry::Num(0.5)))); - } - - #[test] - fn test_parse_series_range() { - assert_eq!(series_range("1-2"), Ok(("", SeriesEntry::Range(1.0, 2.0)))); - assert_eq!( - series_range("01-10"), - Ok(("", SeriesEntry::Range(1.0, 10.0))) - ); - assert_eq!( - series_range("01.-3.52"), - Ok(("", SeriesEntry::Range(1.0, 3.52))) - ); - assert_eq!( - series_range("01.0-.5"), - Ok(("", SeriesEntry::Range(1.0, 0.5))) - ); - assert_eq!( - series_range(".5 - 32."), - Ok(("", SeriesEntry::Range(0.5, 32.))) - ); - } - - #[test] - fn test_parse_series_part() { - assert_eq!(series_part("1p2"), Ok(("", SeriesEntry::Part(1.0, 2.0)))); - assert_eq!(series_part("01p10"), Ok(("", SeriesEntry::Part(1.0, 10.0)))); - assert_eq!( - series_part("01.p3.52"), - Ok(("", SeriesEntry::Part(1.0, 3.52))) - ); - assert_eq!( - series_part("01.0p.5"), - Ok(("", SeriesEntry::Part(1.0, 0.5))) - ); - assert_eq!( - series_part(".5 p 32."), - Ok(("", SeriesEntry::Part(0.5, 32.))) - ); - } - - #[test] - fn test_parse_series_entires() { - assert_eq!(series_entries("1"), Ok(vec![SeriesEntry::Num(1.0)])); - assert_eq!( - series_entries("1-2"), - Ok(vec![SeriesEntry::Range(1.0, 2.0)]) - ); - assert_eq!(series_entries("1p2"), Ok(vec![SeriesEntry::Part(1.0, 2.0)])); - assert_eq!( - series_entries("1,1p2,1-2"), - Ok(vec![ - SeriesEntry::Num(1.0), - SeriesEntry::Part(1.0, 2.0), - SeriesEntry::Range(1.0, 2.0) - ]) - ); - assert_eq!( - series_entries("1p2,1,1p1,2,1-2").map(|mut s| { - s.sort(); - s - }), - Ok(vec![ - SeriesEntry::Num(1.0), - SeriesEntry::Part(1.0, 1.0), - SeriesEntry::Part(1.0, 2.0), - SeriesEntry::Range(1.0, 2.0), - SeriesEntry::Num(2.0), - ]) - ); - } -} diff --git a/mlm_db/src/impls/size.rs b/mlm_db/src/impls/size.rs deleted file mode 100644 index 631ebf55..00000000 --- a/mlm_db/src/impls/size.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::str::FromStr; - -use once_cell::sync::Lazy; -use regex::Regex; - -use crate::Size; - -impl Size { - pub fn unit(self) -> u64 { - if self.bytes() > 0 { 1 } else { 0 } - } -} - -impl std::fmt::Display for Size { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut value = self.bytes() as f64; - let mut unit = "B"; - if value > 1024_f64.powf(4.0) { - value /= 1024_f64.powf(4.0); - unit = "TiB"; - } else if value > 1024_f64.powf(3.0) { - value /= 1024_f64.powf(3.0); - unit = "GiB"; - } else if value > 1024_f64.powf(2.0) { - value /= 1024_f64.powf(2.0); - unit = "MiB"; - } else if value > 1024.0 { - value /= 1024.0; - unit = "KiB"; - } - let value = ((value * 100.0).round() as u64) as f64 / 100.0; - write!(f, "{} {}", value, unit) - } -} - -pub static SIZE_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"^((?:\d{1,3},)?\d{1,6}(?:\.\d{1,3})?) ([kKMGT]?)(i)?B$").unwrap()); - -impl FromStr for Size { - type Err = String; - - fn from_str(value: &str) -> Result { - if let Some((Some(value), Some(unit), i)) = SIZE_PATTERN - .captures(value) - .map(|c| (c.get(1), c.get(2), c.get(3))) - { - let value: f64 = value.as_str().replace(",", "").parse().unwrap(); - let base: u64 = if i.is_some() { 1024 } else { 1000 }; - let multiplier = match unit.as_str() { - "" => 1, - "k" | "K" => base, - "M" => base.pow(2), - "G" => base.pow(3), - "T" => base.pow(4), - _ => unreachable!("unknown unit: {}", unit.as_str()), - } as f64; - Ok(Size::from_bytes((value * multiplier).round() as u64)) - } else { - Err(format!("invalid size value {value}")) - } - } -} - -impl TryFrom for Size { - type Error = String; - - fn try_from(value: String) -> Result { - value.parse() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_size_thousands_divider() { - assert_eq!( - Size::from_str("1,016.2 KiB"), - Ok(Size::from_bytes(1_040_589)) - ); - } - - #[test] - fn test_size_display() { - assert_eq!( - format!("{}", Size::from_str("1.43 GiB").unwrap()), - "1.43 GiB" - ); - } -} diff --git a/mlm_db/src/lib.rs b/mlm_db/src/lib.rs deleted file mode 100644 index a25f9782..00000000 --- a/mlm_db/src/lib.rs +++ /dev/null @@ -1,286 +0,0 @@ -pub mod impls; -mod v01; -mod v02; -mod v03; -mod v04; -mod v05; -mod v06; -mod v07; -mod v08; -mod v09; -mod v10; -mod v11; -mod v12; -mod v13; -mod v14; -mod v15; -mod v16; -mod v17; - -use std::collections::HashMap; - -use anyhow::Result; -use mlm_parse::normalize_title; -use native_db::Models; -use native_db::transaction::RwTransaction; -use native_db::{Database, ToInput, db_type}; -use once_cell::sync::Lazy; -use serde::{Deserialize, Serialize}; -use tokio::sync::MutexGuard; -use tracing::{info, instrument}; - -pub static MODELS: Lazy = Lazy::new(|| { - let mut models = Models::new(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - models.define::().unwrap(); - - models -}); - -pub type Config = v01::Config; -pub type Torrent = v17::Torrent; -pub type TorrentKey = v17::TorrentKey; -pub type SelectedTorrent = v17::SelectedTorrent; -pub type SelectedTorrentKey = v17::SelectedTorrentKey; -pub type DuplicateTorrent = v17::DuplicateTorrent; -pub type ErroredTorrent = v17::ErroredTorrent; -pub type ErroredTorrentKey = v17::ErroredTorrentKey; -pub type ErroredTorrentId = v11::ErroredTorrentId; -pub type Event = v17::Event; -pub type EventKey = v17::EventKey; -pub type EventType = v17::EventType; -pub type List = v05::List; -pub type ListKey = v05::ListKey; -pub type ListItem = v05::ListItem; -pub type ListItemKey = v05::ListItemKey; -pub type ListItemTorrent = v04::ListItemTorrent; -pub type TorrentMeta = v17::TorrentMeta; -pub type TorrentMetaDiff = v17::TorrentMetaDiff; -pub type TorrentMetaField = v17::TorrentMetaField; -pub type VipStatus = v11::VipStatus; -pub type MetadataSource = v10::MetadataSource; -pub type OldDbMainCat = v01::MainCat; -pub type MainCat = v12::MainCat; -pub type Uuid = v03::Uuid; -pub type Timestamp = v03::Timestamp; -pub type Series = v09::Series; -pub type SeriesEntries = v09::SeriesEntries; -pub type SeriesEntry = v09::SeriesEntry; -pub type Language = v03::Language; -pub type FlagBits = v08::FlagBits; -pub type Size = v03::Size; -pub type TorrentCost = v04::TorrentCost; -pub type TorrentStatus = v04::TorrentStatus; -pub type LibraryMismatch = v08::LibraryMismatch; -pub type ClientStatus = v08::ClientStatus; -pub type AudiobookCategory = v06::AudiobookCategory; -pub type EbookCategory = v06::EbookCategory; -pub type MusicologyCategory = v16::MusicologyCategory; -pub type RadioCategory = v16::RadioCategory; -pub type OldCategory = v16::OldCategory; -pub type MediaType = v13::MediaType; -pub type Category = v15::Category; - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum OldMainCat { - Audio, - Ebook, - Musicology, - Radio, -} - -#[derive(Clone, Default, Debug, Deserialize)] -#[serde(try_from = "HashMap")] -pub struct Flags { - pub crude_language: Option, - pub violence: Option, - pub some_explicit: Option, - pub explicit: Option, - pub abridged: Option, - pub lgbt: Option, -} - -#[instrument(skip_all)] -pub fn migrate(db: &Database<'_>) -> Result<()> { - let rw = db.rw_transaction()?; - - info!("Migrations started"); - rw.migrate::()?; - rw.migrate::()?; - rw.migrate::()?; - // recover_migrate::(&rw)?; - rw.migrate::()?; - // recover_migrate::(&rw)?; - rw.migrate::()?; - rw.migrate::()?; - rw.migrate::()?; - rw.commit()?; - info!("Migrations done"); - - Ok(()) -} - -#[allow(clippy::result_large_err, dead_code)] -fn recover_migrate(rw: &RwTransaction<'_>) -> Result<(), db_type::Error> -where - Old: From + Clone + ToInput, - New: From + ToInput, -{ - let old_data = rw - .scan() - .primary::()? - .all()? - .collect::, _>>()?; - - for old in old_data { - let new: New = old.clone().into(); - rw.insert(new).or_else(|err| match err { - db_type::Error::DuplicateKey { .. } => Ok(()), - err => Err(err), - })?; - rw.remove(old)?; - } - - Ok(()) -} - -#[instrument(skip_all)] -pub fn update_search_title(db: &Database<'_>) -> Result<()> { - let rw = db.rw_transaction()?; - - info!("Update search title started"); - let torrents = rw - .scan() - .primary::()? - .all()? - .collect::, _>>()?; - for mut torrent in torrents { - torrent.title_search = normalize_title(&torrent.meta.title); - rw.upsert(torrent)?; - } - rw.commit()?; - info!("Update search title done"); - - Ok(()) -} - -static RW_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -pub trait DatabaseExt { - fn db(&self) -> &Database<'_>; - - #[allow(async_fn_in_trait)] - async fn rw_async(&self) -> Result<(MutexGuard<'_, ()>, RwTransaction<'_>)> { - // Make sure we are only running one rw_transaction at a time - let guard = RW_MUTEX.lock().await; - let rw = self.db().rw_transaction()?; - Ok((guard, rw)) - } - - fn rw_try(&self) -> Result<(MutexGuard<'_, ()>, RwTransaction<'_>)> { - // Make sure we are only running one rw_transaction at a time - let Ok(guard) = RW_MUTEX.try_lock() else { - return Err(anyhow::Error::msg("Failed to acquire lock")); - }; - let rw = self.db().rw_transaction()?; - Ok((guard, rw)) - } -} - -impl DatabaseExt for Database<'_> { - fn db(&self) -> &Database<'_> { - self - } -} diff --git a/mlm_db/src/v01.rs b/mlm_db/src/v01.rs deleted file mode 100644 index 7236574c..00000000 --- a/mlm_db/src/v01.rs +++ /dev/null @@ -1,171 +0,0 @@ -use super::{v02, v11}; -use native_db::{Key, ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug)] -#[native_model(id = 1, version = 1)] -#[native_db] -pub struct Config { - #[primary_key] - pub key: String, - pub value: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 1)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub library_path: Option, - pub library_files: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub replaced_with: Option, - pub request_matadata_update: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 1)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 1)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub duplicate_of: Option, - pub request_replace: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 1)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: MainCat, - pub filetypes: Vec, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec<(String, String)>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum MainCat { - Audio, - Ebook, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum ErroredTorrentId { - Grabber(/* mam_id */ u64), - Linker(/* hash */ String), - Cleaner(/* hash */ String), -} - -impl ToKey for ErroredTorrentId { - fn to_key(&self) -> Key { - match self { - ErroredTorrentId::Grabber(mam_id) => { - Key::new([&[0u8] as &[u8], &mam_id.to_le_bytes()].concat()) - } - ErroredTorrentId::Linker(hash) => Key::new([&[0u8] as &[u8], hash.as_bytes()].concat()), - ErroredTorrentId::Cleaner(hash) => { - Key::new([&[0u8] as &[u8], hash.as_bytes()].concat()) - } - } - } - - fn key_names() -> Vec { - vec!["ErroredTorrentHash".to_string()] - } -} - -impl From for Torrent { - fn from(t: v02::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - title_search: t.title_search, - meta: t.meta, - replaced_with: t.replaced_with.map(|(r, _)| r), - request_matadata_update: t.request_matadata_update, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v02::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v02::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta, - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v02::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta, - } - } -} - -impl From for ErroredTorrentId { - fn from(t: v11::ErroredTorrentId) -> Self { - match t { - v11::ErroredTorrentId::Grabber(id) => ErroredTorrentId::Grabber(id), - v11::ErroredTorrentId::Linker(hash) => ErroredTorrentId::Linker(hash), - v11::ErroredTorrentId::Cleaner(hash) => ErroredTorrentId::Cleaner(hash), - } - } -} diff --git a/mlm_db/src/v02.rs b/mlm_db/src/v02.rs deleted file mode 100644 index 84228a68..00000000 --- a/mlm_db/src/v02.rs +++ /dev/null @@ -1,190 +0,0 @@ -use super::{v01, v03}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use time::{OffsetDateTime, UtcOffset}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 2, from = v01::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub library_path: Option, - pub library_files: Vec, - #[secondary_key] - pub title_search: String, - pub meta: v01::TorrentMeta, - pub created_at: OffsetDateTime, - pub replaced_with: Option<(String, OffsetDateTime)>, - pub request_matadata_update: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 2, from = v01::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: v01::TorrentMeta, - pub created_at: OffsetDateTime, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 2, from = v01::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - #[secondary_key] - pub title_search: String, - pub meta: v01::TorrentMeta, - pub created_at: OffsetDateTime, - pub duplicate_of: Option, - pub request_replace: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 2, from = v01::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - pub created_at: OffsetDateTime, -} - -impl From for Torrent { - fn from(t: v01::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - title_search: t.title_search, - meta: t.meta, - created_at: OffsetDateTime::now_utc(), - replaced_with: t.replaced_with.map(|r| (r, OffsetDateTime::now_utc())), - request_matadata_update: t.request_matadata_update, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v01::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - created_at: OffsetDateTime::now_utc(), - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v01::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta, - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - created_at: OffsetDateTime::now_utc(), - } - } -} - -impl From for ErroredTorrent { - fn from(t: v01::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta, - created_at: OffsetDateTime::now_utc(), - } - } -} - -impl From for Torrent { - fn from(t: v03::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at.0.to_offset(UtcOffset::UTC), - replaced_with: t - .replaced_with - .map(|(with, when)| (with, when.0.to_offset(UtcOffset::UTC))), - request_matadata_update: t.request_matadata_update, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v03::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at.0.to_offset(UtcOffset::UTC), - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v03::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta.into(), - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - created_at: t.created_at.0.to_offset(UtcOffset::UTC), - } - } -} - -impl From for ErroredTorrent { - fn from(t: v03::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(Into::into), - created_at: t.created_at.0.to_offset(UtcOffset::UTC), - } - } -} - -impl From for v01::TorrentMeta { - fn from(t: v03::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - filetypes: t.filetypes, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} diff --git a/mlm_db/src/v03.rs b/mlm_db/src/v03.rs deleted file mode 100644 index 42b67abf..00000000 --- a/mlm_db/src/v03.rs +++ /dev/null @@ -1,503 +0,0 @@ -use super::{v01, v02, v04, v05, v06}; -use native_db::{Key, ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use time::{OffsetDateTime, UtcDateTime}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 3, from = v02::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: Timestamp, - pub replaced_with: Option<(String, Timestamp)>, - pub request_matadata_update: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 3, from = v02::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 3, from = v02::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: Timestamp, - pub duplicate_of: Option, - pub request_replace: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 3, from = v02::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 3)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 7, version = 3)] -#[native_db] -pub struct List { - #[primary_key] - pub id: u64, - #[secondary_key] - pub title: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 8, version = 3)] -#[native_db] -pub struct ListItem { - #[primary_key] - pub guid: (u64, String), - #[secondary_key] - pub list_id: u64, - pub title: String, - pub authors: Vec, - pub series: Vec<(String, u64)>, - pub cover_url: String, - pub book_url: Option, - pub isbn: Option, - pub prefer_format: Option, - pub audio_torrent: Option<(u64, Timestamp)>, - pub wanted_audio_torrent: Option<(u64, Timestamp)>, - pub ebook_torrent: Option<(u64, Timestamp)>, - pub wanted_ebook_torrent: Option<(u64, Timestamp)>, - #[secondary_key] - pub created_at: Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed, - Linked { - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: v01::MainCat, - pub language: Option, - pub filetypes: Vec, - pub size: Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec<(String, String)>, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum Language { - English, - Afrikaans, - Arabic, - Bengali, - Bosnian, - Bulgarian, - Burmese, - Cantonese, - Catalan, - Chinese, - Croatian, - Czech, - Danish, - Dutch, - Estonian, - Farsi, - Finnish, - French, - German, - Greek, - GreekAncient, - Gujarati, - Hebrew, - Hindi, - Hungarian, - Icelandic, - Indonesian, - Irish, - Italian, - Japanese, - Javanese, - Kannada, - Korean, - Lithuanian, - Latin, - Latvian, - Malay, - Malayalam, - Manx, - Marathi, - Norwegian, - Polish, - Portuguese, - BrazilianPortuguese, - Punjabi, - Romanian, - Russian, - ScottishGaelic, - Sanskrit, - Serbian, - Slovenian, - Spanish, - CastilianSpanish, - Swedish, - Tagalog, - Tamil, - Telugu, - Thai, - Turkish, - Ukrainian, - Urdu, - Vietnamese, - Other, -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub struct Size(u64); -impl Size { - pub fn from_bytes(bytes: u64) -> Size { - Size(bytes) - } - - pub fn bytes(self) -> u64 { - self.0 - } -} - -#[derive(Debug, Serialize, Deserialize, Eq, PartialEq, PartialOrd, Ord, Clone, Copy, Hash)] -pub struct Timestamp(pub UtcDateTime); -impl Timestamp { - pub fn now() -> Self { - Self(UtcDateTime::now()) - } -} - -impl From for Timestamp { - fn from(value: UtcDateTime) -> Self { - Self(value) - } -} -impl From for Timestamp { - fn from(value: OffsetDateTime) -> Self { - Self(value.to_utc()) - } -} - -impl ToKey for Timestamp { - fn to_key(&self) -> Key { - Key::new(self.0.unix_timestamp().to_be_bytes().into()) - } - - fn key_names() -> Vec { - vec!["Timestamp".to_string()] - } -} - -#[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Hash)] -pub struct Uuid(uuid::Uuid); -impl Uuid { - pub fn new() -> Self { - Self(uuid::Uuid::new_v4()) - } -} - -impl Default for Uuid { - fn default() -> Self { - Self::new() - } -} - -impl ToKey for Uuid { - fn to_key(&self) -> Key { - Key::new(self.0.as_bytes().to_vec()) - } - - fn key_names() -> Vec { - vec!["Uuid".to_string()] - } -} - -impl From for Torrent { - fn from(t: v02::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: None, - selected_ebook_format: None, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at.into(), - replaced_with: t.replaced_with.map(|(with, when)| (with, when.into())), - request_matadata_update: t.request_matadata_update, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v02::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at.into(), - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v02::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta.into(), - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - created_at: t.created_at.into(), - } - } -} - -impl From for ErroredTorrent { - fn from(t: v02::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(Into::into), - created_at: t.created_at.into(), - } - } -} - -impl From for TorrentMeta { - fn from(t: v01::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - language: None, - filetypes: t.filetypes, - size: Size(0), - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} - -impl From for Torrent { - fn from(t: v05::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v04::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - } - } -} - -impl From for Event { - fn from(t: v04::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for List { - fn from(t: v04::List) -> Self { - Self { - id: t.id.split(':').next().unwrap().parse().unwrap(), - title: t.title, - } - } -} - -impl From for ListItem { - fn from(t: v04::ListItem) -> Self { - let list_id = t.list_id.split(':').next().unwrap().parse().unwrap(); - - Self { - guid: (list_id, t.guid.1), - list_id, - title: t.title, - authors: t.authors, - series: t.series, - cover_url: t.cover_url, - book_url: t.book_url, - isbn: t.isbn, - prefer_format: t.prefer_format, - audio_torrent: t.audio_torrent.as_ref().and_then(|t| { - if t.status == v04::TorrentStatus::Selected { - Some((t.mam_id, t.at)) - } else { - None - } - }), - wanted_audio_torrent: t.audio_torrent.as_ref().and_then(|t| { - if t.status == v04::TorrentStatus::Wanted { - Some((t.mam_id, t.at)) - } else { - None - } - }), - ebook_torrent: t.ebook_torrent.as_ref().and_then(|t| { - if t.status == v04::TorrentStatus::Selected { - Some((t.mam_id, t.at)) - } else { - None - } - }), - wanted_ebook_torrent: t.ebook_torrent.as_ref().and_then(|t| { - if t.status == v04::TorrentStatus::Wanted { - Some((t.mam_id, t.at)) - } else { - None - } - }), - created_at: t.created_at, - } - } -} - -impl From for EventType { - fn from(t: v04::EventType) -> Self { - match t { - v04::EventType::Grabbed { .. } => Self::Grabbed, - v04::EventType::Linked { library_path } => Self::Linked { library_path }, - v04::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v06::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v06::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v06::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - language: t.language, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} diff --git a/mlm_db/src/v04.rs b/mlm_db/src/v04.rs deleted file mode 100644 index 75bf5550..00000000 --- a/mlm_db/src/v04.rs +++ /dev/null @@ -1,292 +0,0 @@ -use super::{v01, v03, v05, v06, v07}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 4, from = v03::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: v03::TorrentMeta, - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 4, from = v03::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 7, version = 4, from = v03::List)] -#[native_db] -pub struct List { - #[primary_key] - pub id: String, - #[secondary_key] - pub title: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 8, version = 4, from = v03::ListItem)] -#[native_db] -pub struct ListItem { - #[primary_key] - pub guid: (String, String), - #[secondary_key] - pub list_id: String, - pub title: String, - pub authors: Vec, - pub series: Vec<(String, u64)>, - pub cover_url: String, - pub book_url: Option, - pub isbn: Option, - pub prefer_format: Option, - pub allow_audio: bool, - pub audio_torrent: Option, - pub allow_ebook: bool, - pub ebook_torrent: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - cost: Option, - wedged: bool, - }, - Linked { - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum TorrentCost { - GlobalFreeleech, - PersonalFreeleech, - Vip, - UseWedge, - TryWedge, - Ratio, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct ListItemTorrent { - pub mam_id: u64, - pub status: TorrentStatus, - pub at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub enum TorrentStatus { - Selected, - Wanted, - NotWanted, - Existing, -} - -impl From for SelectedTorrent { - fn from(t: v03::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: TorrentCost::Ratio, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - } - } -} - -impl From for Event { - fn from(t: v03::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for List { - fn from(t: v03::List) -> Self { - Self { - id: format!("{}:to-read", t.id), - title: t.title, - } - } -} - -impl From for ListItem { - fn from(t: v03::ListItem) -> Self { - Self { - guid: (format!("{}:to-read", t.list_id), t.guid.1), - list_id: format!("{}:to-read", t.list_id), - title: t.title, - authors: t.authors, - series: t.series, - cover_url: t.cover_url, - book_url: t.book_url, - isbn: t.isbn, - prefer_format: t.prefer_format, - allow_audio: true, - audio_torrent: t - .audio_torrent - .map(|t| ListItemTorrent { - mam_id: t.0, - status: TorrentStatus::Selected, - at: t.1, - }) - .or_else(|| { - t.wanted_audio_torrent.map(|t| ListItemTorrent { - mam_id: t.0, - status: TorrentStatus::Wanted, - at: t.1, - }) - }), - allow_ebook: true, - ebook_torrent: t - .ebook_torrent - .map(|t| ListItemTorrent { - mam_id: t.0, - status: TorrentStatus::Selected, - at: t.1, - }) - .or_else(|| { - t.wanted_ebook_torrent.map(|t| ListItemTorrent { - mam_id: t.0, - status: TorrentStatus::Wanted, - at: t.1, - }) - }), - created_at: t.created_at, - } - } -} - -impl From for EventType { - fn from(t: v03::EventType) -> Self { - match t { - v03::EventType::Grabbed => Self::Grabbed { - cost: None, - wedged: false, - }, - v03::EventType::Linked { library_path } => Self::Linked { library_path }, - v03::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - } - } -} - -impl From for List { - fn from(t: v05::List) -> Self { - Self { - id: t.id, - title: t.title, - } - } -} - -impl From for ListItem { - fn from(t: v05::ListItem) -> Self { - Self { - guid: t.guid, - list_id: t.list_id, - title: t.title, - authors: t.authors, - series: t - .series - .into_iter() - .map(|(name, num)| (name, num as u64)) - .collect(), - cover_url: t.cover_url, - book_url: t.book_url, - isbn: t.isbn, - prefer_format: t.prefer_format, - allow_audio: t.allow_audio, - audio_torrent: t.audio_torrent, - allow_ebook: t.allow_ebook, - ebook_torrent: t.ebook_torrent, - created_at: t.created_at, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v06::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - } - } -} - -impl From for Event { - fn from(t: v07::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v07::EventType) -> Self { - match t { - v07::EventType::Grabbed { cost, wedged } => Self::Grabbed { cost, wedged }, - v07::EventType::Linked { library_path } => Self::Linked { library_path }, - v07::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v07::EventType::Updated { .. } => unimplemented!(), - } - } -} diff --git a/mlm_db/src/v05.rs b/mlm_db/src/v05.rs deleted file mode 100644 index 18945675..00000000 --- a/mlm_db/src/v05.rs +++ /dev/null @@ -1,141 +0,0 @@ -use super::{v01, v03, v04, v06}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 5, from = v03::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: v03::TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub enum LibraryMismatch { - NewPath(PathBuf), - NoLibrary, - TorrentRemoved, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 7, version = 5, from = v04::List)] -#[native_db(export_keys = true)] -pub struct List { - #[primary_key] - pub id: String, - #[secondary_key] - pub title: String, - pub updated_at: Option, - pub build_date: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 8, version = 5, from = v04::ListItem)] -#[native_db(export_keys = true)] -pub struct ListItem { - #[primary_key] - pub guid: (String, String), - #[secondary_key] - pub list_id: String, - pub title: String, - pub authors: Vec, - pub series: Vec<(String, f64)>, - pub cover_url: String, - pub book_url: Option, - pub isbn: Option, - pub prefer_format: Option, - pub allow_audio: bool, - pub audio_torrent: Option, - pub allow_ebook: bool, - pub ebook_torrent: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub marked_done_at: Option, -} - -impl From for Torrent { - fn from(t: v03::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: None, - } - } -} - -impl From for List { - fn from(t: v04::List) -> Self { - Self { - id: t.id, - title: t.title, - updated_at: None, - build_date: None, - } - } -} - -impl From for ListItem { - fn from(t: v04::ListItem) -> Self { - Self { - guid: t.guid, - list_id: t.list_id, - title: t.title, - authors: t.authors, - series: t - .series - .into_iter() - .map(|(name, num)| (name, num as f64)) - .collect(), - cover_url: t.cover_url, - book_url: t.book_url, - isbn: t.isbn, - prefer_format: t.prefer_format, - allow_audio: t.allow_audio, - audio_torrent: t.audio_torrent, - allow_ebook: t.allow_ebook, - ebook_torrent: t.ebook_torrent, - created_at: t.created_at, - marked_done_at: None, - } - } -} - -impl From for Torrent { - fn from(t: v06::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - } - } -} diff --git a/mlm_db/src/v06.rs b/mlm_db/src/v06.rs deleted file mode 100644 index ebda045e..00000000 --- a/mlm_db/src/v06.rs +++ /dev/null @@ -1,334 +0,0 @@ -use super::{v01, v03, v04, v05, v07, v08, v16}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 6, from = v05::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 6, from = v04::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 6, from = v03::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, - pub request_replace: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 6, from = v03::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: v01::MainCat, - pub cat: Option, - pub language: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec<(String, String)>, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum AudiobookCategory { - ActionAdventure, - Art, - Biographical, - Business, - ComputerInternet, - Crafts, - CrimeThriller, - Fantasy, - Food, - GeneralFiction, - GeneralNonFic, - HistoricalFiction, - History, - HomeGarden, - Horror, - Humor, - Instructional, - Juvenile, - Language, - LiteraryClassics, - MathScienceTech, - Medical, - Mystery, - Nature, - Philosophy, - PolSocRelig, - Recreation, - Romance, - ScienceFiction, - SelfHelp, - TravelAdventure, - TrueCrime, - UrbanFantasy, - Western, - YoungAdult, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum EbookCategory { - ActionAdventure, - Art, - Biographical, - Business, - ComicsGraphicnovels, - ComputerInternet, - Crafts, - CrimeThriller, - Fantasy, - Food, - GeneralFiction, - GeneralNonFiction, - HistoricalFiction, - History, - HomeGarden, - Horror, - Humor, - IllusionMagic, - Instructional, - Juvenile, - Language, - LiteraryClassics, - MagazinesNewspapers, - MathScienceTech, - Medical, - MixedCollections, - Mystery, - Nature, - Philosophy, - PolSocRelig, - Recreation, - Romance, - ScienceFiction, - SelfHelp, - TravelAdventure, - TrueCrime, - UrbanFantasy, - Western, - YoungAdult, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum Category { - Audio(AudiobookCategory), - Ebook(EbookCategory), -} - -impl From for Torrent { - fn from(t: v05::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v04::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v03::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - request_replace: t.request_replace, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v03::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v03::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: None, - language: t.language, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} - -impl From for Torrent { - fn from(t: v07::Torrent) -> Self { - Self { - hash: t.hash, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v07::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v07::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - duplicate_of: t.duplicate_of, - request_replace: false, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v08::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v08::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: None, - language: t.language, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} - -impl From for Category { - fn from(value: v16::OldCategory) -> Self { - match value { - v16::OldCategory::Audio(audiobook_category) => Self::Audio(audiobook_category), - v16::OldCategory::Ebook(ebook_category) => Self::Ebook(ebook_category), - v16::OldCategory::Musicology(_) => unimplemented!(), - v16::OldCategory::Radio(_) => unimplemented!(), - } - } -} diff --git a/mlm_db/src/v07.rs b/mlm_db/src/v07.rs deleted file mode 100644 index 1f66161f..00000000 --- a/mlm_db/src/v07.rs +++ /dev/null @@ -1,309 +0,0 @@ -use super::{v03, v04, v05, v06, v08}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 7, from = v06::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - pub abs_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: v06::TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 7, from = v06::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: v06::TorrentMeta, - pub created_at: v03::Timestamp, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 7, from = v06::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: v06::TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 7, from = v04::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - cost: Option, - wedged: bool, - }, - Linked { - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMetaDiff { - pub field: TorrentMetaField, - pub from: String, - pub to: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum TorrentMetaField { - MamId, - MainCat, - Cat, - Language, - Filetypes, - Size, - Title, - Authors, - Narrators, - Series, -} - -impl From for Torrent { - fn from(t: v06::Torrent) -> Self { - Self { - hash: t.hash, - abs_id: None, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v06::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - removed_at: None, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v06::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: None, - title_search: t.title_search, - meta: t.meta, - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for Event { - fn from(t: v04::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v04::EventType) -> Self { - match t { - v04::EventType::Grabbed { cost, wedged } => Self::Grabbed { cost, wedged }, - v04::EventType::Linked { library_path } => Self::Linked { library_path }, - v04::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - } - } -} - -impl From for Torrent { - fn from(t: v08::Torrent) -> Self { - let library_mismatch = match (t.library_mismatch, t.client_status) { - (Some(v08::LibraryMismatch::NewLibraryDir(path_buf)), _) => { - Some(v05::LibraryMismatch::NewPath(path_buf)) - } - (Some(v08::LibraryMismatch::NoLibrary), _) => Some(v05::LibraryMismatch::NoLibrary), - (_, Some(v08::ClientStatus::NotInClient)) => Some(v05::LibraryMismatch::TorrentRemoved), - (None, _) => None, - (Some(v08::LibraryMismatch::NewPath(_)), _) => unimplemented!(), - }; - - Self { - hash: t.hash, - abs_id: None, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v08::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - removed_at: None, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v08::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: None, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for Event { - fn from(t: v08::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v08::EventType) -> Self { - match t { - v08::EventType::Grabbed { cost, wedged } => Self::Grabbed { cost, wedged }, - v08::EventType::Linked { library_path } => Self::Linked { library_path }, - v08::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v08::EventType::Updated { fields } => Self::Updated { - fields: fields - .into_iter() - .filter_map(|f| { - Some(TorrentMetaDiff { - field: f.field.try_into().ok()?, - from: f.from, - to: f.to, - }) - }) - .collect(), - }, - v08::EventType::RemovedFromMam => unimplemented!(), - } - } -} - -impl TryFrom for TorrentMetaField { - type Error = (); - - fn try_from(value: v08::TorrentMetaField) -> Result { - match value { - v08::TorrentMetaField::MamId => Ok(TorrentMetaField::MamId), - v08::TorrentMetaField::MainCat => Ok(TorrentMetaField::MainCat), - v08::TorrentMetaField::Cat => Ok(TorrentMetaField::Cat), - v08::TorrentMetaField::Language => Ok(TorrentMetaField::Language), - v08::TorrentMetaField::Flags => Err(()), - v08::TorrentMetaField::Filetypes => Ok(TorrentMetaField::Filetypes), - v08::TorrentMetaField::Size => Ok(TorrentMetaField::Size), - v08::TorrentMetaField::Title => Ok(TorrentMetaField::Title), - v08::TorrentMetaField::Authors => Ok(TorrentMetaField::Authors), - v08::TorrentMetaField::Narrators => Ok(TorrentMetaField::Narrators), - v08::TorrentMetaField::Series => Ok(TorrentMetaField::Series), - } - } -} diff --git a/mlm_db/src/v08.rs b/mlm_db/src/v08.rs deleted file mode 100644 index 9844d3e0..00000000 --- a/mlm_db/src/v08.rs +++ /dev/null @@ -1,458 +0,0 @@ -use super::{v01, v03, v04, v05, v06, v07, v09, v10, v11}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 8, from = v07::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key] - pub mam_id: u64, - pub abs_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub enum LibraryMismatch { - NewLibraryDir(PathBuf), - NewPath(PathBuf), - NoLibrary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub enum ClientStatus { - NotInClient, - RemovedFromMam, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 8, from = v07::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 8, from = v07::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 8, from = v06::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: v01::MainCat, - pub cat: Option, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec<(String, String)>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -pub struct FlagBits(pub u8); - -impl FlagBits { - pub fn new(bits: u8) -> FlagBits { - FlagBits(bits) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 8, from = v07::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - cost: Option, - wedged: bool, - }, - Linked { - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, - RemovedFromMam, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMetaDiff { - pub field: TorrentMetaField, - pub from: String, - pub to: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum TorrentMetaField { - MamId, - MainCat, - Cat, - Language, - Flags, - Filetypes, - Size, - Title, - Authors, - Narrators, - Series, -} - -impl From for Torrent { - fn from(t: v07::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch.map(Into::into), - client_status: None, - } - } -} - -impl From for LibraryMismatch { - fn from(value: v05::LibraryMismatch) -> Self { - match value { - v05::LibraryMismatch::NewPath(path_buf) => LibraryMismatch::NewLibraryDir(path_buf), - v05::LibraryMismatch::NoLibrary => LibraryMismatch::NoLibrary, - v05::LibraryMismatch::TorrentRemoved => unimplemented!(), - } - } -} - -impl From for SelectedTorrent { - fn from(t: v07::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v07::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v06::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v06::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: None, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} - -impl From for Event { - fn from(t: v07::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v07::EventType) -> Self { - match t { - v07::EventType::Grabbed { cost, wedged } => Self::Grabbed { cost, wedged }, - v07::EventType::Linked { library_path } => Self::Linked { library_path }, - v07::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v07::EventType::Updated { fields } => Self::Updated { - fields: fields - .into_iter() - .map(|f| TorrentMetaDiff { - field: f.field.into(), - from: f.from, - to: f.to, - }) - .collect(), - }, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v07::TorrentMetaField) -> Self { - match value { - v07::TorrentMetaField::MamId => TorrentMetaField::MamId, - v07::TorrentMetaField::MainCat => TorrentMetaField::MainCat, - v07::TorrentMetaField::Cat => TorrentMetaField::Cat, - v07::TorrentMetaField::Language => TorrentMetaField::Language, - v07::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v07::TorrentMetaField::Size => TorrentMetaField::Size, - v07::TorrentMetaField::Title => TorrentMetaField::Title, - v07::TorrentMetaField::Authors => TorrentMetaField::Authors, - v07::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v07::TorrentMetaField::Series => TorrentMetaField::Series, - } - } -} - -impl From for Torrent { - fn from(t: v09::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v09::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v09::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v09::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v09::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t - .series - .into_iter() - .map(|series| (series.name, format!("{}", series.entries))) - .collect(), - } - } -} - -impl From for Event { - fn from(t: v10::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v10::EventType) -> Self { - match t { - v10::EventType::Grabbed { cost, wedged, .. } => Self::Grabbed { cost, wedged }, - v10::EventType::Linked { library_path, .. } => Self::Linked { library_path }, - v10::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v10::EventType::Updated { fields } => Self::Updated { fields }, - v10::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v11::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v11::TorrentMetaField) -> Self { - match value { - v11::TorrentMetaField::MamId => TorrentMetaField::MamId, - v11::TorrentMetaField::Vip => unimplemented!(), - v11::TorrentMetaField::MainCat => TorrentMetaField::MainCat, - v11::TorrentMetaField::Cat => TorrentMetaField::Cat, - v11::TorrentMetaField::Language => TorrentMetaField::Language, - v11::TorrentMetaField::Flags => TorrentMetaField::Flags, - v11::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v11::TorrentMetaField::Size => TorrentMetaField::Size, - v11::TorrentMetaField::Title => TorrentMetaField::Title, - v11::TorrentMetaField::Authors => TorrentMetaField::Authors, - v11::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v11::TorrentMetaField::Series => TorrentMetaField::Series, - v11::TorrentMetaField::Source => unimplemented!(), - } - } -} diff --git a/mlm_db/src/v09.rs b/mlm_db/src/v09.rs deleted file mode 100644 index d0726f28..00000000 --- a/mlm_db/src/v09.rs +++ /dev/null @@ -1,292 +0,0 @@ -use super::{v01, v03, v04, v06, v08, v10}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use tracing::warn; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 9, from = v08::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key] - pub mam_id: u64, - pub abs_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 9, from = v08::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 9, from = v08::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 9, from = v08::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: v01::MainCat, - pub cat: Option, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct Series { - pub name: String, - pub entries: SeriesEntries, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct SeriesEntries(pub Vec); - -impl SeriesEntries { - pub fn new(entries: Vec) -> SeriesEntries { - SeriesEntries(entries) - } -} - -impl AsRef> for SeriesEntries { - fn as_ref(&self) -> &Vec { - &self.0 - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub enum SeriesEntry { - Num(f32), - Range(f32, f32), - Part(f32, f32), -} - -impl From for Torrent { - fn from(t: v08::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v08::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v08::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v08::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v08::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t - .series - .into_iter() - .map(|(name, num)| { - Series::try_from((name.clone(), num.clone())).unwrap_or_else(|err| { - warn!("error parsing series num: {err}, num: \"{num}\""); - Series { - name, - entries: SeriesEntries::new(vec![]), - } - }) - }) - .collect(), - } - } -} - -impl From for Torrent { - fn from(t: v10::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v10::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v10::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v10::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v10::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - } - } -} diff --git a/mlm_db/src/v10.rs b/mlm_db/src/v10.rs deleted file mode 100644 index b1a0befa..00000000 --- a/mlm_db/src/v10.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::{v01, v03, v04, v06, v08, v09, v11}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 10, from = v09::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key] - pub mam_id: u64, - pub abs_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 10, from = v09::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 10, from = v09::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 10, from = v09::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v01::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub main_cat: v01::MainCat, - pub cat: Option, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: MetadataSource, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub enum MetadataSource { - Mam, - Manual, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 10, from = v08::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - grabber: Option, - cost: Option, - wedged: bool, - }, - Linked { - linker: Option, - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, - RemovedFromMam, -} - -impl From for Torrent { - fn from(t: v09::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v09::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: None, - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v09::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v09::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v09::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: MetadataSource::Mam, - } - } -} - -impl From for Event { - fn from(t: v08::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v08::EventType) -> Self { - match t { - v08::EventType::Grabbed { cost, wedged } => Self::Grabbed { - grabber: None, - cost, - wedged, - }, - v08::EventType::Linked { library_path } => Self::Linked { - linker: None, - library_path, - }, - v08::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v08::EventType::Updated { fields } => Self::Updated { fields }, - v08::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for Torrent { - fn from(t: v11::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v11::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v11::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v11::ErroredTorrent) -> Self { - Self { - id: t.id.into(), - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v11::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for Event { - fn from(t: v11::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v11::EventType) -> Self { - match t { - v11::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v11::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v11::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v11::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v11::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} diff --git a/mlm_db/src/v11.rs b/mlm_db/src/v11.rs deleted file mode 100644 index 3c17c96c..00000000 --- a/mlm_db/src/v11.rs +++ /dev/null @@ -1,541 +0,0 @@ -use super::{v01, v03, v04, v06, v08, v09, v10, v12}; -use native_db::{Key, ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use time::Date; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 11, from = v10::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key] - pub mam_id: u64, - pub abs_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 11, from = v10::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 11, from = v10::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 11, from = v10::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum ErroredTorrentId { - Grabber(/* mam_id */ u64), - Linker(/* hash */ String), - Cleaner(/* hash */ String), -} - -impl ToKey for ErroredTorrentId { - fn to_key(&self) -> Key { - match self { - ErroredTorrentId::Grabber(mam_id) => { - Key::new([&[0u8] as &[u8], &mam_id.to_le_bytes()].concat()) - } - ErroredTorrentId::Linker(hash) => Key::new([&[1u8] as &[u8], hash.as_bytes()].concat()), - ErroredTorrentId::Cleaner(hash) => { - Key::new([&[2u8] as &[u8], hash.as_bytes()].concat()) - } - } - } - - fn key_names() -> Vec { - vec!["ErroredTorrentHash".to_string()] - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub main_cat: v01::MainCat, - pub cat: Option, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub enum VipStatus { - NotVip, - Permanent, - Temp(Date), -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 11, from = v10::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - grabber: Option, - cost: Option, - wedged: bool, - }, - Linked { - linker: Option, - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, - RemovedFromMam, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMetaDiff { - pub field: TorrentMetaField, - pub from: String, - pub to: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum TorrentMetaField { - MamId, - Vip, - MainCat, - Cat, - Language, - Flags, - Filetypes, - Size, - Title, - Authors, - Narrators, - Series, - Source, -} - -impl From for Torrent { - fn from(t: v10::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - linker: None, - category: None, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v10::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - hash: None, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: None, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v10::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v10::ErroredTorrent) -> Self { - Self { - id: t.id.into(), - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for ErroredTorrentId { - fn from(t: v01::ErroredTorrentId) -> Self { - match t { - v01::ErroredTorrentId::Grabber(id) => ErroredTorrentId::Grabber(id), - v01::ErroredTorrentId::Linker(hash) => ErroredTorrentId::Linker(hash), - v01::ErroredTorrentId::Cleaner(hash) => ErroredTorrentId::Cleaner(hash), - } - } -} - -impl From for TorrentMeta { - fn from(t: v10::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: None, - main_cat: t.main_cat, - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for Event { - fn from(t: v10::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v10::EventType) -> Self { - match t { - v10::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v10::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v10::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v10::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v10::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v08::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v08::TorrentMetaField) -> Self { - match value { - v08::TorrentMetaField::MamId => TorrentMetaField::MamId, - v08::TorrentMetaField::MainCat => TorrentMetaField::MainCat, - v08::TorrentMetaField::Cat => TorrentMetaField::Cat, - v08::TorrentMetaField::Language => TorrentMetaField::Language, - v08::TorrentMetaField::Flags => TorrentMetaField::Flags, - v08::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v08::TorrentMetaField::Size => TorrentMetaField::Size, - v08::TorrentMetaField::Title => TorrentMetaField::Title, - v08::TorrentMetaField::Authors => TorrentMetaField::Authors, - v08::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v08::TorrentMetaField::Series => TorrentMetaField::Series, - } - } -} - -impl From for Torrent { - fn from(t: v12::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - library_path: t.library_path, - library_files: t.library_files, - linker: None, - category: None, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v12::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - hash: None, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: None, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v12::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v12::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v12::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: None, - main_cat: t.media_type.into(), - cat: t.cat, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for v01::MainCat { - fn from(value: v12::MediaType) -> Self { - match value { - v12::MediaType::Audiobook => v01::MainCat::Audio, - v12::MediaType::Ebook => v01::MainCat::Ebook, - v12::MediaType::Musicology => unimplemented!(), - v12::MediaType::Radio => unimplemented!(), - v12::MediaType::Manga => v01::MainCat::Ebook, - v12::MediaType::ComicBook => v01::MainCat::Ebook, - v12::MediaType::Periodical => v01::MainCat::Ebook, - } - } -} - -impl From for Event { - fn from(t: v12::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v12::EventType) -> Self { - match t { - v12::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v12::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v12::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v12::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v12::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v12::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v12::TorrentMetaField) -> Self { - match value { - v12::TorrentMetaField::MamId => TorrentMetaField::MamId, - v12::TorrentMetaField::Vip => TorrentMetaField::Vip, - v12::TorrentMetaField::Cat => TorrentMetaField::Cat, - v12::TorrentMetaField::MediaType => TorrentMetaField::MainCat, - v12::TorrentMetaField::MainCat => unimplemented!(), - v12::TorrentMetaField::Categories => unimplemented!(), - v12::TorrentMetaField::Language => TorrentMetaField::Language, - v12::TorrentMetaField::Flags => TorrentMetaField::Flags, - v12::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v12::TorrentMetaField::Size => TorrentMetaField::Size, - v12::TorrentMetaField::Title => TorrentMetaField::Title, - v12::TorrentMetaField::Authors => TorrentMetaField::Authors, - v12::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v12::TorrentMetaField::Series => TorrentMetaField::Series, - v12::TorrentMetaField::Source => TorrentMetaField::Source, - } - } -} diff --git a/mlm_db/src/v12.rs b/mlm_db/src/v12.rs deleted file mode 100644 index 20e1158a..00000000 --- a/mlm_db/src/v12.rs +++ /dev/null @@ -1,674 +0,0 @@ -use super::{v01, v03, v04, v06, v08, v09, v10, v11, v13, v14, v15, v17}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 12, from = v11::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 12, from = v11::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 12, from = v11::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 12, from = v11::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum MediaType { - Audiobook, - Ebook, - Musicology, - Radio, - Manga, - #[doc(alias = "GraphicNovel")] - ComicBook, - Periodical, - // PeriodicalEbook, - // PeriodicalAudiobook -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -pub enum MainCat { - Fiction, - Nonfiction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -pub enum Category { - Action, - Art, - Biographical, - Business, - Comedy, - CompleteEditionsMusic, - Computer, - Crafts, - Crime, - Drama, - Education, - FactualNews, - Fantasy, - Food, - Guitar, - Health, - Historical, - Home, - Horror, - Humor, - IndividualSheet, - Instructional, - Juvenile, - Language, - Lgbt, - LickLibraryLTP, - LickLibraryTechniques, - LiteraryClassics, - LitRPG, - Math, - Medicine, - Music, - MusicBook, - Mystery, - Nature, - Paranormal, - Philosophy, - Poetry, - Politics, - Reference, - Religion, - Romance, - Rpg, - Science, - ScienceFiction, - SelfHelp, - SheetCollection, - SheetCollectionMP3, - Sports, - Technology, - Thriller, - Travel, - UrbanFantasy, - Western, - YoungAdult, - Superheroes, - LiteraryFiction, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 12, from = v11::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub hash: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - grabber: Option, - cost: Option, - wedged: bool, - }, - Linked { - linker: Option, - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, - RemovedFromMam, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMetaDiff { - pub field: TorrentMetaField, - pub from: String, - pub to: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum TorrentMetaField { - MamId, - Vip, - Cat, - MediaType, - MainCat, - Categories, - Language, - Flags, - Filetypes, - Size, - Title, - Authors, - Narrators, - Series, - Source, -} - -impl From for Torrent { - fn from(t: v11::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: None, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v11::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: None, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v11::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v11::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v11::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.main_cat.into(), - main_cat: None, - categories: vec![], - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for MediaType { - fn from(value: v01::MainCat) -> Self { - match value { - v01::MainCat::Audio => MediaType::Audiobook, - v01::MainCat::Ebook => MediaType::Ebook, - } - } -} - -impl From for Event { - fn from(t: v11::Event) -> Self { - Self { - id: t.id, - hash: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v11::EventType) -> Self { - match t { - v11::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v11::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v11::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v11::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v11::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v11::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v11::TorrentMetaField) -> Self { - match value { - v11::TorrentMetaField::MamId => TorrentMetaField::MamId, - v11::TorrentMetaField::Vip => TorrentMetaField::Vip, - v11::TorrentMetaField::MainCat => TorrentMetaField::MediaType, - v11::TorrentMetaField::Cat => TorrentMetaField::Cat, - v11::TorrentMetaField::Language => TorrentMetaField::Language, - v11::TorrentMetaField::Flags => TorrentMetaField::Flags, - v11::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v11::TorrentMetaField::Size => TorrentMetaField::Size, - v11::TorrentMetaField::Title => TorrentMetaField::Title, - v11::TorrentMetaField::Authors => TorrentMetaField::Authors, - v11::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v11::TorrentMetaField::Series => TorrentMetaField::Series, - v11::TorrentMetaField::Source => TorrentMetaField::Source, - } - } -} - -impl From for Torrent { - fn from(t: v13::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v13::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v13::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v13::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v13::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type.into(), - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for MediaType { - fn from(value: v13::MediaType) -> Self { - match value { - v13::MediaType::Audiobook => MediaType::Audiobook, - v13::MediaType::Ebook => MediaType::Ebook, - v13::MediaType::Musicology => MediaType::Musicology, - v13::MediaType::Radio => MediaType::Radio, - v13::MediaType::Manga => MediaType::Manga, - v13::MediaType::ComicBook => MediaType::ComicBook, - v13::MediaType::PeriodicalEbook => MediaType::Periodical, - v13::MediaType::PeriodicalAudiobook => MediaType::Periodical, - } - } -} - -impl From for Category { - fn from(value: v14::Category) -> Self { - match value { - v14::Category::Action => Category::Action, - v14::Category::Art => Category::Art, - v14::Category::Biographical => Category::Biographical, - v14::Category::Business => Category::Business, - v14::Category::Comedy => Category::Comedy, - v14::Category::CompleteEditionsMusic => Category::CompleteEditionsMusic, - v14::Category::Computer => Category::Computer, - v14::Category::Crafts => Category::Crafts, - v14::Category::Crime => Category::Crime, - v14::Category::Dramatization => Category::Drama, - v14::Category::Education => Category::Education, - v14::Category::FactualNews => Category::FactualNews, - v14::Category::Fantasy => Category::Fantasy, - v14::Category::Food => Category::Food, - v14::Category::Guitar => Category::Guitar, - v14::Category::Health => Category::Health, - v14::Category::Historical => Category::Historical, - v14::Category::Home => Category::Home, - v14::Category::Horror => Category::Horror, - v14::Category::Humor => Category::Humor, - v14::Category::IndividualSheet => Category::IndividualSheet, - v14::Category::Instructional => Category::Instructional, - v14::Category::Juvenile => Category::Juvenile, - v14::Category::Language => Category::Language, - v14::Category::Lgbt => Category::Lgbt, - v14::Category::LickLibraryLTP => Category::LickLibraryLTP, - v14::Category::LickLibraryTechniques => Category::LickLibraryTechniques, - v14::Category::LiteraryClassics => Category::LiteraryClassics, - v14::Category::LitRPG => Category::LitRPG, - v14::Category::Math => Category::Math, - v14::Category::Medicine => Category::Medicine, - v14::Category::Music => Category::Music, - v14::Category::MusicBook => Category::MusicBook, - v14::Category::Mystery => Category::Mystery, - v14::Category::Nature => Category::Nature, - v14::Category::Paranormal => Category::Paranormal, - v14::Category::Philosophy => Category::Philosophy, - v14::Category::Poetry => Category::Poetry, - v14::Category::Politics => Category::Politics, - v14::Category::Reference => Category::Reference, - v14::Category::Religion => Category::Religion, - v14::Category::Romance => Category::Romance, - v14::Category::Rpg => Category::Rpg, - v14::Category::Science => Category::Science, - v14::Category::ScienceFiction => Category::ScienceFiction, - v14::Category::SelfHelp => Category::SelfHelp, - v14::Category::SheetCollection => Category::SheetCollection, - v14::Category::SheetCollectionMP3 => Category::SheetCollectionMP3, - v14::Category::Sports => Category::Sports, - v14::Category::Technology => Category::Technology, - v14::Category::Thriller => Category::Thriller, - v14::Category::Travel => Category::Travel, - v14::Category::UrbanFantasy => Category::UrbanFantasy, - v14::Category::Western => Category::Western, - v14::Category::YoungAdult => Category::YoungAdult, - v14::Category::Superheroes => Category::Superheroes, - v14::Category::LiteraryFiction => Category::LiteraryFiction, - v14::Category::ProgressionFantasy => Category::LitRPG, - v14::Category::DramaPlays => Category::Drama, - v14::Category::Unknown(_) => unimplemented!(), - } - } -} - -impl From for Event { - fn from(t: v15::Event) -> Self { - Self { - id: t.id, - hash: t.torrent_id, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event, - } - } -} - -impl From for EventType { - fn from(t: v17::EventType) -> Self { - match t { - v17::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v17::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v17::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v17::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v17::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v17::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v17::TorrentMetaField) -> Self { - match value { - v17::TorrentMetaField::MamId => TorrentMetaField::MamId, - v17::TorrentMetaField::Vip => TorrentMetaField::Vip, - v17::TorrentMetaField::MediaType => TorrentMetaField::MediaType, - v17::TorrentMetaField::MainCat => TorrentMetaField::MainCat, - v17::TorrentMetaField::Categories => TorrentMetaField::Categories, - v17::TorrentMetaField::Cat => TorrentMetaField::Cat, - v17::TorrentMetaField::Language => TorrentMetaField::Language, - v17::TorrentMetaField::Flags => TorrentMetaField::Flags, - v17::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v17::TorrentMetaField::Size => TorrentMetaField::Size, - v17::TorrentMetaField::Title => TorrentMetaField::Title, - v17::TorrentMetaField::Authors => TorrentMetaField::Authors, - v17::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v17::TorrentMetaField::Series => TorrentMetaField::Series, - v17::TorrentMetaField::Source => TorrentMetaField::Source, - v17::TorrentMetaField::Edition => unimplemented!(), - } - } -} diff --git a/mlm_db/src/v13.rs b/mlm_db/src/v13.rs deleted file mode 100644 index 0c7751f4..00000000 --- a/mlm_db/src/v13.rs +++ /dev/null @@ -1,312 +0,0 @@ -use super::{v03, v04, v06, v08, v09, v10, v11, v12, v14}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 13, from = v12::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 13, from = v12::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 13, from = v12::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 13, from = v12::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum MediaType { - Audiobook, - Ebook, - Musicology, - Radio, - Manga, - #[doc(alias = "GraphicNovel")] - ComicBook, - PeriodicalEbook, - PeriodicalAudiobook, -} - -impl From for Torrent { - fn from(t: v12::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v12::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v12::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v12::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v12::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type.into(), - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for MediaType { - fn from(value: v12::MediaType) -> Self { - match value { - v12::MediaType::Audiobook => MediaType::Audiobook, - v12::MediaType::Ebook => MediaType::Ebook, - v12::MediaType::Musicology => MediaType::Musicology, - v12::MediaType::Radio => MediaType::Radio, - v12::MediaType::Manga => MediaType::Manga, - v12::MediaType::ComicBook => MediaType::ComicBook, - v12::MediaType::Periodical => MediaType::PeriodicalEbook, - } - } -} - -impl From for Torrent { - fn from(t: v14::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v14::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v14::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v14::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v14::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories.into_iter().map(|c| c.into()).collect(), - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} diff --git a/mlm_db/src/v14.rs b/mlm_db/src/v14.rs deleted file mode 100644 index 07c86982..00000000 --- a/mlm_db/src/v14.rs +++ /dev/null @@ -1,481 +0,0 @@ -use super::{v03, v04, v06, v08, v09, v10, v11, v12, v13, v15}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 14, from = v13::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub hash: String, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 14, from = v13::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 14, from = v13::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 14, from = v13::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: v13::MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -pub enum Category { - Action, - Art, - Biographical, - Business, - Comedy, - CompleteEditionsMusic, - Computer, - Crafts, - Crime, - Dramatization, - Education, - FactualNews, - Fantasy, - Food, - Guitar, - Health, - Historical, - Home, - Horror, - Humor, - IndividualSheet, - Instructional, - Juvenile, - Language, - Lgbt, - LickLibraryLTP, - LickLibraryTechniques, - LiteraryClassics, - LitRPG, - Math, - Medicine, - Music, - MusicBook, - Mystery, - Nature, - Paranormal, - Philosophy, - Poetry, - Politics, - Reference, - Religion, - Romance, - Rpg, - Science, - ScienceFiction, - SelfHelp, - SheetCollection, - SheetCollectionMP3, - Sports, - Technology, - Thriller, - Travel, - UrbanFantasy, - Western, - YoungAdult, - Superheroes, - LiteraryFiction, - ProgressionFantasy, - DramaPlays, - Unknown(u8), -} - -impl From for Torrent { - fn from(t: v13::Torrent) -> Self { - Self { - hash: t.hash, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v13::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v13::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v13::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v13::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories.into_iter().map(|c| c.into()).collect(), - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for Category { - fn from(value: v12::Category) -> Self { - match value { - v12::Category::Action => Category::Action, - v12::Category::Art => Category::Art, - v12::Category::Biographical => Category::Biographical, - v12::Category::Business => Category::Business, - v12::Category::Comedy => Category::Comedy, - v12::Category::CompleteEditionsMusic => Category::CompleteEditionsMusic, - v12::Category::Computer => Category::Computer, - v12::Category::Crafts => Category::Crafts, - v12::Category::Crime => Category::Crime, - v12::Category::Drama => Category::Dramatization, - v12::Category::Education => Category::Education, - v12::Category::FactualNews => Category::FactualNews, - v12::Category::Fantasy => Category::Fantasy, - v12::Category::Food => Category::Food, - v12::Category::Guitar => Category::Guitar, - v12::Category::Health => Category::Health, - v12::Category::Historical => Category::Historical, - v12::Category::Home => Category::Home, - v12::Category::Horror => Category::Horror, - v12::Category::Humor => Category::Humor, - v12::Category::IndividualSheet => Category::IndividualSheet, - v12::Category::Instructional => Category::Instructional, - v12::Category::Juvenile => Category::Juvenile, - v12::Category::Language => Category::Language, - v12::Category::Lgbt => Category::Lgbt, - v12::Category::LickLibraryLTP => Category::LickLibraryLTP, - v12::Category::LickLibraryTechniques => Category::LickLibraryTechniques, - v12::Category::LiteraryClassics => Category::LiteraryClassics, - v12::Category::LitRPG => Category::LitRPG, - v12::Category::Math => Category::Math, - v12::Category::Medicine => Category::Medicine, - v12::Category::Music => Category::Music, - v12::Category::MusicBook => Category::MusicBook, - v12::Category::Mystery => Category::Mystery, - v12::Category::Nature => Category::Nature, - v12::Category::Paranormal => Category::Paranormal, - v12::Category::Philosophy => Category::Philosophy, - v12::Category::Poetry => Category::Poetry, - v12::Category::Politics => Category::Politics, - v12::Category::Reference => Category::Reference, - v12::Category::Religion => Category::Religion, - v12::Category::Romance => Category::Romance, - v12::Category::Rpg => Category::Rpg, - v12::Category::Science => Category::Science, - v12::Category::ScienceFiction => Category::ScienceFiction, - v12::Category::SelfHelp => Category::SelfHelp, - v12::Category::SheetCollection => Category::SheetCollection, - v12::Category::SheetCollectionMP3 => Category::SheetCollectionMP3, - v12::Category::Sports => Category::Sports, - v12::Category::Technology => Category::Technology, - v12::Category::Thriller => Category::Thriller, - v12::Category::Travel => Category::Travel, - v12::Category::UrbanFantasy => Category::UrbanFantasy, - v12::Category::Western => Category::Western, - v12::Category::YoungAdult => Category::YoungAdult, - v12::Category::Superheroes => Category::Superheroes, - v12::Category::LiteraryFiction => Category::LiteraryFiction, - } - } -} - -impl From for Torrent { - fn from(t: v15::Torrent) -> Self { - Self { - hash: t.id, - mam_id: t.meta.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v15::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v15::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v15::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v15::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories.into_iter().map(|c| c.into()).collect(), - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - } - } -} - -impl From for Category { - fn from(value: v15::Category) -> Self { - match value { - v15::Category::Action => Category::Action, - v15::Category::Art => Category::Art, - v15::Category::Biographical => Category::Biographical, - v15::Category::Business => Category::Business, - v15::Category::Comedy => Category::Comedy, - v15::Category::CompleteEditionsMusic => Category::CompleteEditionsMusic, - v15::Category::Computer => Category::Computer, - v15::Category::Crafts => Category::Crafts, - v15::Category::Crime => Category::Crime, - v15::Category::Dramatization => Category::Dramatization, - v15::Category::Education => Category::Education, - v15::Category::FactualNews => Category::FactualNews, - v15::Category::Fantasy => Category::Fantasy, - v15::Category::Food => Category::Food, - v15::Category::Guitar => Category::Guitar, - v15::Category::Health => Category::Health, - v15::Category::Historical => Category::Historical, - v15::Category::Home => Category::Home, - v15::Category::Horror => Category::Horror, - v15::Category::Humor => Category::Humor, - v15::Category::IndividualSheet => Category::IndividualSheet, - v15::Category::Instructional => Category::Instructional, - v15::Category::Juvenile => Category::Juvenile, - v15::Category::Language => Category::Language, - v15::Category::Lgbt => Category::Lgbt, - v15::Category::LickLibraryLTP => Category::LickLibraryLTP, - v15::Category::LickLibraryTechniques => Category::LickLibraryTechniques, - v15::Category::LiteraryClassics => Category::LiteraryClassics, - v15::Category::LitRPG => Category::LitRPG, - v15::Category::Math => Category::Math, - v15::Category::Medicine => Category::Medicine, - v15::Category::Music => Category::Music, - v15::Category::MusicBook => Category::MusicBook, - v15::Category::Mystery => Category::Mystery, - v15::Category::Nature => Category::Nature, - v15::Category::Paranormal => Category::Paranormal, - v15::Category::Philosophy => Category::Philosophy, - v15::Category::Poetry => Category::Poetry, - v15::Category::Politics => Category::Politics, - v15::Category::Reference => Category::Reference, - v15::Category::Religion => Category::Religion, - v15::Category::Romance => Category::Romance, - v15::Category::Rpg => Category::Rpg, - v15::Category::Science => Category::Science, - v15::Category::ScienceFiction => Category::ScienceFiction, - v15::Category::SelfHelp => Category::SelfHelp, - v15::Category::SheetCollection => Category::SheetCollection, - v15::Category::SheetCollectionMP3 => Category::SheetCollectionMP3, - v15::Category::Sports => Category::Sports, - v15::Category::Technology => Category::Technology, - v15::Category::Thriller => Category::Thriller, - v15::Category::Travel => Category::Travel, - v15::Category::UrbanFantasy => Category::UrbanFantasy, - v15::Category::Western => Category::Western, - v15::Category::YoungAdult => Category::YoungAdult, - v15::Category::Superheroes => Category::Superheroes, - v15::Category::LiteraryFiction => Category::LiteraryFiction, - v15::Category::ProgressionFantasy => Category::ProgressionFantasy, - v15::Category::ContemporaryFiction => Category::Unknown(59), - v15::Category::DramaPlays => Category::DramaPlays, - v15::Category::Unknown(id) => Category::Unknown(id), - } - } -} diff --git a/mlm_db/src/v15.rs b/mlm_db/src/v15.rs deleted file mode 100644 index a490c90d..00000000 --- a/mlm_db/src/v15.rs +++ /dev/null @@ -1,466 +0,0 @@ -use super::{v03, v04, v06, v08, v09, v10, v11, v12, v13, v14, v16, v17}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; -use time::UtcDateTime; -use uuid::Uuid; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 15, from = v14::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub id: String, - pub id_is_hash: bool, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 15, from = v14::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 15, from = v14::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 15, from = v14::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: v13::MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, - pub uploaded_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum Category { - Action, - Art, - Biographical, - Business, - Comedy, - CompleteEditionsMusic, - Computer, - Crafts, - Crime, - Dramatization, - Education, - FactualNews, - Fantasy, - Food, - Guitar, - Health, - Historical, - Home, - Horror, - Humor, - IndividualSheet, - Instructional, - Juvenile, - Language, - Lgbt, - LickLibraryLTP, - LickLibraryTechniques, - LiteraryClassics, - LitRPG, - Math, - Medicine, - Music, - MusicBook, - Mystery, - Nature, - Paranormal, - Philosophy, - Poetry, - Politics, - Reference, - Religion, - Romance, - Rpg, - Science, - ScienceFiction, - SelfHelp, - SheetCollection, - SheetCollectionMP3, - Sports, - Technology, - Thriller, - Travel, - UrbanFantasy, - Western, - YoungAdult, - Superheroes, - LiteraryFiction, - ProgressionFantasy, - ContemporaryFiction, - DramaPlays, - Unknown(u8), -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 15, from = v12::Event)] -#[native_db] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub torrent_id: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: v12::EventType, -} - -impl From for Torrent { - fn from(t: v14::Torrent) -> Self { - let id_is_hash = Uuid::parse_str(&t.hash).is_err(); - Self { - id: t.hash, - id_is_hash, - mam_id: t.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v14::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v14::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v14::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v14::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories.into_iter().map(|c| c.into()).collect(), - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - uploaded_at: v03::Timestamp(UtcDateTime::UNIX_EPOCH), - } - } -} - -impl From for Category { - fn from(value: v14::Category) -> Self { - match value { - v14::Category::Action => Category::Action, - v14::Category::Art => Category::Art, - v14::Category::Biographical => Category::Biographical, - v14::Category::Business => Category::Business, - v14::Category::Comedy => Category::Comedy, - v14::Category::CompleteEditionsMusic => Category::CompleteEditionsMusic, - v14::Category::Computer => Category::Computer, - v14::Category::Crafts => Category::Crafts, - v14::Category::Crime => Category::Crime, - v14::Category::Dramatization => Category::Dramatization, - v14::Category::Education => Category::Education, - v14::Category::FactualNews => Category::FactualNews, - v14::Category::Fantasy => Category::Fantasy, - v14::Category::Food => Category::Food, - v14::Category::Guitar => Category::Guitar, - v14::Category::Health => Category::Health, - v14::Category::Historical => Category::Historical, - v14::Category::Home => Category::Home, - v14::Category::Horror => Category::Horror, - v14::Category::Humor => Category::Humor, - v14::Category::IndividualSheet => Category::IndividualSheet, - v14::Category::Instructional => Category::Instructional, - v14::Category::Juvenile => Category::Juvenile, - v14::Category::Language => Category::Language, - v14::Category::Lgbt => Category::Lgbt, - v14::Category::LickLibraryLTP => Category::LickLibraryLTP, - v14::Category::LickLibraryTechniques => Category::LickLibraryTechniques, - v14::Category::LiteraryClassics => Category::LiteraryClassics, - v14::Category::LitRPG => Category::LitRPG, - v14::Category::Math => Category::Math, - v14::Category::Medicine => Category::Medicine, - v14::Category::Music => Category::Music, - v14::Category::MusicBook => Category::MusicBook, - v14::Category::Mystery => Category::Mystery, - v14::Category::Nature => Category::Nature, - v14::Category::Paranormal => Category::Paranormal, - v14::Category::Philosophy => Category::Philosophy, - v14::Category::Poetry => Category::Poetry, - v14::Category::Politics => Category::Politics, - v14::Category::Reference => Category::Reference, - v14::Category::Religion => Category::Religion, - v14::Category::Romance => Category::Romance, - v14::Category::Rpg => Category::Rpg, - v14::Category::Science => Category::Science, - v14::Category::ScienceFiction => Category::ScienceFiction, - v14::Category::SelfHelp => Category::SelfHelp, - v14::Category::SheetCollection => Category::SheetCollection, - v14::Category::SheetCollectionMP3 => Category::SheetCollectionMP3, - v14::Category::Sports => Category::Sports, - v14::Category::Technology => Category::Technology, - v14::Category::Thriller => Category::Thriller, - v14::Category::Travel => Category::Travel, - v14::Category::UrbanFantasy => Category::UrbanFantasy, - v14::Category::Western => Category::Western, - v14::Category::YoungAdult => Category::YoungAdult, - v14::Category::Superheroes => Category::Superheroes, - v14::Category::LiteraryFiction => Category::LiteraryFiction, - v14::Category::ProgressionFantasy => Category::ProgressionFantasy, - v14::Category::Unknown(59) => Category::ContemporaryFiction, - v14::Category::DramaPlays => Category::DramaPlays, - v14::Category::Unknown(id) => Category::Unknown(id), - } - } -} - -impl From for Event { - fn from(t: v12::Event) -> Self { - Self { - id: t.id, - torrent_id: t.hash, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event, - } - } -} - -impl From for Torrent { - fn from(t: v16::Torrent) -> Self { - Self { - id: t.id, - id_is_hash: t.id_is_hash, - mam_id: t.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v16::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v16::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v16::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v16::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat.map(|c| c.into()), - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - uploaded_at: t.uploaded_at, - } - } -} - -impl From for Event { - fn from(t: v17::Event) -> Self { - Self { - id: t.id, - torrent_id: t.torrent_id, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} diff --git a/mlm_db/src/v16.rs b/mlm_db/src/v16.rs deleted file mode 100644 index 8eb2ad8e..00000000 --- a/mlm_db/src/v16.rs +++ /dev/null @@ -1,332 +0,0 @@ -use super::{v03, v04, v06, v08, v09, v10, v11, v12, v13, v15, v17}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 16, from = v15::Torrent)] -#[native_db] -pub struct Torrent { - #[primary_key] - pub id: String, - pub id_is_hash: bool, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 16, from = v15::SelectedTorrent)] -#[native_db] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 16, from = v15::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 16, from = v15::ErroredTorrent)] -#[native_db] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: v13::MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub size: v03::Size, - pub title: String, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, - pub uploaded_at: v03::Timestamp, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum MusicologyCategory { - GuitarBassTabs, - IndividualSheet, - IndividualSheetMP3, - InstructionalBookWithVideo, - InstructionalMediaMusic, - LickLibraryLTPJamWith, - LickLibraryTechniquesQL, - MusicCompleteEditions, - MusicBook, - MusicBookMP3, - SheetCollection, - SheetCollectionMP3, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum RadioCategory { - Comedy, - Drama, - FactualDocumentary, - Reading, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] -pub enum OldCategory { - Audio(v06::AudiobookCategory), - Ebook(v06::EbookCategory), - Musicology(MusicologyCategory), - Radio(RadioCategory), -} - -impl From for Torrent { - fn from(t: v15::Torrent) -> Self { - Self { - id: t.id, - id_is_hash: t.id_is_hash, - mam_id: t.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v15::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v15::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v15::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v15::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat.map(|c| c.into()), - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - uploaded_at: t.uploaded_at, - } - } -} - -impl From for OldCategory { - fn from(value: v06::Category) -> Self { - match value { - v06::Category::Audio(audiobook_category) => Self::Audio(audiobook_category), - v06::Category::Ebook(ebook_category) => Self::Ebook(ebook_category), - } - } -} - -impl From for Torrent { - fn from(t: v17::Torrent) -> Self { - Self { - id: t.id, - id_is_hash: t.id_is_hash, - mam_id: t.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v17::SelectedTorrent) -> Self { - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: t.title_search, - meta: t.meta.into(), - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v17::DuplicateTorrent) -> Self { - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: t.title_search, - meta: t.meta.into(), - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v17::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v17::TorrentMeta) -> Self { - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - size: t.size, - title: t.title, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - uploaded_at: t.uploaded_at, - } - } -} diff --git a/mlm_db/src/v17.rs b/mlm_db/src/v17.rs deleted file mode 100644 index c85a62ce..00000000 --- a/mlm_db/src/v17.rs +++ /dev/null @@ -1,350 +0,0 @@ -use super::{v03, v04, v08, v09, v10, v11, v12, v13, v15, v16}; -use mlm_parse::{normalize_title, parse_edition}; -use native_db::{ToKey, native_db}; -use native_model::{Model, native_model}; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 2, version = 17, from = v16::Torrent)] -#[native_db(export_keys = true)] -pub struct Torrent { - #[primary_key] - pub id: String, - pub id_is_hash: bool, - #[secondary_key(unique)] - pub mam_id: u64, - pub abs_id: Option, - pub goodreads_id: Option, - pub library_path: Option, - pub library_files: Vec, - pub linker: Option, - pub category: Option, - pub selected_audio_format: Option, - pub selected_ebook_format: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - #[secondary_key] - pub created_at: v03::Timestamp, - pub replaced_with: Option<(String, v03::Timestamp)>, - pub request_matadata_update: bool, - pub library_mismatch: Option, - pub client_status: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 3, version = 17, from = v16::SelectedTorrent)] -#[native_db(export_keys = true)] -pub struct SelectedTorrent { - #[primary_key] - pub mam_id: u64, - pub goodreads_id: Option, - #[secondary_key(unique, optional)] - pub hash: Option, - pub dl_link: String, - pub unsat_buffer: Option, - pub wedge_buffer: Option, - pub cost: v04::TorrentCost, - pub category: Option, - pub tags: Vec, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub grabber: Option, - pub created_at: v03::Timestamp, - pub started_at: Option, - pub removed_at: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 4, version = 17, from = v16::DuplicateTorrent)] -#[native_db] -pub struct DuplicateTorrent { - #[primary_key] - pub mam_id: u64, - pub dl_link: Option, - #[secondary_key] - pub title_search: String, - pub meta: TorrentMeta, - pub created_at: v03::Timestamp, - pub duplicate_of: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 5, version = 17, from = v16::ErroredTorrent)] -#[native_db(export_keys = true)] -pub struct ErroredTorrent { - #[primary_key] - pub id: v11::ErroredTorrentId, - pub title: String, - pub error: String, - pub meta: Option, - #[secondary_key] - pub created_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -pub struct TorrentMeta { - pub mam_id: u64, - pub vip_status: Option, - pub cat: Option, - pub media_type: v13::MediaType, - pub main_cat: Option, - pub categories: Vec, - pub language: Option, - pub flags: Option, - pub filetypes: Vec, - pub num_files: u64, - pub size: v03::Size, - pub title: String, - pub edition: Option<(String, u64)>, - pub authors: Vec, - pub narrators: Vec, - pub series: Vec, - pub source: v10::MetadataSource, - pub uploaded_at: v03::Timestamp, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[native_model(id = 6, version = 17, from = v15::Event)] -#[native_db(export_keys = true)] -pub struct Event { - #[primary_key] - pub id: v03::Uuid, - #[secondary_key] - pub torrent_id: Option, - #[secondary_key] - pub mam_id: Option, - #[secondary_key] - pub created_at: v03::Timestamp, - pub event: EventType, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum EventType { - Grabbed { - grabber: Option, - cost: Option, - wedged: bool, - }, - Linked { - linker: Option, - library_path: PathBuf, - }, - Cleaned { - library_path: PathBuf, - files: Vec, - }, - Updated { - fields: Vec, - }, - RemovedFromMam, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct TorrentMetaDiff { - pub field: TorrentMetaField, - pub from: String, - pub to: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum TorrentMetaField { - MamId, - Vip, - Cat, - MediaType, - MainCat, - Categories, - Language, - Flags, - Filetypes, - Size, - Title, - Edition, - Authors, - Narrators, - Series, - Source, -} - -impl From for Torrent { - fn from(t: v16::Torrent) -> Self { - let meta: TorrentMeta = t.meta.into(); - Self { - id: t.id, - id_is_hash: t.id_is_hash, - mam_id: t.mam_id, - abs_id: t.abs_id, - goodreads_id: t.goodreads_id, - library_path: t.library_path, - library_files: t.library_files, - linker: t.linker, - category: t.category, - selected_audio_format: t.selected_audio_format, - selected_ebook_format: t.selected_ebook_format, - title_search: normalize_title(&meta.title), - meta, - created_at: t.created_at, - replaced_with: t.replaced_with, - request_matadata_update: t.request_matadata_update, - library_mismatch: t.library_mismatch, - client_status: t.client_status, - } - } -} - -impl From for SelectedTorrent { - fn from(t: v16::SelectedTorrent) -> Self { - let meta: TorrentMeta = t.meta.into(); - Self { - mam_id: t.mam_id, - goodreads_id: t.goodreads_id, - hash: t.hash, - dl_link: t.dl_link, - unsat_buffer: t.unsat_buffer, - wedge_buffer: None, - cost: t.cost, - category: t.category, - tags: t.tags, - title_search: normalize_title(&meta.title), - meta, - grabber: t.grabber, - created_at: t.created_at, - started_at: t.started_at, - removed_at: t.removed_at, - } - } -} - -impl From for DuplicateTorrent { - fn from(t: v16::DuplicateTorrent) -> Self { - let meta: TorrentMeta = t.meta.into(); - Self { - mam_id: t.mam_id, - dl_link: t.dl_link, - title_search: normalize_title(&meta.title), - meta, - created_at: t.created_at, - duplicate_of: t.duplicate_of, - } - } -} - -impl From for ErroredTorrent { - fn from(t: v16::ErroredTorrent) -> Self { - Self { - id: t.id, - title: t.title, - error: t.error, - meta: t.meta.map(|t| t.into()), - created_at: t.created_at, - } - } -} - -impl From for TorrentMeta { - fn from(t: v16::TorrentMeta) -> Self { - let (title, edition) = parse_edition(&t.title, ""); - - Self { - mam_id: t.mam_id, - vip_status: t.vip_status, - cat: t.cat, - media_type: t.media_type, - main_cat: t.main_cat, - categories: t.categories, - language: t.language, - flags: t.flags, - filetypes: t.filetypes, - num_files: 0, - size: t.size, - title, - edition, - authors: t.authors, - narrators: t.narrators, - series: t.series, - source: t.source, - uploaded_at: t.uploaded_at, - } - } -} - -impl From for Event { - fn from(t: v15::Event) -> Self { - Self { - id: t.id, - torrent_id: t.torrent_id, - mam_id: t.mam_id, - created_at: t.created_at, - event: t.event.into(), - } - } -} - -impl From for EventType { - fn from(t: v12::EventType) -> Self { - match t { - v12::EventType::Grabbed { - grabber, - cost, - wedged, - } => Self::Grabbed { - grabber, - cost, - wedged, - }, - v12::EventType::Linked { - linker, - library_path, - } => Self::Linked { - linker, - library_path, - }, - v12::EventType::Cleaned { - library_path, - files, - } => Self::Cleaned { - library_path, - files, - }, - v12::EventType::Updated { fields } => Self::Updated { - fields: fields.into_iter().map(Into::into).collect(), - }, - v12::EventType::RemovedFromMam => Self::RemovedFromMam, - } - } -} - -impl From for TorrentMetaDiff { - fn from(value: v12::TorrentMetaDiff) -> Self { - Self { - field: value.field.into(), - from: value.from, - to: value.to, - } - } -} - -impl From for TorrentMetaField { - fn from(value: v12::TorrentMetaField) -> Self { - match value { - v12::TorrentMetaField::MamId => TorrentMetaField::MamId, - v12::TorrentMetaField::Vip => TorrentMetaField::Vip, - v12::TorrentMetaField::MediaType => TorrentMetaField::MediaType, - v12::TorrentMetaField::MainCat => TorrentMetaField::MainCat, - v12::TorrentMetaField::Categories => TorrentMetaField::Categories, - v12::TorrentMetaField::Cat => TorrentMetaField::Cat, - v12::TorrentMetaField::Language => TorrentMetaField::Language, - v12::TorrentMetaField::Flags => TorrentMetaField::Flags, - v12::TorrentMetaField::Filetypes => TorrentMetaField::Filetypes, - v12::TorrentMetaField::Size => TorrentMetaField::Size, - v12::TorrentMetaField::Title => TorrentMetaField::Title, - v12::TorrentMetaField::Authors => TorrentMetaField::Authors, - v12::TorrentMetaField::Narrators => TorrentMetaField::Narrators, - v12::TorrentMetaField::Series => TorrentMetaField::Series, - v12::TorrentMetaField::Source => TorrentMetaField::Source, - } - } -} diff --git a/mlm_mam/Cargo.toml b/mlm_mam/Cargo.toml deleted file mode 100644 index d64f548a..00000000 --- a/mlm_mam/Cargo.toml +++ /dev/null @@ -1,30 +0,0 @@ -[package] -name = "mlm_mam" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0.100" -bytes = "1.11.0" -cookie = "0.18.1" -itertools = "0.14.0" -mlm_db = { path = "../mlm_db" } -mlm_parse = { path = "../mlm_parse" } -native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } -native_model = "0.4.20" -once_cell = "1.21.3" -reqwest = "0.12.20" -reqwest_cookie_store = "0.8.0" -serde = "1.0.136" -serde_derive = "1.0.136" -serde_json = "1.0.140" -serde-nested-json = "0.1.3" -thiserror = "2.0.17" -time = { version = "0.3.41", features = [ - "formatting", - "local-offset", - "macros", - "serde", -] } -tokio = { version = "1.45.1", features = ["full"] } -tracing = "0.1" diff --git a/mlm_mam/src/api.rs b/mlm_mam/src/api.rs deleted file mode 100644 index eb7ad66d..00000000 --- a/mlm_mam/src/api.rs +++ /dev/null @@ -1,367 +0,0 @@ -use std::{ - sync::Arc, - time::{Duration, SystemTime}, -}; - -use anyhow::{Error, Result, bail}; -use bytes::Bytes; -use cookie::Cookie; -use mlm_db::DatabaseExt as _; -use native_db::Database; -use reqwest::Url; -use reqwest_cookie_store::CookieStoreRwLock; -use serde::{Deserialize, Serialize}; -use time::{OffsetDateTime, UtcDateTime}; -use tokio::sync::Mutex; -use tracing::{debug, error, info, trace, warn}; - -use crate::{ - // config::{Config, SnatchlistType}, - enums::SnatchlistType, - search::{MaMTorrent, SearchError, SearchFields, SearchQuery, SearchResult, Tor}, - user_data::UserResponse, - user_torrent::UserDetailsTorrentResponse, -}; - -#[derive(Debug, Serialize, Deserialize)] -pub struct BonusBuyResult { - pub success: bool, - pub error: Option, -} - -#[derive(thiserror::Error, Debug)] -#[error("Hit MaM rate limit")] -pub struct RateLimitError; - -impl RateLimitError { - pub fn maybe(error: reqwest::Error) -> anyhow::Error { - if error.status().is_some_and(|s| s == 429) { - anyhow::Error::new(RateLimitError) - } else { - anyhow::Error::new(error) - } - } - - pub fn maybe_resp(response: &reqwest::Response) -> Result<(), anyhow::Error> { - if response.status() == 429 { - Err(anyhow::Error::new(RateLimitError)) - } else { - Ok(()) - } - } -} - -#[derive(thiserror::Error, Debug)] -pub enum WedgeBuyError { - #[error("torrent is VIP")] - IsVip, - #[error("torrent is global freeleech")] - IsGlobalFreeleech, - #[error("torrent is personal freeleech")] - IsPersonalFreeleech, - #[error("Unknown error: {0}")] - Unknown(String), -} - -pub struct MaM<'a> { - jar: Arc, - client: reqwest::Client, - db: Arc>, - pub user: Arc>>, -} - -impl<'a> MaM<'a> { - pub async fn new(mam_id: &str, db: Arc>) -> Result> { - let jar: CookieStoreRwLock = Default::default(); - let url = "https://www.myanonamouse.net/json".parse::().unwrap(); - - let stored_mam_id = db - .r_transaction() - .and_then(|r| r.get().primary::("mam_id")) - .ok() - .flatten() - .map(|c| c.value); - if stored_mam_id.is_some() { - info!("Restoring mam_id"); - } else { - info!("Using mam_id from config"); - } - let has_stored_mam_id = stored_mam_id.is_some(); - let cookie = Cookie::build(("mam_id", stored_mam_id.unwrap_or(mam_id.to_owned()))) - .expires(OffsetDateTime::now_local()? + Duration::from_mins(10)) - .build(); - jar.write() - .unwrap() - .store_response_cookies([cookie].into_iter(), &url); - - let jar = Arc::new(jar); - let client = reqwest::Client::builder() - .cookie_provider(jar.clone()) - .user_agent("MLM") - .timeout(Duration::from_secs(20)) - .build()?; - - let mam = MaM { - jar, - client, - db, - user: Default::default(), - }; - if let Err(err) = mam.user_info().await { - if has_stored_mam_id { - warn!("Stored mam_id failed with {err}, falling back to config value"); - let cookie = Cookie::build(("mam_id", mam_id.to_owned())) - .expires(OffsetDateTime::now_local()? + Duration::from_mins(10)) - .build(); - mam.jar - .write() - .unwrap() - .store_response_cookies([cookie].into_iter(), &url); - - // Retry with user_info() - mam.user_info().await?; - } else { - return Err(err); - } - } - - Ok(mam) - } - - pub async fn check_mam_id(&self) -> Result<()> { - let resp = self - .client - .get("https://www.myanonamouse.net/json/checkCookie.php") - .send() - .await?; - - RateLimitError::maybe_resp(&resp)?; - - let status = resp.status(); - let text = resp.text().await?; - info!("checked mam_id: {text:?}"); - - if status.is_client_error() || status.is_server_error() { - bail!("Bad status for checkCookie: {}", status) - } - - // Check if the body contains "Success":true - if !text.contains("\"Success\":true") { - bail!("Session check failed (Success: false in response): {}", text) - } - - self.store_cookies().await; - Ok(()) - } - - pub async fn user_info(&self) -> Result { - let mut cache = self.user.lock().await; - if let Some((at, user_info)) = cache.as_ref() - && SystemTime::now() - .duration_since(*at) - .is_ok_and(|d| d < Duration::from_secs(60)) - { - return Ok(user_info.clone()); - } - let resp: UserResponse = self - .client - .get("https://www.myanonamouse.net/jsonLoad.php?snatch_summary=true") - .send() - .await? - .error_for_status() - .map_err(RateLimitError::maybe)? - .json() - .await?; - self.store_cookies().await; - cache.replace((SystemTime::now(), resp.clone())); - Ok(resp) - } - - pub async fn cached_user_info(&self) -> Option { - self.user - .lock() - .await - .as_ref() - .map(|(_, user_info)| user_info.clone()) - } - - pub async fn add_unsats(&self, unsats: u64) { - if let Some((_, user)) = self.user.lock().await.as_mut() { - user.unsat.count += unsats; - } - } - - pub async fn get_torrent_file(&self, dl_hash: &str) -> Result { - let resp = self - .client - .get(format!( - "https://www.myanonamouse.net/tor/download.php/{dl_hash}" - )) - .send() - .await? - .error_for_status() - .map_err(RateLimitError::maybe)? - .bytes() - .await?; - Ok(resp) - } - - pub async fn get_torrent_info(&self, hash: &str) -> Result> { - let mut resp = self - .search(&SearchQuery { - fields: SearchFields { - description: true, - isbn: true, - ..Default::default() - }, - tor: Tor { - hash: hash.to_string(), - ..Default::default() - }, - ..Default::default() - }) - .await?; - Ok(resp.data.pop().map(|mut t| { - t.fix(); - t - })) - } - pub async fn get_torrent_info_by_id(&self, mam_id: u64) -> Result> { - let mut resp = self - .search(&SearchQuery { - fields: SearchFields { - description: true, - media_info: true, - isbn: true, - dl_link: true, - ..Default::default() - }, - tor: Tor { - id: mam_id, - ..Default::default() - }, - ..Default::default() - }) - .await?; - Ok(resp.data.pop().map(|mut t| { - t.fix(); - t - })) - } - - pub async fn search(&self, query: &SearchQuery) -> Result { - debug!("search: {}", serde_json::to_string_pretty(query)?); - let resp = self - .client - .post("https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php") - .json(query) - .send() - .await? - .error_for_status() - .map_err(RateLimitError::maybe)? - .text() - .await?; - if let Ok(resp) = serde_json::from_str::(&resp) { - if resp.error == "Nothing returned, out of 0" { - return Ok(SearchResult::default()); - } else { - return Err(Error::msg(resp.error)); - } - }; - let mut resp: SearchResult = serde_json::from_str(&resp).map_err(|err| { - error!("Error parsing mam response: {err}\nResponse: {resp}"); - err - })?; - for t in &mut resp.data { - t.fix(); - } - self.store_cookies().await; - Ok(resp) - } - - pub async fn snatchlist( - &self, - kind: SnatchlistType, - page: u64, - timestamp: UtcDateTime, - ) -> Result { - debug!("snatchlist: {:?} {}", kind, page); - let user = self.user_info().await?; - let resp = self - .client - .get("https://cdn.myanonamouse.net/json/loadUserDetailsTorrents.php") - .query(&( - ("uid", user.uid.to_string()), - ("iteration", page.to_string()), - ("type", kind.to_string()), - ("cacheTime", timestamp.unix_timestamp().to_string()), - )) - .send() - .await? - .error_for_status() - .map_err(RateLimitError::maybe)? - .text() - .await?; - let resp: UserDetailsTorrentResponse = serde_json::from_str(&resp).map_err(|err| { - error!("Error parsing mam response: {err}\nResponse: {resp}"); - err - })?; - self.store_cookies().await; - Ok(resp) - } - - pub async fn wedge_torrent(&self, mam_id: u64) -> Result<()> { - let timestamp = UtcDateTime::now().unix_timestamp() * 1000; - let resp: BonusBuyResult = self - .client - .get(format!( - "https://www.myanonamouse.net/json/bonusBuy.php/{timestamp}" - )) - .query(&[ - ("spendtype", "personalFL"), - ("torrentid", mam_id.to_string().as_str()), - ("timestamp", timestamp.to_string().as_str()), - ]) - .send() - .await? - .json() - .await?; - self.store_cookies().await; - if resp.success { - return Ok(()); - } - let err = match resp.error.as_deref() { - Some("This Torrent is VIP") => WedgeBuyError::IsVip, - Some("Cannot spend FL Wedges on Freeleech Picks") => WedgeBuyError::IsGlobalFreeleech, - Some("This is already a personal freeleech") => WedgeBuyError::IsPersonalFreeleech, - Some(err) => WedgeBuyError::Unknown(err.to_owned()), - None => WedgeBuyError::Unknown("No error message provided".to_owned()), - }; - Err(anyhow::Error::new(err)) - } - - async fn store_cookies(&self) { - let Ok(jar) = self.jar.read() else { - return; - }; - let Some(cookie) = jar.get("myanonamouse.net", "/", "mam_id") else { - return; - }; - let value = cookie.value().to_string(); - drop(jar); - let Ok((_guard, rw)) = self.db.rw_try() else { - return; - }; - let ok = rw - .upsert(mlm_db::Config { - key: "mam_id".to_string(), - value, - }) - .and_then(|_| rw.commit()) - .is_ok(); - - if ok { - trace!("stored new mam_id"); - } - } -} diff --git a/mlm_mam/src/enums.rs b/mlm_mam/src/enums.rs deleted file mode 100644 index a84812c5..00000000 --- a/mlm_mam/src/enums.rs +++ /dev/null @@ -1,400 +0,0 @@ -use std::{fmt, marker::PhantomData}; - -use mlm_db::{AudiobookCategory, EbookCategory, MusicologyCategory, RadioCategory}; -use serde::{ - Deserialize, Deserializer, Serialize, Serializer, - de::{self, SeqAccess, Visitor}, -}; - -#[derive(Debug, Deserialize, Clone, Copy)] -#[serde(rename_all = "lowercase")] -pub enum SearchTarget { - Bookmarks, - New, - Mine, - AllReseed, - MyReseed, - Uploader(u64), -} - -impl Serialize for SearchTarget { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - match self { - SearchTarget::Bookmarks => serializer.serialize_str("bookmarks"), - SearchTarget::New => serializer.serialize_str("torrents"), - SearchTarget::Mine => serializer.serialize_str("mine"), - SearchTarget::AllReseed => serializer.serialize_str("allRseed"), - SearchTarget::MyReseed => serializer.serialize_str("myReseed"), - SearchTarget::Uploader(id) => serializer.serialize_str(&format!("u{id}")), - } - } -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "lowercase")] -pub enum SearchIn { - Author, - Description, - Filenames, - FileTypes, - Narrator, - Series, - Tags, - Title, -} - -impl SearchIn { - pub fn as_str(&self) -> &str { - match self { - SearchIn::Author => "author", - SearchIn::Description => "description", - SearchIn::Filenames => "filenames", - SearchIn::FileTypes => "fileTypes", - SearchIn::Narrator => "narrator", - SearchIn::Series => "series", - SearchIn::Tags => "tags", - SearchIn::Title => "title", - } - } -} - -#[derive(Debug, Serialize, Deserialize, Clone, Copy)] -pub enum SearchKind { - /// Last update had 1+ seeders - #[serde(rename = "active")] - Active, - /// Last update has 0 seeders - #[serde(rename = "inactive")] - Inactive, - /// Freeleech torrents - #[serde(rename = "fl")] - Freeleech, - /// Freeleech or VIP torrents - #[serde(rename = "fl-VIP")] - Free, - /// VIP torrents - #[serde(rename = "VIP")] - Vip, - /// Torrents not VIP - #[serde(rename = "nVIP")] - NotVip, - /// Torrents missing meta data (old torrents) - #[serde(rename = "nMeta")] - NoMeta, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SnatchlistType { - Unsat, - InactUnsat, - SeedUnsat, - SeedSat, - InactSat, - UploadsActive, -} - -impl fmt::Display for SnatchlistType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - SnatchlistType::Unsat => write!(f, "unsat"), - SnatchlistType::InactUnsat => write!(f, "inactUnsat"), - SnatchlistType::SeedUnsat => write!(f, "seedUnsat"), - SnatchlistType::SeedSat => write!(f, "sSat"), - SnatchlistType::InactSat => write!(f, "inactSat"), - SnatchlistType::UploadsActive => write!(f, "upAct"), - } - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Categories { - #[serde(default = "default_categories_field")] - #[serde(deserialize_with = "categories_parser")] - pub audio: Option>, - #[serde(default = "default_categories_field")] - #[serde(deserialize_with = "categories_parser")] - pub ebook: Option>, - #[serde(default = "default_categories_field")] - #[serde(deserialize_with = "categories_parser")] - pub musicology: Option>, - #[serde(default = "default_categories_field")] - #[serde(deserialize_with = "categories_parser")] - pub radio: Option>, -} - -impl Categories { - pub fn get_main_cats(&self) -> Vec { - if self.audio.as_ref().is_some_and(|c| !c.is_empty()) - || self.ebook.as_ref().is_some_and(|c| !c.is_empty()) - || self.musicology.as_ref().is_some_and(|c| !c.is_empty()) - || self.radio.as_ref().is_some_and(|c| !c.is_empty()) - { - return vec![]; - } - - let main_cats = [ - self.audio - .as_ref() - .is_none_or(|c| !c.is_empty()) - .then_some(13), - self.ebook - .as_ref() - .is_none_or(|c| !c.is_empty()) - .then_some(14), - self.musicology - .as_ref() - .is_none_or(|c| !c.is_empty()) - .then_some(15), - self.radio - .as_ref() - .is_none_or(|c| !c.is_empty()) - .then_some(16), - ] - .into_iter() - .flatten() - .collect::>(); - - if main_cats.len() == 4 { - return vec![]; - } - - main_cats - } - - pub fn get_cats(&self) -> Vec { - if self.audio.as_ref().is_none_or(|c| c.is_empty()) - && self.ebook.as_ref().is_none_or(|c| c.is_empty()) - && self.musicology.as_ref().is_none_or(|c| c.is_empty()) - && self.radio.as_ref().is_none_or(|c| c.is_empty()) - { - return vec![]; - } - - self.audio - .clone() - .unwrap_or_else(AudiobookCategory::all) - .iter() - .map(|c| c.to_id()) - .chain( - self.ebook - .clone() - .unwrap_or_else(EbookCategory::all) - .iter() - .map(|c| c.to_id()), - ) - .chain( - self.musicology - .clone() - .unwrap_or_else(MusicologyCategory::all) - .iter() - .map(|c| c.to_id()), - ) - .chain( - self.radio - .clone() - .unwrap_or_else(RadioCategory::all) - .iter() - .map(|c| c.to_id()), - ) - .collect() - } - - pub fn matches(&self, category: u64) -> bool { - if let Some(cat) = AudiobookCategory::from_id(category) { - self.audio.as_ref().is_none_or(|cats| cats.contains(&cat)) - } else if let Some(cat) = EbookCategory::from_id(category) { - self.ebook.as_ref().is_none_or(|cats| cats.contains(&cat)) - } else if let Some(cat) = MusicologyCategory::from_id(category) { - self.musicology - .as_ref() - .is_none_or(|cats| cats.contains(&cat)) - } else if let Some(cat) = RadioCategory::from_id(category) { - self.radio.as_ref().is_none_or(|cats| cats.contains(&cat)) - } else { - false - } - } -} - -impl Default for Categories { - fn default() -> Self { - Self { - audio: None, - ebook: None, - musicology: Some(vec![]), - radio: Some(vec![]), - } - } -} - -fn categories_parser<'de, T, D>(deserializer: D) -> Result>, D::Error> -where - T: Deserialize<'de>, - T: TryFrom, - D: Deserializer<'de>, -{ - // This is a Visitor that forwards string types to T's `FromStr` impl and - // forwards map types to T's `Deserialize` impl. The `PhantomData` is to - // keep the compiler from complaining about T being an unused generic type - // parameter. We need T in order to know the Value type for the Visitor - // impl. - struct CategoriesParser(PhantomData T>); - - impl<'de, T> Visitor<'de> for CategoriesParser - where - T: Deserialize<'de>, - T: TryFrom, - { - type Value = Option>; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("bool or array") - } - - fn visit_bool(self, value: bool) -> Result - where - E: de::Error, - { - Ok(if value { None } else { Some(vec![]) }) - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - let mut values = vec![]; - loop { - let elm: Option = seq.next_element()?; - let Some(elm) = elm else { - return Ok(Some(values)); - }; - values.push(T::try_from(elm).map_err(serde::de::Error::custom)?); - } - } - } - - deserializer.deserialize_any(CategoriesParser(PhantomData)) -} - -fn default_categories_field() -> Option> { - Some(vec![]) -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(try_from = "String")] -#[allow(unused)] -pub enum UserClass { - Dev, - SysOp, - SrAdministrator, - Administrator, - UploaderCoordinator, - SrModerator, - Moderator, - TorrentMod, - ForumMod, - SupportStaff, - EntryLevelStaff, - Uploader, - Mouseketeer, - Supporter, - Elite, - EliteVip, - Vip, - PowerUser, - User, - Mouse, -} - -impl UserClass { - pub fn from_str(class: &str) -> Option { - match class { - "Dev" => Some(UserClass::Dev), - "SysOp" => Some(UserClass::SysOp), - "SR Administrator" => Some(UserClass::SrAdministrator), - "Administrator" => Some(UserClass::Administrator), - "Uploader Coordinator" => Some(UserClass::UploaderCoordinator), - "SR Moderator" => Some(UserClass::SrModerator), - "Moderator" => Some(UserClass::Moderator), - "Torrent Mod" => Some(UserClass::TorrentMod), - "Forum Mod" => Some(UserClass::ForumMod), - "Support Staff" => Some(UserClass::SupportStaff), - "Entry Level Staff" => Some(UserClass::EntryLevelStaff), - "Uploader" => Some(UserClass::Uploader), - "Mouseketeer" => Some(UserClass::Mouseketeer), - "Supporter" => Some(UserClass::Supporter), - "Elite" => Some(UserClass::Elite), - "Elite VIP" => Some(UserClass::EliteVip), - "VIP" => Some(UserClass::Vip), - "Power User" => Some(UserClass::PowerUser), - "User" => Some(UserClass::User), - "Mous" => Some(UserClass::Mouse), - _ => None, - } - } -} - -impl TryFrom for UserClass { - type Error = String; - - fn try_from(value: String) -> Result { - let v = Self::from_str(&value); - match v { - Some(v) => Ok(v), - None => Err(format!("invalid category {value}")), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_categories() { - let mut categories = Categories::default(); - assert_eq!(categories.get_main_cats(), vec![13, 14]); - assert_eq!(categories.get_cats(), Vec::::new()); - - categories.audio = Some(vec![]); - assert_eq!(categories.get_main_cats(), vec![14]); - assert_eq!(categories.get_cats(), Vec::::new()); - - categories.audio = None; - categories.ebook = Some(vec![]); - assert_eq!(categories.get_main_cats(), vec![13]); - assert_eq!(categories.get_cats(), Vec::::new()); - - categories.audio = None; - categories.ebook = Some(vec![EbookCategory::Food]); - assert_eq!(categories.get_main_cats(), Vec::::new()); - assert_eq!( - categories.get_cats(), - [ - AudiobookCategory::all() - .into_iter() - .map(AudiobookCategory::to_id) - .collect::>(), - vec![EbookCategory::Food.to_id()] - ] - .concat() - ); - - categories.audio = Some(vec![AudiobookCategory::Food]); - categories.ebook = Some(vec![EbookCategory::Food]); - assert_eq!(categories.get_main_cats(), Vec::::new()); - assert_eq!( - categories.get_cats(), - vec![AudiobookCategory::Food.to_id(), EbookCategory::Food.to_id()] - ); - - categories.audio = Some(vec![]); - categories.ebook = Some(vec![EbookCategory::Food]); - assert_eq!(categories.get_main_cats(), Vec::::new()); - assert_eq!(categories.get_cats(), vec![EbookCategory::Food.to_id()]); - } -} diff --git a/mlm_mam/src/lib.rs b/mlm_mam/src/lib.rs deleted file mode 100644 index 1b718a21..00000000 --- a/mlm_mam/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod api; -pub mod enums; -pub mod meta; -pub mod search; -pub mod serde; -pub mod user_data; -pub mod user_torrent; diff --git a/mlm_mam/src/meta.rs b/mlm_mam/src/meta.rs deleted file mode 100644 index dda47614..00000000 --- a/mlm_mam/src/meta.rs +++ /dev/null @@ -1,76 +0,0 @@ -use anyhow::{Error, Result}; -use mlm_db::{MediaType, OldCategory, TorrentMeta}; -use mlm_parse::{SERIES_CLEANUP, TITLE_CLEANUP, clean_name, clean_value, parse_edition}; - -#[derive(thiserror::Error, Debug)] -pub enum MetaError { - #[error("{0}")] - UnknownMediaType(String), - #[error("Unknown category: {0}")] - UnknownCat(u8), - #[error("Unknown old category: {0} ({1})")] - UnknownOldCat(String, u64), - #[error("Unknown language id {0}, code: {1}")] - UnknownLanguage(u8, String), - #[error("{0}")] - InvalidSize(String), - #[error("{0}")] - InvalidSeries(&'static str), - #[error("Invalid added date: {0}")] - InvalidAdded(String), - #[error("Invalid vip_expiry: {0}")] - InvalidVipExpiry(u64), - #[error("Unknown error: {0}")] - Other(#[from] Error), -} - -pub fn clean_meta(mut meta: TorrentMeta, tags: &str) -> Result { - // A large amount of audiobook torrents have been incorrectly set to ebook - if meta.media_type == MediaType::Ebook - && let Some(OldCategory::Audio(_)) = meta.cat - { - meta.media_type = MediaType::Audiobook; - } - for author in &mut meta.authors { - clean_name(author)?; - } - for narrator in &mut meta.narrators { - clean_name(narrator)?; - } - for series in &mut meta.series { - series.name = SERIES_CLEANUP - .replace_all(&clean_value(&series.name)?, "") - .to_string(); - } - - let (title, edition) = parse_edition(&meta.title, tags); - meta.title = title; - meta.edition = edition; - - // Apparently authors is getting removed from periodicals - if meta.media_type != MediaType::PeriodicalEbook - && meta.media_type != MediaType::PeriodicalAudiobook - && meta.authors.len() == 1 - && let Some(author) = meta.authors.first() - { - if let Some(title) = meta.title.strip_suffix(author) { - if let Some(title) = title - .strip_suffix(" by ") - .or_else(|| title.strip_suffix(" - ")) - { - meta.title = title.trim().to_string(); - } - } else if let Some(title) = meta.title.strip_prefix(author) - && let Some(title) = title.strip_prefix(" - ") - { - meta.title = title.trim().to_string(); - } - } - - meta.title = TITLE_CLEANUP - .replace_all(&meta.title, "") - .trim() - .to_string(); - - Ok(meta) -} diff --git a/mlm_mam/src/search.rs b/mlm_mam/src/search.rs deleted file mode 100644 index 269ce10e..00000000 --- a/mlm_mam/src/search.rs +++ /dev/null @@ -1,400 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::Result; -use mlm_db::{ - Category, FlagBits, Language, MainCat, MediaType, MetadataSource, OldCategory, Series, - SeriesEntries, Timestamp, TorrentMeta, VipStatus, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use time::UtcDateTime; -use tracing::warn; - -use crate::{ - enums::{SearchIn, SearchKind, SearchTarget}, - meta::{MetaError, clean_meta}, - serde::{ - DATE_TIME_FORMAT, bool_string_or_number, is_false, is_zero, json_or_default, - opt_string_or_number, string_or_number, vec_string_or_number, - }, -}; - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct SearchQuery { - #[serde(flatten)] - pub fields: SearchFields, - - /// int in range of 5 to 100, telling how many results to return - #[serde(skip_serializing_if = "is_zero")] - pub perpage: u64, - - pub tor: Tor, -} - -#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy)] -pub struct SearchFields { - /// If this parameter is set, it will display the full description field for the torrent. - #[serde(skip_serializing_if = "is_false")] - pub description: bool, - #[serde(skip_serializing_if = "is_false")] - #[serde(rename = "mediaInfo")] - pub media_info: bool, - /// show hash for dl link (prepend https://www.myanonamouse.net/tor/download.php/ to use) for downloading on something without a session cookie. Alternatively use session cookie and just hit https://www.myanonamouse.net/tor/download.php?tid=# replacing # with the id number. - #[serde(skip_serializing_if = "is_false")] - #[serde(rename = "dlLink")] - pub dl_link: bool, - /// If this value is set, will return the isbn field (though often blank). - #[serde(skip_serializing_if = "is_false")] - pub isbn: bool, - #[serde(skip_serializing_if = "is_false")] - pub thumbnail: bool, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct Tor { - #[serde(rename = "searchIn")] - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option, - #[serde(rename = "searchType")] - #[serde(skip_serializing_if = "Option::is_none")] - pub kind: Option, - - /// Text to search for - #[serde(skip_serializing_if = "String::is_empty")] - pub text: String, - #[serde(skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "srchIn")] - pub srch_in: Vec, - - /// List of integers for the languages you wish to view in results - #[serde(skip_serializing_if = "Vec::is_empty")] - pub browse_lang: Vec, - /// Array of ID(s) of the main category to include - #[serde(skip_serializing_if = "Vec::is_empty")] - pub main_cat: Vec, - /// List of integers for the categories you wish to view in results - #[serde(skip_serializing_if = "Vec::is_empty")] - pub cat: Vec, - - /// Date in format YYYY-MM-DD or unix timestamp of earliest torrent(s) to show. Inclusive of the provided value - #[serde(skip_serializing_if = "String::is_empty")] - #[serde(rename = "startDate")] - pub start_date: String, - /// Date in format YYYY-MM-DD or unix timestamp torrents should have been created before. Exclusive of value provided - #[serde(skip_serializing_if = "String::is_empty")] - #[serde(rename = "endDate")] - pub end_date: String, - - #[serde(skip_serializing_if = "is_zero")] - #[serde(rename = "minSize")] - pub min_size: u64, - #[serde(skip_serializing_if = "is_zero")] - #[serde(rename = "maxSize")] - pub max_size: u64, - #[serde(skip_serializing_if = "is_zero")] - pub unit: u64, - - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "minSeeders")] - pub min_seeders: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "maxSeeders")] - pub max_seeders: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "minLeechers")] - pub min_leechers: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "maxLeechers")] - pub max_leechers: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "minSnatched")] - pub min_snatched: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "maxSnatched")] - pub max_snatched: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - #[serde(rename = "browseFlagsHideVsShow")] - pub browse_flags_hide_vs_show: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - #[serde(rename = "browseFlags")] - pub browse_flags: Vec, - - /// Hexadecimal encoded hash from a torrent - #[serde(skip_serializing_if = "String::is_empty")] - pub hash: String, - - #[serde(skip_serializing_if = "is_zero")] - pub id: u64, - - // sortType enum 'titleAsc': By the Title, Descending order - // 'titleDesc': By the Title, Ascending order - // 'fileAsc': By number of files, Ascending Order - // 'fileDesc': By number of files, Descending Order - // 'sizeAsc': By size of the torrent, Ascending Order - // 'sizeDesc': By size of the torrent, Descending Order - // 'seedersAsc': By number of Seeders, Ascending Order - // 'seedersDesc': By number of Seeders, Descending Order - // 'leechersAsc': By number of Leechers, Ascending Order - // 'leechersDesc': By number of Leechers, Descending Order - // 'snatchedAsc': By number of times snatched, Ascending Order - // 'snatchedDesc': By number of times snatched, Descending Order - // 'dateAsc': By Date Added, Ascending Order - // 'dateDesc': By Date Added, Descending Order - // 'bmkaAsc': By date bookmarked, Ascending Order (Note: may return odd results if not bookmarked) - // 'bmkaDesc': By date bookmarked, Descending Order (Note: may return odd results if not bookmarked) - // 'reseedAsc': Date Reseed Request Added, Ascending Order (Note: may return odd results if no reseed request) - // 'reseedDesc': Date Reseed Request Added, Descending Order (Note: may return odd results if no reseed request) - // 'categoryAsc': Sorted by category (number) Ascending, followed by title Ascending - // 'categoryDesc': Sorted by category (number) Descending, followed by title Ascending - // 'random': random, duh - // 'default': - // If text search present: by weight DESC, then ID desceding, - // else if instead searchIn is 'myReseed' or 'allReseed': same as reseedAsc - // else if searchIn is Bookmarks: same as 'bmkaDesc' - // else same as 'dateDesc' - #[serde(rename = "sortType")] - #[serde(skip_serializing_if = "String::is_empty")] - pub sort_type: String, - - /// Number of entries to skip. Used in pagination. - #[serde(rename = "startNumber")] - #[serde(skip_serializing_if = "is_zero")] - pub start_number: u64, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct SearchResult { - pub perpage: usize, - pub start: usize, - pub data: Vec, - pub total: usize, - pub found: usize, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SearchError { - pub error: String, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MaMTorrent { - pub id: u64, - /// format: DATE_TIME_FORMAT - pub added: String, - #[serde(deserialize_with = "json_or_default")] - pub author_info: BTreeMap, - pub bookmarked: Option, - pub browseflags: u8, - /// Old MainCat field -> MediaType - pub main_cat: u8, - pub category: u64, - pub mediatype: u8, - pub maincat: u8, - #[serde(deserialize_with = "json_or_default")] - pub categories: Vec, - pub catname: String, - pub cat: String, - pub comments: u64, - #[serde(default)] - #[serde(deserialize_with = "opt_string_or_number")] - pub description: Option, - pub dl: Option, - pub filetype: String, - #[serde(default)] - #[serde(rename = "mediainfo")] - #[serde(deserialize_with = "json_or_default")] - pub media_info: Option, - #[serde(deserialize_with = "bool_string_or_number")] - pub fl_vip: bool, - #[serde(deserialize_with = "bool_string_or_number")] - pub free: bool, - #[serde(default)] - #[serde(deserialize_with = "opt_string_or_number")] - pub isbn: Option, - pub lang_code: String, - pub language: u8, - pub leechers: u64, - #[serde(deserialize_with = "bool_string_or_number")] - pub my_snatched: bool, - #[serde(deserialize_with = "json_or_default")] - pub narrator_info: BTreeMap, - pub numfiles: u64, - #[serde(default)] - pub owner: u64, - #[serde(default)] - #[serde(deserialize_with = "string_or_number")] - pub owner_name: String, - #[serde(deserialize_with = "json_or_default")] - pub ownership: Vec, - #[serde(deserialize_with = "bool_string_or_number")] - pub personal_freeleech: bool, - pub seeders: u64, - #[serde(deserialize_with = "json_or_default")] - pub series_info: BTreeMap>, - pub size: String, - #[serde(deserialize_with = "string_or_number")] - pub tags: String, - pub times_completed: u64, - pub thumbnail: Option, - #[serde(deserialize_with = "string_or_number")] - pub title: String, - #[serde(deserialize_with = "bool_string_or_number")] - pub vip: bool, - pub vip_expire: u64, - pub w: u64, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MediaInfo { - #[serde(rename = "General")] - pub general: MediaInfoGeneral, - #[serde(rename = "Audio1")] - pub audio: MediaInfoAudio, - #[serde(default)] - pub menu: Option, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MediaInfoGeneral { - #[serde(rename = "Duration")] - pub duration: String, - #[serde(rename = "Format")] - pub format: String, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MediaInfoAudio { - #[serde(rename = "Format")] - pub format: String, - #[serde(rename = "BitRate")] - pub bitrate: String, - #[serde(rename = "Channels")] - pub channels: u8, - #[serde(rename = "BitRate_Mode")] - pub mode: String, - #[serde(rename = "SamplingRate")] - pub sampling_rate: String, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct MediaInfoMenu { - #[serde(deserialize_with = "vec_string_or_number")] - extra: Vec, -} - -impl MaMTorrent { - pub fn as_meta(&self) -> Result { - let authors = self.author_info.values().cloned().collect(); - let narrators = self.narrator_info.values().cloned().collect(); - let series = self - .series_info - .values() - .map(|series| { - let Value::String(series_name) = series - .first() - .ok_or(MetaError::InvalidSeries("Missing series name"))? - else { - return Err(MetaError::InvalidSeries("Series name is not a string").into()); - }; - let Value::String(series_num) = series - .get(1) - .ok_or(MetaError::InvalidSeries("Missing series num"))? - else { - return Err(MetaError::InvalidSeries("Series num is not a string").into()); - }; - Series::try_from((series_name.clone(), series_num.clone())).or_else(|err| { - warn!("error parsing series num: {err}"); - Ok(Series { - name: series_name.to_string(), - entries: SeriesEntries::new(vec![]), - }) - }) - }) - .collect::>>()?; - - let media_type = MediaType::from_id(self.mediatype) - .or_else(|| MediaType::from_main_cat_id(self.main_cat)) - .ok_or_else(|| { - MetaError::UnknownMediaType(format!( - "Unknown mediatype {} and main_cat {}", - self.mediatype, self.main_cat - )) - })?; - let main_cat = MainCat::from_id(self.maincat); - let categories = self - .categories - .iter() - .map(|id| Category::from_id(*id).ok_or_else(|| MetaError::UnknownCat(*id))) - .collect::, _>>()?; - let cat = OldCategory::from_one_id(self.category) - .ok_or_else(|| MetaError::UnknownOldCat(self.catname.clone(), self.category))?; - - let language = Language::from_id(self.language) - .ok_or_else(|| MetaError::UnknownLanguage(self.language, self.lang_code.clone()))?; - let filetypes = self - .filetype - .split(" ") - .map(|t| t.to_owned()) - .collect::>(); - let size = self.size.parse().map_err(MetaError::InvalidSize)?; - let vip_status = if !self.vip { - VipStatus::NotVip - } else if self.vip_expire == 0 { - VipStatus::Permanent - } else { - VipStatus::Temp( - UtcDateTime::from_unix_timestamp(self.vip_expire as i64) - .map_err(|_| MetaError::InvalidVipExpiry(self.vip_expire))? - .date(), - ) - }; - let uploaded_at = match UtcDateTime::parse(&self.added, &DATE_TIME_FORMAT) { - Ok(added) => Timestamp::from(added), - Err(_) => { - return Err(MetaError::InvalidAdded(self.added.clone())); - } - }; - - Ok(clean_meta( - TorrentMeta { - mam_id: self.id, - vip_status: Some(vip_status), - media_type, - main_cat, - categories, - cat: Some(cat), - language: Some(language), - flags: Some(FlagBits::new(self.browseflags)), - filetypes, - num_files: self.numfiles, - size, - title: self.title.clone(), - edition: None, - authors, - narrators, - series, - source: MetadataSource::Mam, - uploaded_at, - }, - &self.tags, - )?) - } - - pub fn is_free(&self) -> bool { - self.free || self.personal_freeleech || self.fl_vip - } - - pub fn fix(&mut self) { - if self.ownership.len() == 2 { - let Some(id) = self.ownership.first().and_then(|v| v.as_u64()) else { - return; - }; - self.owner = id; - let Some(name) = self.ownership.get(1).and_then(|v| v.as_str()) else { - return; - }; - self.owner_name = name.to_owned(); - } - } -} diff --git a/mlm_mam/src/serde.rs b/mlm_mam/src/serde.rs deleted file mode 100644 index 52c07724..00000000 --- a/mlm_mam/src/serde.rs +++ /dev/null @@ -1,148 +0,0 @@ -use once_cell::sync::Lazy; -use serde::{Deserialize, Deserializer}; -use serde_json::Value; -use time::{ - Date, - format_description::{self, OwnedFormatItem}, -}; - -pub static DATE_FORMAT: Lazy = - Lazy::new(|| format_description::parse_owned::<2>("[year]-[month]-[day]").unwrap()); - -pub static DATE_TIME_FORMAT: Lazy = Lazy::new(|| { - format_description::parse_owned::<2>("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap() -}); - -pub fn is_false(value: &bool) -> bool { - !value -} - -pub fn is_zero(value: &u64) -> bool { - *value == 0 -} - -pub fn bool_string_or_number<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let v = Value::deserialize(deserializer)?; - match v { - Value::String(v) => Ok(v == "yes" || v == "1"), - Value::Number(v) => Ok(v.as_u64().unwrap_or_default() == 1), - _ => Err(serde::de::Error::custom("expected number or string")), - } -} - -pub fn num_string_or_number<'de, D, T>(deserializer: D) -> Result -where - D: Deserializer<'de>, - T: TryFrom, -{ - let v = Value::deserialize(deserializer)?; - match v { - Value::String(v) => Ok(v - .parse::() - .map_err(|_| serde::de::Error::custom("invalid number string")) - .and_then(|v| { - v.try_into() - .map_err(|_| serde::de::Error::custom("invalid number string")) - })?), - Value::Number(v) => Ok(v - .as_u64() - .ok_or_else(|| serde::de::Error::custom("invalid number")) - .and_then(|v| { - v.try_into() - .map_err(|_| serde::de::Error::custom("invalid number")) - })?), - _ => Err(serde::de::Error::custom("expected number or string")), - } -} - -pub fn opt_num_string_or_number<'de, D, T>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, - T: TryFrom, -{ - let v = Option::::deserialize(deserializer)?; - match v { - Some(Value::String(v)) => Ok(Some( - v.parse::() - .map_err(|_| serde::de::Error::custom("invalid number string")) - .and_then(|v| { - v.try_into() - .map_err(|_| serde::de::Error::custom("invalid number string")) - })?, - )), - Some(Value::Number(v)) => Ok(Some( - v.as_u64() - .ok_or_else(|| serde::de::Error::custom("invalid number")) - .and_then(|v| { - v.try_into() - .map_err(|_| serde::de::Error::custom("invalid number")) - })?, - )), - None => Ok(None), - _ => Err(serde::de::Error::custom("expected number or string")), - } -} - -pub fn opt_string_or_number<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let v = Option::::deserialize(deserializer)?; - match v { - Some(Value::String(v)) => Ok(Some(v)), - Some(Value::Number(v)) => Ok(Some(v.to_string())), - None => Ok(None), - _ => Err(serde::de::Error::custom("expected number or string")), - } -} - -pub fn vec_string_or_number<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let v = Vec::::deserialize(deserializer)?; - v.into_iter() - .filter_map(|v| match v { - Value::String(v) => Some(Ok(v)), - Value::Number(v) => Some(Ok(v.to_string())), - Value::Null => None, - _ => Some(Err(serde::de::Error::custom("expected number or string"))), - }) - .collect() -} - -pub fn string_or_number<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let v = Value::deserialize(deserializer)?; - match v { - Value::String(v) => Ok(v), - Value::Number(v) => Ok(v.to_string()), - _ => Err(serde::de::Error::custom("expected number or string")), - } -} - -pub fn json_or_default<'de, T, D>(deserializer: D) -> Result -where - T: Deserialize<'de> + Default, - D: Deserializer<'de>, -{ - let v: Result = serde_nested_json::deserialize(deserializer); - let Ok(v) = v else { - return Ok(T::default()); - }; - Ok(T::deserialize(v).unwrap_or_default()) -} - -pub fn parse_opt_date<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let v: Option = Deserialize::deserialize(deserializer)?; - v.map(|v| Date::parse(&v, &DATE_FORMAT).map_err(serde::de::Error::custom)) - .transpose() -} diff --git a/mlm_mam/src/user_data.rs b/mlm_mam/src/user_data.rs deleted file mode 100644 index e4475fa6..00000000 --- a/mlm_mam/src/user_data.rs +++ /dev/null @@ -1,83 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct UserResponse { - pub uid: u64, - pub username: String, - pub downloaded_bytes: f64, - pub uploaded_bytes: f64, - pub seedbonus: i64, - pub wedges: u64, - pub unsat: Unsats, - // pub classname: UserClass, - // pub connectable: String, - // pub country_code: Option, - // pub country_name: Option, - // pub created: u64, - // pub downloaded: String, - // pub duplicates: Duplicates, - // #[serde(rename = "inactHnr")] - // pub inact_hnr: InactHnr, - // #[serde(rename = "inactSat")] - // pub inact_sat: InactHnr, - // #[serde(rename = "inactUnsat")] - // pub inact_unsat: InactHnr, - // pub ipv6_mac: bool, - // pub ite: Ite, - // pub last_access: Option, - // pub last_access_ago: Option, - // pub leeching: InactHnr, - // pub partial: bool, - // pub ratio: f64, - // pub recently_deleted: u64, - // pub reseed: Reseed, - // #[serde(rename = "sSat")] - // pub s_sat: InactHnr, - // #[serde(rename = "seedHnr")] - // pub seed_hnr: InactHnr, - // #[serde(rename = "seedUnsat")] - // pub seed_unsat: InactHnr, - // #[serde(rename = "upAct")] - // pub up_act: InactHnr, - // #[serde(rename = "upInact")] - // pub up_inact: InactHnr, - // pub update: u64, - // pub uploaded: String, - // pub username: String, - // pub v6_connectable: bool, - // pub vip_until: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Unsats { - pub count: u64, - pub red: bool, - pub size: Option, - pub limit: u64, -} - -// #[derive(Debug, Serialize, Deserialize)] -// pub struct Duplicates { -// pub count: u64, -// pub red: bool, -// } -// -// #[derive(Debug, Serialize, Deserialize)] -// pub struct InactHnr { -// pub count: u64, -// pub red: bool, -// pub size: Option, -// } -// -// #[derive(Debug, Serialize, Deserialize)] -// pub struct Ite { -// pub count: u64, -// pub latest: u64, -// } -// -// #[derive(Debug, Serialize, Deserialize)] -// pub struct Reseed { -// pub count: u64, -// pub inactive: u64, -// pub red: bool, -// } diff --git a/mlm_mam/src/user_torrent.rs b/mlm_mam/src/user_torrent.rs deleted file mode 100644 index f8f19f08..00000000 --- a/mlm_mam/src/user_torrent.rs +++ /dev/null @@ -1,260 +0,0 @@ -use anyhow::Result; -use itertools::Itertools as _; -use mlm_db::{ - Category, FlagBits, MediaType, MetadataSource, OldCategory, Series, SeriesEntries, Timestamp, - TorrentMeta, VipStatus, -}; -use mlm_parse::clean_value; -use serde::{Deserialize, Serialize}; -use time::UtcDateTime; -use tracing::warn; - -use crate::{ - meta::{MetaError, clean_meta}, - serde::{bool_string_or_number, num_string_or_number, opt_num_string_or_number}, -}; - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct UserDetailsTorrentResponse { - pub rows: Vec, - pub success: bool, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct UserDetailsTorrent { - pub catname: String, - pub catimg: String, - #[serde(deserialize_with = "num_string_or_number")] - pub category: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub title: String, - #[serde(deserialize_with = "num_string_or_number")] - pub comments: u64, - /// format: DATE_TIME_FORMAT - pub last_seed: String, - pub size: String, - pub tags: String, - #[serde(rename = "browseFlags")] - #[serde(deserialize_with = "num_string_or_number")] - pub browse_flags: u8, - #[serde(deserialize_with = "bool_string_or_number")] - pub free: bool, - #[serde(deserialize_with = "bool_string_or_number")] - pub vip: bool, - #[serde(default)] - #[serde(deserialize_with = "opt_num_string_or_number")] - pub vip_expire: Option, - #[serde(deserialize_with = "num_string_or_number")] - pub times_completed: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub uploaded: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub downloaded: u64, - /// format: DATE_TIME_FORMAT - pub complete_date: String, - #[serde(deserialize_with = "num_string_or_number")] - pub complete_unix: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub seedtime: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub leechtime: u64, - #[serde(rename = "OneToOne")] - #[serde(deserialize_with = "bool_string_or_number")] - pub one_to_one: bool, - pub last_action: String, - #[serde(deserialize_with = "num_string_or_number")] - pub last_action_unix: u64, - /// format: DATE_TIME_FORMAT - pub start_date: String, - #[serde(deserialize_with = "num_string_or_number")] - pub start_unix: u64, - #[serde(deserialize_with = "bool_string_or_number")] - pub finished: bool, - #[serde(deserialize_with = "num_string_or_number")] - pub to_go: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub seeders: u64, - #[serde(deserialize_with = "num_string_or_number")] - pub leechers: u64, - #[serde(rename = "uploaderName")] - pub uploader_name: String, - #[serde(rename = "uploaderID")] - #[serde(deserialize_with = "num_string_or_number")] - pub uploader_id: u64, - #[serde(rename = "personalFree")] - #[serde(deserialize_with = "bool_string_or_number")] - pub personal_free: bool, - // #[serde(rename = "uploadPretty")] - // pub upload_pretty: String, - // #[serde(rename = "downloadPretty")] - // pub download_pretty: String, - // #[serde(rename = "ratioColor")] - // pub ratio_color: String, - // pub ratio: String, - // #[serde(rename = "seedtimeColor")] - // pub seedtime_color: String, - // #[serde(rename = "seedtimePretty")] - // pub seedtime_pretty: String, - // #[serde(rename = "leechtimePretty")] - // pub leechtime_pretty: String, - pub cat: String, - pub dl: String, - // #[serde(rename = "percentDone")] - // pub percent_done: f64, - pub to_go_pretty: String, - #[serde(rename = "STG")] - /// Seed Time to Go, format: "1d 12:10:28", "23:46:38" - pub stg: Option, - #[serde(default)] - pub author: Vec, - #[serde(default)] - pub series: Vec, - #[serde(default)] - pub categories: Vec, - #[serde(default)] - #[serde(rename = "fileTypes")] - pub file_types: Vec, - #[serde(default)] - pub narrator: Vec, - #[serde(rename = "fileTypesDisabled")] - #[serde(default)] - pub file_types_disabled: Vec, -} - -impl UserDetailsTorrent { - pub fn as_meta(&self) -> Result { - let authors = self - .author - .iter() - .sorted_by(|a, b| a.id.cmp(&b.id)) - .map(|a| a.name.clone()) - .collect(); - let narrators = self - .narrator - .iter() - .sorted_by(|a, b| a.id.cmp(&b.id)) - .map(|a| a.name.clone()) - .collect(); - let series = self - .series - .iter() - .sorted_by(|a, b| a.id.cmp(&b.id)) - .map(|series| { - Series::try_from((series.name.clone(), series.number.clone())).or_else(|err| { - warn!("error parsing series num: {err}"); - Ok(Series { - name: series.name.clone(), - entries: SeriesEntries::new(vec![]), - }) - }) - }) - .collect::>>()?; - - let cat = OldCategory::from_one_id(self.category) - .ok_or_else(|| MetaError::UnknownOldCat(self.catname.clone(), self.category))?; - let media_type = - MediaType::from_main_cat_id(cat.as_main_cat().as_id()).ok_or_else(|| { - MetaError::UnknownMediaType(format!( - "Unknown mediatype from old cat {}", - self.category - )) - })?; - let mut categories = self - .categories - .iter() - .map(|c| Category::from_id(c.id as u8).ok_or_else(|| MetaError::UnknownCat(c.id as u8))) - .collect::, _>>()?; - categories.sort(); - - let filetypes = self - .file_types - .iter() - .map(|t| t.name.to_owned()) - .collect::>(); - let size = self.size.parse().map_err(MetaError::InvalidSize)?; - - let vip_status = if !self.vip { - VipStatus::NotVip - } else if let Some(vip_expire) = self.vip_expire { - VipStatus::Temp( - UtcDateTime::from_unix_timestamp(vip_expire as i64) - .map_err(|_| MetaError::InvalidVipExpiry(vip_expire))? - .date(), - ) - } else { - VipStatus::Permanent - }; - - Ok(clean_meta( - TorrentMeta { - mam_id: self.id, - vip_status: Some(vip_status), - media_type, - // TODO: Currently main_cat isn't returned - main_cat: None, - categories, - cat: Some(cat), - // TODO: Currently language isn't returned - language: None, - flags: Some(FlagBits::new(self.browse_flags)), - filetypes, - // TODO: Currently num_files isn't returned - num_files: 0, - size, - title: clean_value(&self.title)?, - edition: None, - authors, - narrators, - series, - source: MetadataSource::Mam, - // TODO: Currently added isn't returned - uploaded_at: Timestamp::from(UtcDateTime::UNIX_EPOCH), - }, - &clean_value(&self.tags)?, - )?) - } -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Author { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MaMSeries { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, - pub number: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MaMCategory { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct FileType { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Narrator { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct FileTypesDisabled { - #[serde(deserialize_with = "num_string_or_number")] - pub id: u64, - pub name: String, -} diff --git a/mlm_parse/Cargo.toml b/mlm_parse/Cargo.toml deleted file mode 100644 index bc16e0af..00000000 --- a/mlm_parse/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "mlm_parse" -version = "0.1.0" -edition = "2024" - -[dependencies] -anyhow = "1.0.100" -htmlentity = "1.3.2" -once_cell = "1.21.3" -regex = "1.12.2" -unidecode = "0.3.0" diff --git a/mlm_parse/src/lib.rs b/mlm_parse/src/lib.rs deleted file mode 100644 index b8390c08..00000000 --- a/mlm_parse/src/lib.rs +++ /dev/null @@ -1,294 +0,0 @@ -use anyhow::Result; -use htmlentity::entity::{self, ICodedDataTrait as _}; -use once_cell::sync::Lazy; -use regex::{Captures, Match, Regex}; -use unidecode::unidecode; - -pub fn clean_value(value: &str) -> Result { - entity::decode(value.as_bytes()).to_string() -} - -pub fn normalize_title(value: &str) -> String { - let title = unidecode(value).to_lowercase().replace(" & ", " and "); - let title = SEARCH_TITLE_CLEANUP.replace_all(&title, ""); - SEARCH_TITLE_VOLUME.replace_all(&title, "").to_string() -} - -pub fn clean_name(name: &mut String) -> Result<()> { - *name = clean_value(name)?; - - let mut to_lowercase = vec![]; - let mut to_uppercase = vec![]; - let mut start = 0; - - let mut check_word = |start, end| { - let word = &name.get(start..end); - if let Some(word) = word - && word.len() > 3 - && word.chars().all(|c| c.is_uppercase()) - { - to_lowercase.push((start + 1)..end); - } else if let Some(word) = word - && word.len() > 3 - && word.chars().all(|c| c.is_lowercase()) - { - to_uppercase.push(start); - } - }; - for (i, char) in name.char_indices() { - if char == ' ' { - check_word(start, i); - start = i + 1; - } - } - check_word(start, name.len()); - - for range in to_lowercase { - let Some(part) = name.get(range.clone()) else { - continue; - }; - name.replace_range(range, &part.to_lowercase()); - } - for i in to_uppercase { - let Some(part) = name.get(i..=i) else { - continue; - }; - name.replace_range(i..=i, &part.to_uppercase()); - } - - Ok(()) -} - -static EDITION_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)^(.*?)(?:(?:(?:\s*[-–.:;|,]\s*)((\w+?)\s+(?:[a-z]+\s+)*(?:Edition|ed\.))|(?:\s*[-–.:;|,]\s*)?(?:\s*[(\[]\s*)((\w*?)\s+(?:[a-z]+\s+)*(?:Edition|ed\.))(?:\s*[)\]]\s*))(?:\s*[-:;,]\s*)?(.*?)|\s+((\d+\w*?)\s+(?:Edition|ed\.)))$").unwrap() -}); - -static EDITION_START_REGEX: Lazy = Lazy::new(|| { - Regex::new(r"(?i)((\d+(?:st|nd|rd|th)|first|second|third|fifth|sixth|seventh|eight|ninth|tenth|new|revised|updated)\s+(?:[a-z']+\s+)*(?:Edition|ed\.)|(\w+?)\s+(?:Edition|ed\.))").unwrap() -}); - -pub static TITLE_CLEANUP: Lazy = Lazy::new(|| { - Regex::new( - r"(?i)(?:: A (?:Novel|Memoir)$)|(?:: An? (?:\w+ )(?:Fantasy Adventure)$)|(?:: An? (?:\w+ ){1,3}(?:Romance)$)|(?:\s*-\s*\d+(?:\.| - )epub$)|(?:\s*[\(\[]\.?(?:digital|light novel|epub|pdf|cbz|cbr|mp3|m4b|tpb|fixed|unabridged)[\)\]])*", - ) - .unwrap() -}); - -static SEARCH_TITLE_CLEANUP: Lazy = - Lazy::new(|| Regex::new(r"(?i)^(?:the|a|an)\s+|[^\w ]").unwrap()); - -static SEARCH_TITLE_VOLUME: Lazy = - Lazy::new(|| Regex::new(r"(?i)(?:volume|vol\.)").unwrap()); - -pub static SERIES_CLEANUP: Lazy = - Lazy::new(|| Regex::new(r"(?i)(?:\s*\((?:digital|light novel)\))*").unwrap()); - -pub fn parse_edition(title: &str, tags: &str) -> (String, Option<(String, u64)>) { - if let Some(captures) = EDITION_REGEX.captures(title) - && let Some(edition) = parse_normal_edition_match(&captures) - { - let mut title_str = captures.get(1).unwrap().as_str().to_string(); - if let Some(subtitle_match) = captures.get(6) { - let subtitle_str = subtitle_match.as_str(); - if !subtitle_str.is_empty() { - title_str.push_str(&format!(": {}", subtitle_str.trim())); - } - } - return (title_str, Some(edition)); - } - - if let Some(captures) = EDITION_REGEX.captures(tags) - && let Some(edition) = parse_normal_edition_match(&captures) - { - return (title.to_string(), Some(edition)); - } - if let Some(captures) = EDITION_START_REGEX.captures(tags) - && let Some(edition) = parse_start_edition_match(&captures) - { - return (title.to_string(), Some(edition)); - } - - (title.to_string(), None) -} - -fn parse_normal_edition_match(captures: &Captures) -> Option<(String, u64)> { - let edition_match = captures - .get(2) - .or_else(|| captures.get(4)) - .or_else(|| captures.get(7))?; - - let edition_number = captures - .get(3) - .or_else(|| captures.get(5)) - .or_else(|| captures.get(8))?; - - parse_edition_match(edition_match, edition_number) -} -fn parse_start_edition_match(captures: &Captures) -> Option<(String, u64)> { - let edition_match = captures.get(1)?; - let edition_number = captures.get(2).or_else(|| captures.get(3))?; - - parse_edition_match(edition_match, edition_number) -} - -fn parse_edition_match(edition_match: Match, edition_number: Match) -> Option<(String, u64)> { - let edition_number = match edition_number.as_str().to_lowercase().as_str() { - "first" | "1st" => 1, - "second" | "2nd" => 2, - "third" | "3rd" => 3, - "fourth" | "4th" => 4, - "fifth" | "5th" => 5, - "sixth" | "6th" => 6, - "seventh" | "7th" => 7, - "eighth" | "8th" => 8, - "ninth" | "9th" => 9, - "tenth" | "10th" => 10, - n if n.ends_with("th") => n[..n.len() - 2].parse().unwrap_or(0), - n => n.parse().unwrap_or(0), - }; - let mut edition_str = edition_match.as_str().to_string(); - - let mut first_letter = true; - for (i, c) in edition_str.clone().char_indices() { - if first_letter - && !c.is_uppercase() - && let Some(char) = edition_str.get_mut(i..=i) - { - char.make_ascii_uppercase(); - } - first_letter = c == ' '; - } - if edition_str.ends_with("Ed.") { - edition_str.replace_range((edition_str.len() - 3).., "Edition"); - } - - Some((edition_str, edition_number)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_edition_base() { - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, None); - } - - #[test] - fn test_parse_edition_in_title() { - let (parsed_title, parsed_edition) = parse_edition("Title (1st edition)", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("1st Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = parse_edition("Title [2nd edition]", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("2nd Edition".to_string(), 2))); - - let (parsed_title, parsed_edition) = parse_edition("Title: 3rd Edition", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("3rd Edition".to_string(), 3))); - - let (parsed_title, parsed_edition) = parse_edition("Title, 10th Edition", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("10th Edition".to_string(), 10))); - - let (parsed_title, parsed_edition) = parse_edition("Title (first edition)", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("First Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = parse_edition("Title [Second edition]", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("Second Edition".to_string(), 2))); - - let (parsed_title, parsed_edition) = parse_edition("Title: Third Edition", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("Third Edition".to_string(), 3))); - - let (parsed_title, parsed_edition) = - parse_edition("Title, Fourth Canadian Edition", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!( - parsed_edition, - Some(("Fourth Canadian Edition".to_string(), 4)) - ); - - let (parsed_title, parsed_edition) = parse_edition("Title, 1st edition A subtitle", "Tags"); - assert_eq!(parsed_title, "Title: A subtitle"); - assert_eq!(parsed_edition, Some(("1st Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = - parse_edition("Title, and some more title, 1st edition A subtitle", "Tags"); - assert_eq!(parsed_title, "Title, and some more title: A subtitle"); - assert_eq!(parsed_edition, Some(("1st Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = - parse_edition("Title [Second edition] A subtitle", "Tags"); - assert_eq!(parsed_title, "Title: A subtitle"); - assert_eq!(parsed_edition, Some(("Second Edition".to_string(), 2))); - - let (parsed_title, parsed_edition) = - parse_edition("Title - 25th Anniversary Edition: A subtitle", "Tags"); - assert_eq!(parsed_title, "Title: A subtitle"); - assert_eq!( - parsed_edition, - Some(("25th Anniversary Edition".to_string(), 25)) - ); - - let (parsed_title, parsed_edition) = - parse_edition("Title - 2012 Edition, A subtitle", "Tags"); - assert_eq!(parsed_title, "Title: A subtitle"); - assert_eq!(parsed_edition, Some(("2012 Edition".to_string(), 2012))); - - let (parsed_title, parsed_edition) = parse_edition("Title (UK Edition)", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("UK Edition".to_string(), 0))); - - let (parsed_title, parsed_edition) = parse_edition("Title (New Edition)", "Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("New Edition".to_string(), 0))); - - let (parsed_title, parsed_edition) = parse_edition("Title: A subtitle 3rd Edition", "Tags"); - assert_eq!(parsed_title, "Title: A subtitle"); - assert_eq!(parsed_edition, Some(("3rd Edition".to_string(), 3))); - } - - #[test] - fn test_parse_edition_in_tags() { - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags (1st edition)"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("1st Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags [2nd edition]"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("2nd Edition".to_string(), 2))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags | 3rd Edition"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("3rd Edition".to_string(), 3))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags, 10th Edition"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("10th Edition".to_string(), 10))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Tags | first edition | more"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("First Edition".to_string(), 1))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Second edition, Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("Second Edition".to_string(), 2))); - - let (parsed_title, parsed_edition) = parse_edition("Title", "Third Edition Tags"); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("Third Edition".to_string(), 3))); - - let (parsed_title, parsed_edition) = parse_edition( - "Title", - "Greenery Press, 3rd ed. edition, March 16, 2016; Illustrated by Barbara O'Toole", - ); - assert_eq!(parsed_title, "Title"); - assert_eq!(parsed_edition, Some(("3rd Edition".to_string(), 3))); - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 0d8c9ec5..00000000 --- a/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184", - "devDependencies": { - "typescript": "^5.8.3" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 20eacc12..00000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,24 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - typescript: - specifier: ^5.8.3 - version: 5.8.3 - -packages: - - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - -snapshots: - - typescript@5.8.3: {} diff --git a/python/README.md b/python/README.md new file mode 100644 index 00000000..f719a078 --- /dev/null +++ b/python/README.md @@ -0,0 +1,250 @@ +# MyAnonaSuite Python Runtime + +This directory contains the MyAnonaSuite runtime and its active HeavyMLM and +MAM-Spender modules. +The `mlm-python` command and existing data paths remain unchanged for backward +compatibility. + +The first implemented slice is a lossless database migration: + +1. The legacy Rust executable exports its `native_db` database to versioned JSON. +2. The Python migrator backs up the original database. +3. Every record is inserted into SQLite with its complete canonical JSON payload. +4. Record counts and SQLite integrity are validated before the temporary database + atomically replaces the destination. + +## Migrate an existing database + +Check out and build the frozen one-time legacy exporter: + +```powershell +git clone --branch legacy-export-v1 https://github.com/Plungis/MLM legacy-export +cd legacy-export +cargo build --release -p mlm +cd .. +``` + +Then run: + +```powershell +cd python +python -m pip install -e . +mlm-python migrate ` + --source-db "$env:LOCALAPPDATA\MLM\data.db" ` + --destination "$env:LOCALAPPDATA\MLM\data.sqlite3" ` + --legacy-executable ".\legacy-export\target\release\mlm.exe" +``` + +The old executable is used only to decode its private `native_db` format. It +exports from the automatic backup copy, not the live database, and is never +used by the new application runtime. You can also pass +`--export-json path\to\export.json` instead of `--legacy-executable`. + +## Process migrated pending downloads + +```powershell +mlm-python download ` + --config "$env:APPDATA\MLM\config.toml" ` + --database "$env:LOCALAPPDATA\MLM\data.sqlite3" +``` + +This command currently performs one downloader pass. It validates the MaM +cookie, logs into the first configured qBittorrent server, includes the required +`tid` on every MaM download, and records successes or errors in SQLite. + +## Run the Python service + +```powershell +mlm-python run ` + --config "$env:APPDATA\MLM\config.toml" ` + --database "$env:LOCALAPPDATA\MLM\data.sqlite3" +``` + +The service runs the configured autograbbers, Goodreads and Notion imports, +pending downloader, qBittorrent library organizers, and duplicate cleaner on +their configured intervals. The dashboard listens on `web_host:web_port`. + +## Use MAM-Spender + +Open `http://localhost:3157/suite/mam-spender/dashboard` or choose **M$ / Spend** +in the suite switcher. Its Dashboard, Graph, History, All MaM Data, and Config +pages are part of the same process, database, and authenticated MaM session as +HeavyMLM. Frequently adjusted purchase policy, point buffer, and schedule +controls are available directly on the Dashboard and on the Config page. The +Config page also owns Session_ID/import tools and identifies the exact running +suite version and listener. + +To migrate the standalone MAM-Spender Web Edition, open **MAM-Spender → +Config**, copy the contents of its `data/config.json` into **Import old +config.json**, and choose **Import Web Edition data**. This imports module +settings, totals, run history, spending events, cached MaM data, bonus history, +and any plain Session_ID. The original file is not changed. + +## ABSidekick module + +Choose **A_ / ABS** in the suite switcher to open the integrated ABSidekick +organizer. Its own sidebar exposes Run Center, Review Desk, Targeting, Matching, +Tags & Actions, and Config. Config stores the Audiobookshelf URL, library, +provider, and API token by default; uncheck the save option only for a +deliberately session-only token. Stored credentials remain on the server and +are represented in the UI by a saved-status indicator rather than being +returned to the browser. All policy changes apply to the next preview or run +without restarting MyAnonaSuite. + +Config also includes an optional native Google Books provider. Create a Google +Cloud project, enable the Books API, create an API key, restrict that key to +the Books API, paste it into **Google Books API Key**, and choose **Test & +Enable**. The in-app guide links directly to each Google Cloud screen and +explains optional stable-IP restrictions. A pasted key is saved privately and +survives MyAnonaSuite restarts; after the field clears, the saved-status message +confirms that the key is still loaded and does not need to be pasted again. +Google remains disabled until the live test succeeds; when no tested key is +present, MyAnonaSuite sends no request to Google. The key itself is never +returned to the browser or sent to Audiobookshelf. Once the key is tested, +previews, initial matching runs, and Review Desk scans always try the selected +Audiobookshelf metadata provider first. If that ABS result does not pass the +automatic-match policy for any reason, native Google Books is queried +immediately as a second pass. This does not depend on the optional extra-provider +fallback setting. The live log displays each ABS and Google attempt, including +zero-result and skipped searches, its result count, the selected provider, and +the exact score or safety gate that led to Review. Reviewer-controlled +Google searches remain available on each Review Desk item. Temporary Google +timeouts, rate limits, and 5xx responses receive one immediate retry and do not +disable the provider after a single failed item. Three consecutive exhausted +transient searches open the run-local circuit breaker; authentication or API +configuration rejections still stop Google immediately. Job diagnostics state +whether the next item will retry or the provider was disabled and include the +underlying error. + +Native Open Library search is enabled by default as the third lookup. If the +Audiobookshelf provider and the tested Google provider do not produce an +automatic match, MyAnonaSuite queries Open Library's official JSON Search API. +No Open Library API key is required. Add a contact email under **Config -> Open +Library Search** to identify the installation as Open Library requests for +regular API clients; identified clients are limited to three requests per +second and anonymous clients to one. MyAnonaSuite enforces the matching limit, +caches repeated searches for the current run, requests only the fields used by +the matcher, and gives transient failures the same one-retry/three-strike +circuit-breaker treatment as Google. The provider can be disabled completely, +and it is also available directly in Review Desk manual searches. + +The winner-margin check compares only meaningfully different works. Duplicate +provider listings with the same normalized title and verified author are +treated as one logical match, and the candidate with richer evidence is +preferred. A tie still requires Review when candidates disagree on the title, +author, collection status, or series sequence. + +ABSidekick retains the source project's dry-run preview, weighted scoring, +metadata-patch, ABS quick-match and tags-only modes, author/tag/path targeting, +pause/resume/cancel, retries, live searchable logs, cover comparisons, and +manual approve/reject workflow. Its state is stored in an `absidekick` folder +beside the configured SQLite database, separate from HeavyMLM and MAM-Spender. + +High-precision matching is enabled by default. The engine normalizes Unicode, +punctuation, file formats, edition labels, and recognized series packaging; +compares only metadata present on both sides; treats exact ASIN/ISBN matches as +strong evidence; and blocks automatic writes on contradictory identifiers, +authors, series positions, collection status, duration, or an ambiguous +runner-up. Adaptive search cleans common filename numbering and retries a +title-only query only when the precise query is empty. Every decision, evidence +signal, search source, and conflict is visible in previews, logs, and the Review +Desk. These safeguards and their thresholds can be changed live on the Matching +screen. + +Within the Review Desk, expand **Research this match manually**, edit the title, +author, provider, or result limit, and select **Search Now**. The row remains +open while searching and reports whether the edited fields produced a confident +match, review-only candidates, no results, or an error. Candidates can be +selected and approved directly, or the item can be rejected without leaving the +review. + +The persistent **Live activity** strip confirms every server-backed action, +keeps an elapsed timer running, disables the initiating button while work is in +flight, and retains the completion or error result. **Scan Review Tags** also +shows its live phase, current book title, and `completed / total` count while +Audiobookshelf and metadata-provider requests are still running. + +For speed and rate-limit safety, optional Audiobookshelf-proxied providers such +as iTunes are disabled by default. Tested native Google is the automatic second +pass whenever ABS does not auto-match, and native Open Library is the third. +The primary provider gets one precise search; only an empty response can trigger +a cleaned/title-only retry. Leading track/disc numbers are removed before the +first query. Metadata search timeout is separate from the general ABS API +timeout and never uses general API retries. Provider failures follow the +run-local retry and circuit-breaker policy described above, then leave the item +available for Review. + +Track prefixes with secondary indices, such as `01(3) Octopussy` and +`02 (7) Thunderball`, are reduced to the real title before searching and +scoring, while numeric titles such as `11(22)63` remain intact. + +Series-number folder prefixes are parsed separately from real titles. For +example, `Pern 17 - The Masterharper of Pern` searches as +`The Masterharper of Pern`. Series metadata or a repeated distinctive series +word makes that cleanup immediate; otherwise the unmodified title is tried +first and the parsed title is only a fallback. Candidate cards display the +strategy that produced them, and quick-match mode uses an evidence-backed +parsed title when applicable. + +## Publish the request portal on a custom domain + +The Configuration screen can enable a separate request portal without exposing +the HeavyMLM dashboard on that hostname. Set the request domain, a portal title, +named requester accounts, and a per-client request limit there. Changes apply +immediately. Passwords are never written to `config.toml` in plain text; +MyAnonaSuite saves salted PBKDF2-SHA256 hashes. The old shared username/password +and access code remain available as compatibility options for upgraded installs. + +Each named account has its own permissions. Ordinary accounts send requests to +the Requests inbox for review. Trusted accounts can be given **Auto-approve +requests**, which schedules valid releases immediately under the same format, +slot, ratio, and freeleech-wedge safeguards as manually approved requests. This +permission does not grant access to the HeavyMLM dashboard, configuration, or +other MyAnonaSuite modules. Removing an account or resetting its password +invalidates that account's existing portal login session. + +Accounts can also have a **Requests per week** limit. This is a rolling +seven-day allowance rather than a calendar-week reset, so values such as 10 or +20 work consistently regardless of the day the account was created. `0` means +unlimited. Pending, approved, fulfilled, and later-rejected submissions all +count because the limit controls how many requests the account may submit, not +how many are ultimately downloaded. The portal shows each signed-in requester +their used and remaining allowance before they submit. + +Point an HTTPS reverse proxy at the MyAnonaSuite service and preserve the +original `Host` header. For example, a minimal Caddy site is: + +```caddyfile +requests.example.com { + reverse_proxy 127.0.0.1:3157 +} +``` + +Then set `request_portal_domains = ["requests.example.com"]` and enable the +portal. That hostname serves only the request form and static assets; dashboard, +configuration, API documentation, and approval routes return 404. Do not expose +port 3157 directly to the public internet. `request_portal_require_account_login` +defaults to `true`: the portal fails closed until at least one request account is +created, old shared-access cookies are rejected, and every accepted submission +records the authenticated username. Set it to `false` only if anonymous or +legacy shared-code access is intentionally required. + +For Nginx Proxy Manager, use `bookrequest.example.com` as the Domain Name, +`http` as the scheme, the MyAnonaSuite host/IP as Forward Hostname, and `3157` +as Forward Port. Leave NPM's Access List set to **Publicly Accessible** if the +MyAnonaSuite account login should be the only prompt; an NPM Basic Auth access +list creates a separate first login. The app recognizes either the preserved +`Host` header or a matching `X-Forwarded-Host` header. + +Administrators can open the portal in a new tab from **Requests → Request +Portal** in the sidebar or the **Open request portal** button in the request +inbox. When individual account login is required, the local preview also asks +for a request account so preview submissions cannot become anonymous records. + +Visitors can combine title, author, series, narrator, format, category, +language, availability, seeder, and sort filters. They can also paste a public +Goodreads book URL to prefill its metadata and run the search. Submissions from +ordinary accounts enter the Requests inbox for approval. Submissions from +trusted auto-approve accounts are revalidated and scheduled immediately. Every +decision records the account that submitted it and remains visible in request +history. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..def10cbc --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,38 @@ +[build-system] +requires = ["hatchling>=1.26"] +build-backend = "hatchling.build" + +[project] +name = "mlm-python" +version = "0.5.0b65" +description = "MyAnonaSuite with HeavyMLM, ABSidekick, and MAM-Spender modules" +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "MLM contributors" }] +dependencies = [ + "fastapi>=0.115,<1", + "httpx>=0.27,<1", + "jinja2>=3.1,<4", + "python-multipart>=0.0.20,<1", + "uvicorn[standard]>=0.34,<1", +] + +[project.optional-dependencies] +dev = ["pytest>=8,<10", "ruff>=0.16,<1"] + +[project.scripts] +mlm-python = "mlm.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/mlm"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py311" +line-length = 88 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "DTZ", "FURB", "PYI"] diff --git a/python/src/mlm/__init__.py b/python/src/mlm/__init__.py new file mode 100644 index 00000000..32516967 --- /dev/null +++ b/python/src/mlm/__init__.py @@ -0,0 +1,3 @@ +"""MyAnonaSuite runtime and HeavyMLM automation module.""" + +__version__ = "0.5.0b65" diff --git a/python/src/mlm/__main__.py b/python/src/mlm/__main__.py new file mode 100644 index 00000000..eb53e2f3 --- /dev/null +++ b/python/src/mlm/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/python/src/mlm/audiobookshelf.py b/python/src/mlm/audiobookshelf.py new file mode 100644 index 00000000..64ddfd48 --- /dev/null +++ b/python/src/mlm/audiobookshelf.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx + +from .repository import Repository + + +class AudiobookshelfClient: + def __init__( + self, + url: str, + token: str, + *, + client: httpx.AsyncClient | None = None, + ) -> None: + self._owns_client = client is None + self.client = client or httpx.AsyncClient( + base_url=url.rstrip("/"), + headers={"Authorization": f"Bearer {token}", "User-Agent": "MLM"}, + timeout=30, + ) + + async def close(self) -> None: + if self._owns_client: + await self.client.aclose() + + async def find_book(self, torrent: dict[str, Any]) -> dict[str, Any] | None: + library_path = torrent.get("library_path") + authors = torrent.get("meta", {}).get("authors", []) + if not library_path or not authors: + return None + response = await self.client.get("/api/libraries") + response.raise_for_status() + libraries = [ + library + for library in response.json().get("libraries", []) + if any( + Path(library_path) == Path(folder.get("fullPath", "")) + or Path(folder.get("fullPath", "")) in Path(library_path).parents + for folder in library.get("folders", []) + ) + ] + for library in libraries: + response = await self.client.get( + f"/api/libraries/{library['id']}/search", params={"q": authors[0]} + ) + response.raise_for_status() + for author in response.json().get("authors", []): + response = await self.client.get( + f"/api/authors/{author['id']}", params={"include": "items"} + ) + response.raise_for_status() + for book in response.json().get("libraryItems", []): + if Path(book.get("path", "")) == Path(library_path): + return book + return None + + async def update_book( + self, book_id: str, mam_row: dict[str, Any], meta: dict[str, Any] + ) -> None: + title_parts = str(meta.get("title", "")).split(":", 1) + title = title_parts[0] if len(title_parts[0]) >= 4 else meta.get("title", "") + subtitle = ( + title_parts[1].strip() + if len(title_parts) > 1 and title != meta.get("title") + else None + ) + isbn_value = str(mam_row.get("isbn") or "").strip() + payload = { + "metadata": { + "title": title, + "subtitle": subtitle, + "authors": [{"name": name} for name in meta.get("authors", [])], + "series": [ + { + "name": series.get("name"), + "sequence": str(series.get("entries", [""])[0]) + if series.get("entries") + else None, + } + for series in meta.get("series", []) + ], + "narrators": meta.get("narrators", []), + "description": mam_row.get("description"), + "isbn": None + if not isbn_value or isbn_value.startswith("ASIN:") + else isbn_value, + "asin": isbn_value.removeprefix("ASIN:").strip() + if isbn_value.startswith("ASIN:") + else None, + "genres": [meta.get("cat", {}).get("name")] + if isinstance(meta.get("cat"), dict) + else [], + "language": meta.get("language"), + "explicit": bool(int(meta.get("flags") or 0) & (1 << 4)), + "abridged": bool(int(meta.get("flags") or 0) & (1 << 5)), + } + } + response = await self.client.patch(f"/api/items/{book_id}/media", json=payload) + response.raise_for_status() + + async def delete_book(self, book_id: str) -> None: + response = await self.client.delete(f"/api/items/{book_id}") + response.raise_for_status() + + +async def match_torrents_to_audiobookshelf( + repository: Repository, client: AudiobookshelfClient +) -> int: + matched = 0 + for torrent in repository.library_torrents(): + if torrent.get("abs_id"): + continue + book = await client.find_book(torrent) + if not book: + continue + torrent["abs_id"] = book["id"] + repository.update_torrent(torrent) + matched += 1 + return matched diff --git a/python/src/mlm/autograbber.py b/python/src/mlm/autograbber.py new file mode 100644 index 00000000..b203b187 --- /dev/null +++ b/python/src/mlm/autograbber.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from .config import Config +from .mam import MamClient +from .repository import Repository +from .search import ( + as_bool, + matches_filter, + metadata_matches, + normalize_title, + search_pages, + torrent_meta, +) + + +def _preferred_types(config: Config, media_type: str) -> tuple[str, ...]: + if media_type in {"audiobook", "periodical_audiobook"}: + return config.audio_types + if media_type in {"ebook", "manga", "comic_book", "periodical_ebook"}: + return config.ebook_types + if media_type == "musicology": + return config.music_types + if media_type == "radio": + return config.radio_types + return () + + +def _preference(formats: list[str], preferred: tuple[str, ...]) -> int | None: + positions = [preferred.index(fmt) for fmt in formats if fmt in preferred] + return min(positions) if positions else None + + +def _cost(row: dict[str, Any], requested: str) -> str: + if as_bool(row.get("vip")): + return "Vip" + if as_bool(row.get("personal_freeleech")): + return "PersonalFreeleech" + if as_bool(row.get("free")): + return "GlobalFreeleech" + if requested == "wedge": + return "UseWedge" + if requested == "try_wedge": + return "TryWedge" + return "Ratio" + + +def _tagging(config: Config, row: dict[str, Any]) -> tuple[str | None, list[str]]: + category = None + tags: list[str] = [] + for rule in config.tags: + if matches_filter(row, rule): + category = category or rule.get("category") + tags.extend(str(tag) for tag in rule.get("tags", [])) + return category, list(dict.fromkeys(tags)) + + +async def run_autograbber( + config: Config, + repository: Repository, + mam: MamClient, + rule: dict[str, Any], + *, + index: int = 0, +) -> int: + user = await mam.user_info() + unsat = user.get("unsat", {}) + site_limit = max(0, int(unsat.get("limit", 0))) + used = max(0, int(unsat.get("count", 0))) + slot_buffer = int(rule.get("unsat_buffer", config.unsat_buffer)) + slot_cap = max(0, site_limit - slot_buffer) + if config.max_unsat_slots is not None: + slot_cap = min(slot_cap, config.max_unsat_slots) + maximum = max(0, slot_cap - used) + if rule.get("max_active_downloads") is not None: + active = sum( + selected.get("grabber") == rule.get("name", str(index)) + for selected in repository.pending_selected() + ) + maximum = min(maximum, max(0, int(rule["max_active_downloads"]) - active)) + if maximum == 0 and rule.get("cost", "free") not in { + "metadata_only", + "metadata_only_add", + }: + return 0 + + selected_count = 0 + async for row in search_pages(mam, rule): + if await select_row(config, repository, row, rule, index=index): + selected_count += 1 + if selected_count >= maximum: + break + return selected_count + + +async def select_row( + config: Config, + repository: Repository, + row: dict[str, Any], + rule: dict[str, Any], + *, + index: int = 0, + goodreads_id: int | None = None, +) -> bool: + torrent_id = int(row.get("id", 0)) + if not torrent_id or torrent_id in config.ignore_torrents: + return False + if not matches_filter(row, rule) or repository.has_mam_id(torrent_id): + return False + requested_cost = rule.get("cost", "free") + if requested_cost == "free" and not any( + as_bool(row.get(field)) + for field in ("vip", "personal_freeleech", "free", "fl_vip") + ): + return False + if requested_cost in {"metadata_only", "metadata_only_add"}: + return False + + meta = torrent_meta(row) + title_search = normalize_title(meta["title"]) + preferred = _preferred_types(config, meta["media_type"]) + preference = _preference(meta["filetypes"], preferred) + if preference is None: + return False + duplicate = False + for existing in repository.records_with_title(title_search): + old_meta = existing.get("meta", {}) + if not metadata_matches(meta, old_meta): + continue + old_preference = _preference(old_meta.get("filetypes", []), preferred) + if old_preference is not None and old_preference <= preference: + duplicate = True + break + category, tags = _tagging(config, row) + candidate = { + "mam_id": torrent_id, + "goodreads_id": goodreads_id, + "hash": None, + "dl_link": row.get("dl"), + "unsat_buffer": rule.get("unsat_buffer"), + "wedge_buffer": rule.get("wedge_buffer"), + "cost": _cost(row, requested_cost), + "category": rule.get("category") or category, + "tags": tags, + "title_search": title_search, + "meta": meta, + "grabber": rule.get("name", str(index)), + "created_at": datetime.now(UTC).isoformat(), + "started_at": None, + "removed_at": None, + } + if duplicate: + if not rule.get("dry_run", False): + repository.add_duplicate(candidate) + return False + if not candidate["dl_link"]: + return False + if not rule.get("dry_run", False): + repository.add_selected(candidate) + return True diff --git a/python/src/mlm/cleaner.py b/python/src/mlm/cleaner.py new file mode 100644 index 00000000..03fb881c --- /dev/null +++ b/python/src/mlm/cleaner.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import contextlib +from pathlib import Path + +from .config import Config +from .qbittorrent import QbitClient +from .repository import Repository +from .search import metadata_matches + + +def _preference(config: Config, torrent: dict) -> int: + media = torrent.get("meta", {}).get("media_type") + preferred = ( + config.audio_types + if media + in {"audiobook", "periodical_audiobook", "Audiobook", "PeriodicalAudiobook"} + else config.ebook_types + ) + formats = torrent.get("meta", {}).get("filetypes", []) + positions = [preferred.index(value) for value in formats if value in preferred] + return min(positions) if positions else len(preferred) + 1 + + +def remove_library_files(torrent: dict) -> None: + library_path_value = torrent.get("library_path") + if not library_path_value: + return + library_path = Path(library_path_value) + for relative in torrent.get("library_files", []): + path = library_path / relative + path.unlink(missing_ok=True) + parent = path.parent + while parent != library_path: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + remaining = list(library_path.iterdir()) if library_path.exists() else [] + if all(path.name in {"cover.jpg", "metadata.json"} for path in remaining): + for path in remaining: + path.unlink(missing_ok=True) + with contextlib.suppress(OSError): + library_path.rmdir() + + +async def clean_superseded( + config: Config, + repository: Repository, + qbit_clients: list[tuple[dict, QbitClient]], +) -> int: + grouped: dict[str, list[dict]] = {} + for torrent in repository.library_torrents(): + grouped.setdefault(torrent["title_search"], []).append(torrent) + cleaned = 0 + for rows in grouped.values(): + if len(rows) < 2: + continue + + clusters: list[list[dict]] = [] + for torrent in rows: + meta = torrent.get("meta", {}) + placed = False + for cluster in clusters: + if metadata_matches(meta, cluster[0].get("meta", {})): + cluster.append(torrent) + placed = True + break + if not placed: + clusters.append([torrent]) + + for cluster in clusters: + if len(cluster) < 2: + continue + cluster.sort( + key=lambda row: ( + _preference(config, row), + -sum( + (Path(row["library_path"]) / file).stat().st_size + for file in row.get("library_files", []) + if row.get("library_path") + and (Path(row["library_path"]) / file).exists() + ), + ) + ) + keep, *remove_rows = cluster + for torrent in remove_rows: + for qbit_config, qbit in qbit_clients: + update = qbit_config.get("on_cleaned") + if not update or not torrent.get("id_is_hash"): + continue + if update.get("category"): + await qbit.set_category([torrent["id"]], update["category"]) + await qbit.add_tags([torrent["id"]], update.get("tags", [])) + remove_library_files(torrent) + torrent["replaced_with"] = [keep["id"], keep["created_at"]] + torrent["library_path"] = None + torrent["library_files"] = [] + torrent["library_mismatch"] = None + torrent["abs_id"] = None + repository.update_torrent(torrent) + cleaned += 1 + return cleaned diff --git a/python/src/mlm/cli.py b/python/src/mlm/cli.py new file mode 100644 index 00000000..a9e9b5db --- /dev/null +++ b/python/src/mlm/cli.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import asyncio +import sys +from pathlib import Path + +from . import __version__ +from .config import ConfigError, load_config +from .downloader import grab_selected_torrents +from .mam import authenticated_mam_client +from .migration import MigrationError, migrate +from .qbittorrent import QbitClient +from .repository import Repository + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mlm-python") + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + migration = subparsers.add_parser( + "migrate", help="back up and migrate a legacy native_db database to SQLite" + ) + migration.add_argument("--source-db", required=True, type=Path) + migration.add_argument("--destination", required=True, type=Path) + source = migration.add_mutually_exclusive_group(required=True) + source.add_argument("--legacy-executable", type=Path) + source.add_argument("--export-json", type=Path) + downloader = subparsers.add_parser( + "download", help="process migrated pending torrents once" + ) + downloader.add_argument("--config", required=True, type=Path) + downloader.add_argument("--database", required=True, type=Path) + run = subparsers.add_parser("run", help="start the Python MLM service and web UI") + run.add_argument("--config", required=True, type=Path) + run.add_argument("--database", required=True, type=Path) + return parser + + +async def _download(config_path: Path, database_path: Path) -> int: + config = load_config(config_path) + if not config.qbittorrent: + raise ConfigError("at least one [[qbittorrent]] entry is required") + repository = Repository(database_path) + mam = await authenticated_mam_client( + config.mam_id, + stored_mam_id=repository.config_value("mam_id"), + cookie_store=lambda value: repository.set_config_value("mam_id", value), + ) + async with mam: + qbits: list[QbitClient] = [] + try: + for definition in config.qbittorrent: + qbit = QbitClient(definition.url) + await qbit.login(definition.username, definition.password) + qbits.append(qbit) + result = await grab_selected_torrents( + config, repository, mam, qbits[0], other_qbits=qbits[1:] + ) + finally: + for qbit in qbits: + await qbit.close() + print( + f"Download run: {result.downloaded} downloaded, " + f"{result.failed} failed, {result.skipped} skipped" + ) + return 1 if result.failed else 0 + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if args.command == "run": + try: + import uvicorn + + from .web import create_app + + config = load_config(args.config) + print( + f"Starting MyAnonaSuite {__version__} " + f"from {Path(__file__).resolve().parent}" + ) + uvicorn.run( + create_app(args.config, args.database), + host=config.web_host, + port=config.web_port, + ) + return 0 + except (ConfigError, OSError) as error: + print(f"Startup failed: {error}", file=sys.stderr) + return 1 + if args.command == "download": + try: + return asyncio.run(_download(args.config, args.database)) + except (ConfigError, OSError) as error: + print(f"Download failed: {error}", file=sys.stderr) + return 1 + + try: + result = migrate( + args.source_db, + args.destination, + export_json=args.export_json, + legacy_executable=args.legacy_executable, + ) + except MigrationError as error: + print(f"Migration failed: {error}", file=sys.stderr) + return 1 + + print(f"Migrated database: {result.destination}") + print(f"Original database backup: {result.source_backup}") + for table, count in result.counts.items(): + print(f" {table}: {count}") + print(f"Export SHA-256: {result.export_sha256}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/src/mlm/config.py b/python/src/mlm/config.py new file mode 100644 index 00000000..9491237c --- /dev/null +++ b/python/src/mlm/config.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import json +import os +import re +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + + +class ConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class QbitConfig: + url: str + username: str = "" + password: str = "" + on_cleaned: dict[str, Any] | None = None + on_invalid_torrent: dict[str, Any] | None = None + path_mapping: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class RequestPortalUser: + username: str + password_hash: str + display_name: str = "" + permissions: tuple[str, ...] = () + weekly_request_limit: int = 0 + + +@dataclass(frozen=True) +class Config: + mam_id: str + web_host: str = "0.0.0.0" + web_port: int = 3157 + min_ratio: float = 2.0 + unsat_buffer: int = 10 + max_unsat_slots: int | None = None + wedge_buffer: int = 0 + prefer_wedges: bool = False + download_on_wedge_failure: bool = False + grab_both_formats: bool = False + add_torrents_stopped: bool = False + exclude_narrator_in_library_dir: bool = False + search_interval: int = 30 + link_interval: int = 10 + import_interval: int = 135 + ignore_torrents: tuple[int, ...] = () + audio_types: tuple[str, ...] = ("m4b", "m4a", "mp4", "mp3", "ogg") + ebook_types: tuple[str, ...] = ("cbz", "epub", "pdf", "mobi", "azw3", "azw", "cbr") + music_types: tuple[str, ...] = ("pdf", "mp3") + radio_types: tuple[str, ...] = ("mp3",) + enabled_modules: tuple[str, ...] = ("heavymlm", "absidekick", "mam_spender") + absidekick_auto_sync: bool = True + request_portal_enabled: bool = False + request_portal_require_account_login: bool = True + request_portal_domains: tuple[str, ...] = () + request_portal_title: str = "Library Requests" + request_portal_username: str = "" + request_portal_password_hash: str = "" + request_portal_access_code: str = "" + request_portal_rate_limit: int = 20 + request_portal_users: tuple[RequestPortalUser, ...] = () + qbittorrent: tuple[QbitConfig, ...] = () + search: dict[str, Any] = field(default_factory=dict) + audiobookshelf: dict[str, Any] | None = None + autograbs: tuple[dict[str, Any], ...] = () + snatchlist: tuple[dict[str, Any], ...] = () + goodreads_lists: tuple[dict[str, Any], ...] = () + notion_lists: tuple[dict[str, Any], ...] = () + tags: tuple[dict[str, Any], ...] = () + libraries: tuple[dict[str, Any], ...] = () + + +_ALIASES = { + "goodreads_interval": "import_interval", + "autograb": "autograbs", + "goodreads_list": "goodreads_lists", + "notion_list": "notion_lists", + "tag": "tags", + "library": "libraries", + "request_portal_user": "request_portal_users", +} + + +def _environment_overrides(environment: Mapping[str, str]) -> dict[str, Any]: + overrides: dict[str, Any] = {} + for key, value in environment.items(): + if not key.startswith("MLM_CONF_"): + continue + name = key.removeprefix("MLM_CONF_").lower() + try: + overrides[name] = tomllib.loads(f"value = {value}")["value"] + except tomllib.TOMLDecodeError: + overrides[name] = value + return overrides + + +def load_config(path: Path, *, environment: Mapping[str, str] | None = None) -> Config: + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + raise ConfigError(f"could not read config {path}: {error}") from error + for old, new in _ALIASES.items(): + if old not in raw: + continue + legacy_value = raw.pop(old) + if new not in raw: + raw[new] = legacy_value + raw.update( + _environment_overrides(os.environ if environment is None else environment) + ) + # Beta 29 briefly exposed this option. Accept and discard it so those config + # files still start after collection splitting was removed in beta 30. + raw.pop("split_collections", None) + + allowed = set(Config.__dataclass_fields__) + unknown = sorted(set(raw) - allowed) + if unknown: + raise ConfigError(f"unknown configuration fields: {', '.join(unknown)}") + if "mam_id" not in raw: + raise ConfigError("missing required configuration field: mam_id") + + qbit_rows = raw.pop("qbittorrent", []) + request_user_rows = raw.pop("request_portal_users", []) + try: + qbit = tuple(QbitConfig(**row) for row in qbit_rows) + request_users_list: list[RequestPortalUser] = [] + for row in request_user_rows: + if not isinstance(row, Mapping): + raise ValueError("request portal accounts must be TOML objects") + permissions = row.get("permissions", ()) + if isinstance(permissions, str) or not isinstance( + permissions, (list, tuple) + ): + raise ValueError("request portal account permissions must be a list") + request_users_list.append( + RequestPortalUser( + username=str(row.get("username", "")).strip(), + password_hash=str(row.get("password_hash", "")), + display_name=str(row.get("display_name", "")).strip(), + permissions=tuple(str(value) for value in permissions), + weekly_request_limit=int(row.get("weekly_request_limit", 0)), + ) + ) + request_users = tuple(request_users_list) + tuple_fields = { + "ignore_torrents", + "audio_types", + "ebook_types", + "music_types", + "radio_types", + "enabled_modules", + "request_portal_domains", + "autograbs", + "snatchlist", + "goodreads_lists", + "notion_lists", + "tags", + "libraries", + } + for name in tuple_fields & raw.keys(): + raw[name] = tuple(raw[name]) + config = Config( + qbittorrent=qbit, + request_portal_users=request_users, + **raw, + ) + if config.min_ratio <= 0: + raise ConfigError("min_ratio must be greater than zero") + if config.unsat_buffer < 0: + raise ConfigError("unsat_buffer cannot be negative") + if config.max_unsat_slots is not None and config.max_unsat_slots < 0: + raise ConfigError("max_unsat_slots cannot be negative") + if config.wedge_buffer < 0: + raise ConfigError("wedge_buffer cannot be negative") + for name in ("search_interval", "link_interval", "import_interval"): + if int(getattr(config, name)) < 1: + raise ConfigError(f"{name} must be at least 1 minute") + if config.request_portal_rate_limit < 1: + raise ConfigError("request_portal_rate_limit must be at least 1") + if bool(config.request_portal_username) != bool( + config.request_portal_password_hash + ): + raise ConfigError( + "request_portal_username and request_portal_password_hash must " + "both be configured" + ) + if len(config.request_portal_username) > 100: + raise ConfigError("request_portal_username cannot exceed 100 characters") + request_usernames: set[str] = set() + for user in config.request_portal_users: + if not user.username: + raise ConfigError("request portal account username cannot be empty") + if len(user.username) > 100: + raise ConfigError( + "request portal account username cannot exceed 100 characters" + ) + if any(character in user.username for character in "\r\n\0"): + raise ConfigError( + "request portal account usernames cannot contain control characters" + ) + if not user.password_hash: + raise ConfigError( + f"request portal account {user.username!r} requires a password hash" + ) + if len(user.display_name) > 120: + raise ConfigError( + "request portal account display name cannot exceed 120 characters" + ) + if user.weekly_request_limit < 0: + raise ConfigError( + "request portal account weekly request limit cannot be negative" + ) + normalized_username = user.username.casefold() + if normalized_username in request_usernames: + raise ConfigError( + f"duplicate request portal account username: {user.username}" + ) + request_usernames.add(normalized_username) + unknown_permissions = sorted(set(user.permissions) - {"auto_approve"}) + if unknown_permissions: + raise ConfigError( + "unknown request portal permissions for " + f"{user.username}: {', '.join(unknown_permissions)}" + ) + if config.request_portal_enabled and not config.request_portal_domains: + raise ConfigError( + "request_portal_domains must contain a custom domain when the " + "request portal is enabled" + ) + for domain in config.request_portal_domains: + if not domain.strip() or "://" in domain or "/" in domain or " " in domain: + raise ConfigError( + "request_portal_domains entries must be hostnames without " + "a scheme, path, or spaces" + ) + return config + except (TypeError, ValueError) as error: + raise ConfigError(f"invalid configuration: {error}") from error + + +def _toml_scalar(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, Mapping): + return ( + "{ " + + ", ".join(f"{key} = {_toml_scalar(item)}" for key, item in value.items()) + + " }" + ) + if isinstance(value, (list, tuple)): + return "[" + ", ".join(_toml_scalar(item) for item in value) + "]" + raise ConfigError(f"unsupported editable value: {value!r}") + + +def save_root_config_values(path: Path, values: Mapping[str, object | None]) -> Config: + try: + text = path.read_text(encoding="utf-8") + except OSError as error: + raise ConfigError(f"could not read config {path}: {error}") from error + + table = re.search(r"(?m)^[ \t]*\[", text) + position = table.start() if table else len(text) + root = text[:position] + suffix = text[position:] + for legacy, canonical in _ALIASES.items(): + if canonical in values: + legacy_pattern = re.compile( + rf"(?m)^[ \t]*{re.escape(legacy)}[ \t]*=.*(?:\r?\n|$)" + ) + root = legacy_pattern.sub("", root) + missing: list[str] = [] + for key, value in values.items(): + pattern = re.compile(rf"(?m)^[ \t]*{re.escape(key)}[ \t]*=.*(?:\r?\n|$)") + if value is None: + root = pattern.sub("", root) + continue + replacement = f"{key} = {_toml_scalar(value)}\n" + root, count = pattern.subn(replacement, root, count=1) + if count == 0: + missing.append(replacement) + + if missing: + root = root.rstrip() + "\n" + "".join(missing) + "\n" + text = root + suffix.lstrip("\r\n") + + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(text, encoding="utf-8") + load_config(temporary, environment={}) + os.replace(temporary, path) + except (OSError, ConfigError) as error: + temporary.unlink(missing_ok=True) + if isinstance(error, ConfigError): + raise + raise ConfigError(f"could not save config {path}: {error}") from error + return load_config(path) + + +def save_config_text(path: Path, text: str) -> Config: + """Validate and atomically replace the complete editable TOML document.""" + if not text.strip(): + raise ConfigError("configuration cannot be empty") + if not text.endswith("\n"): + text += "\n" + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(text, encoding="utf-8") + load_config(temporary, environment={}) + os.replace(temporary, path) + except (OSError, ConfigError) as error: + temporary.unlink(missing_ok=True) + if isinstance(error, ConfigError): + raise + raise ConfigError(f"could not save config {path}: {error}") from error + return load_config(path) diff --git a/python/src/mlm/database.py b/python/src/mlm/database.py new file mode 100644 index 00000000..a72bcbd2 --- /dev/null +++ b/python/src/mlm/database.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +SCHEMA_VERSION = 5 + +SCHEMA = """ +PRAGMA foreign_keys = ON; + +CREATE TABLE migration_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + payload_json TEXT NOT NULL +); + +CREATE TABLE torrents ( + id TEXT PRIMARY KEY, + mam_id INTEGER NOT NULL UNIQUE, + title_search TEXT NOT NULL, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE TABLE selected_torrents ( + mam_id INTEGER PRIMARY KEY, + hash TEXT UNIQUE, + title_search TEXT NOT NULL, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE TABLE duplicate_torrents ( + mam_id INTEGER PRIMARY KEY, + title_search TEXT NOT NULL, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE TABLE errored_torrents ( + id_json TEXT PRIMARY KEY, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE TABLE events ( + id_json TEXT PRIMARY KEY, + torrent_id TEXT, + mam_id INTEGER, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE INDEX events_torrent_id_idx ON events(torrent_id); +CREATE INDEX events_mam_id_idx ON events(mam_id); + +CREATE TABLE lists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + payload_json TEXT NOT NULL +); + +CREATE TABLE list_items ( + guid_json TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at_json TEXT, + payload_json TEXT NOT NULL +); + +CREATE INDEX list_items_list_id_idx ON list_items(list_id); + +CREATE TABLE activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + level TEXT NOT NULL, + component TEXT NOT NULL, + message TEXT NOT NULL, + context_json TEXT NOT NULL +); + +CREATE INDEX activity_log_created_at_idx ON activity_log(created_at DESC); + +CREATE TABLE requests ( + id TEXT PRIMARY KEY, + mam_id INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + payload_json TEXT NOT NULL +); + +CREATE INDEX requests_status_created_idx ON requests(status, created_at DESC); +CREATE INDEX requests_mam_id_idx ON requests(mam_id); + +CREATE TABLE abs_books ( + abs_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + title_search TEXT NOT NULL, + authors_json TEXT NOT NULL, + series_json TEXT NOT NULL, + library_path TEXT, + asin TEXT, + isbn TEXT, + payload_json TEXT NOT NULL, + synced_at TEXT NOT NULL +); + +CREATE INDEX abs_books_title_search_idx ON abs_books(title_search); +CREATE INDEX abs_books_asin_idx ON abs_books(asin); + +CREATE TABLE mam_spender_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL +); + +CREATE TABLE mam_spender_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + payload_json TEXT NOT NULL +); + +CREATE INDEX mam_spender_history_created_idx + ON mam_spender_history(created_at DESC); + +CREATE TABLE mam_spender_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + category TEXT NOT NULL, + payload_json TEXT NOT NULL +); + +CREATE INDEX mam_spender_events_created_idx + ON mam_spender_events(created_at DESC); +""" + +DATA_TABLES = ( + "config", + "torrents", + "selected_torrents", + "duplicate_torrents", + "errored_torrents", + "events", + "lists", + "list_items", +) + + +def connect(path: Path) -> sqlite3.Connection: + connection = sqlite3.connect(path, timeout=5) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 5000") + return connection + + +def initialize(connection: sqlite3.Connection) -> None: + connection.executescript(SCHEMA) + + +def ensure_database(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + connection = connect(path) + try: + connection.execute("PRAGMA journal_mode = WAL") + connection.execute("PRAGMA synchronous = NORMAL") + if ( + not path.exists() + or not connection.execute( + "SELECT 1 FROM sqlite_master " + "WHERE type='table' AND name='migration_meta'" + ).fetchone() + ): + initialize(connection) + connection.executemany( + "INSERT INTO migration_meta(key, value) VALUES (?, ?)", + [("schema_version", str(SCHEMA_VERSION)), ("created_fresh", "true")], + ) + else: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + level TEXT NOT NULL, + component TEXT NOT NULL, + message TEXT NOT NULL, + context_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS activity_log_created_at_idx + ON activity_log(created_at DESC); + CREATE TABLE IF NOT EXISTS requests ( + id TEXT PRIMARY KEY, + mam_id INTEGER NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS requests_status_created_idx + ON requests(status, created_at DESC); + CREATE INDEX IF NOT EXISTS requests_mam_id_idx + ON requests(mam_id); + CREATE TABLE IF NOT EXISTS abs_books ( + abs_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + title_search TEXT NOT NULL, + authors_json TEXT NOT NULL, + series_json TEXT NOT NULL, + library_path TEXT, + asin TEXT, + isbn TEXT, + payload_json TEXT NOT NULL, + synced_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS abs_books_title_search_idx + ON abs_books(title_search); + CREATE INDEX IF NOT EXISTS abs_books_asin_idx + ON abs_books(asin); + CREATE TABLE IF NOT EXISTS mam_spender_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS mam_spender_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS mam_spender_history_created_idx + ON mam_spender_history(created_at DESC); + CREATE TABLE IF NOT EXISTS mam_spender_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + category TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS mam_spender_events_created_idx + ON mam_spender_events(created_at DESC); + """ + ) + connection.execute( + """INSERT INTO migration_meta(key, value) + VALUES ('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value""", + (str(SCHEMA_VERSION),), + ) + connection.commit() + finally: + connection.close() diff --git a/python/src/mlm/downloader.py b/python/src/mlm/downloader.py new file mode 100644 index 00000000..07307b44 --- /dev/null +++ b/python/src/mlm/downloader.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from dataclasses import dataclass, field + +from .config import Config +from .mam import MamClient, MamRateLimitError, MamWedgeError +from .qbittorrent import QbitClient +from .repository import Repository +from .search import as_bool +from .torrent import info_hash + + +@dataclass(frozen=True) +class DownloadRun: + downloaded: int = 0 + failed: int = 0 + skipped: int = 0 + skip_reasons: dict[str, int] = field(default_factory=dict) + available_slots: int = 0 + ratio_buffer_bytes: int = 0 + slots_used: int = 0 + slots_total: int = 0 + slot_cap: int = 0 + wedges_remaining: int = 0 + wedge_buffer: int = 0 + failures: list[dict[str, object]] = field(default_factory=list) + + +async def _confirm_wedge( + mam: MamClient, + torrent_id: int, + wedges_before: int, +) -> dict[str, object]: + confirmation: dict[str, object] = { + "torrent_id": torrent_id, + "wedges_before": wedges_before, + "verified": False, + } + try: + user = await mam.user_info() + wedges_after = max(0, int(user.get("wedges", 0))) + confirmation["wedges_after"] = wedges_after + if wedges_after < wedges_before: + confirmation.update(verified=True, verified_by="wedge_balance") + return confirmation + except Exception as error: # noqa: BLE001 - retain confirmation diagnostics + confirmation["balance_check_error"] = f"{type(error).__name__}: {error}" + + try: + current = await mam.get_torrent_info_by_id(torrent_id) + personal_freeleech = bool( + current and as_bool(current.get("personal_freeleech")) + ) + confirmation["personal_freeleech"] = personal_freeleech + if personal_freeleech: + confirmation.update(verified=True, verified_by="personal_freeleech") + return confirmation + except Exception as error: # noqa: BLE001 - retain confirmation diagnostics + confirmation["torrent_check_error"] = f"{type(error).__name__}: {error}" + return confirmation + + +async def _torrent_file_with_backoff( + mam: MamClient, + torrent_id: int, + *, + use_wedge: bool = False, +) -> bytes: + delay = 30 + while True: + try: + return await mam.get_torrent_file(torrent_id, use_wedge=use_wedge) + except MamRateLimitError: + await asyncio.sleep(delay) + delay = min(delay * 2, 300) + + +async def grab_selected_torrents( + config: Config, + repository: Repository, + mam: MamClient, + qbit: QbitClient, + other_qbits: Iterable[QbitClient] = (), +) -> DownloadRun: + downloaded = failed = skipped = 0 + failures: list[dict[str, object]] = [] + skip_reasons = {"unsat_slots": 0, "ratio_buffer": 0} + pending = repository.pending_selected() + repository.log_activity( + "downloader", + "Evaluating selected torrents", + context={"pending": len(pending)}, + ) + user = await mam.user_info() + unsat = user.get("unsat", {}) + slots_total = max(0, int(unsat.get("limit", 0))) + slots_used = max(0, int(unsat.get("count", 0))) + available_slots = max(0, slots_total - slots_used) + wedges_remaining = max(0, int(user.get("wedges", 0))) + downloading_size = repository.selected_pipeline_status()["downloading_bytes"] + remaining_buffer = ( + float(user.get("uploaded_bytes", 0)) + - float(user.get("downloaded_bytes", 0)) + - downloading_size + ) / config.min_ratio + starting_ratio_buffer = max(0, int(remaining_buffer)) + for selected in pending: + diagnostic_context: dict[str, object] = { + "mam_id": selected.get("mam_id"), + "title": selected.get("meta", {}).get("title"), + } + try: + torrent_id = int(selected["mam_id"]) + slot_buffer = int( + selected.get("unsat_buffer") + if selected.get("unsat_buffer") is not None + else config.unsat_buffer + ) + slot_cap = max(0, slots_total - slot_buffer) + if config.max_unsat_slots is not None: + slot_cap = min(slot_cap, config.max_unsat_slots) + size = int(selected.get("meta", {}).get("size", 0)) + if slots_used + downloaded >= slot_cap: + skipped += 1 + skip_reasons["unsat_slots"] += 1 + repository.log_activity( + "downloader", + f"Deferred MaM #{torrent_id}: no unsatisfied slot available", + level="warning", + context={ + "mam_id": torrent_id, + "slots_used": slots_used + downloaded, + "slots_total": slots_total, + "slot_cap": slot_cap, + "available_slots": available_slots, + "slot_buffer": slot_buffer, + }, + ) + continue + torrent_file = await _torrent_file_with_backoff(mam, torrent_id) + torrent_hash = info_hash(torrent_file) + existing = await qbit.torrents(hashes=[torrent_hash]) + if not existing: + for other_qbit in other_qbits: + existing = await other_qbit.torrents(hashes=[torrent_hash]) + if existing: + break + wedged = False + wedge_failed_fallback = False + cost = selected.get("cost") + wedge_buffer = int( + selected.get("wedge_buffer") + if selected.get("wedge_buffer") is not None + else config.wedge_buffer + ) + current = None + currently_free = False + if not existing and (config.prefer_wedges or cost != "Ratio"): + current = await mam.get_torrent_info(torrent_hash) + currently_free = bool( + current + and any( + as_bool(current.get(field)) + for field in ("free", "personal_freeleech", "fl_vip", "vip") + ) + ) + wants_wedge = ( + not existing + and not currently_free + and (config.prefer_wedges or cost in {"UseWedge", "TryWedge"}) + ) + wedge_context: dict[str, object] = { + "stage": "wedge_decision", + "wedge_attempted": False, + "mam_id": torrent_id, + "cost": cost, + "prefer_wedges": config.prefer_wedges, + "download_on_wedge_failure": config.download_on_wedge_failure, + "currently_free": currently_free, + "already_present": bool(existing), + "wants_wedge": wants_wedge, + "wedges_before": wedges_remaining, + "wedge_buffer": wedge_buffer, + "raw_freeleech_flags": { + field: current.get(field) if current else None + for field in ("free", "personal_freeleech", "fl_vip", "vip") + }, + } + diagnostic_context.update(wedge_context) + repository.log_activity( + "downloader", + f"Evaluated freeleech state for MaM #{torrent_id}", + level="debug", + context=wedge_context, + ) + if wants_wedge and wedges_remaining > wedge_buffer: + wedge_context.update(stage="wedge_request", wedge_attempted=True) + diagnostic_context.update(wedge_context) + repository.log_activity( + "downloader", + f"Applying freeleech wedge to MaM #{torrent_id}", + context=wedge_context, + ) + try: + wedged_torrent_file = await _torrent_file_with_backoff( + mam, + torrent_id, + use_wedge=True, + ) + wedged_torrent_hash = info_hash(wedged_torrent_file) + if wedged_torrent_hash != torrent_hash: + raise MamWedgeError( + "MaM returned a different torrent after applying the wedge", + reason="torrent_mismatch", + context={ + **wedge_context, + "expected_hash": torrent_hash, + "received_hash": wedged_torrent_hash, + }, + ) + confirmation = await _confirm_wedge( + mam, torrent_id, wedges_remaining + ) + wedge_context.update( + stage="wedge_confirmation", + endpoint=f"/tor/download.php?tid={torrent_id}&fl", + **confirmation, + ) + diagnostic_context.update(wedge_context) + if not confirmation["verified"]: + raise MamWedgeError( + "MaM reported wedge success, but HeavyMLM could not " + "confirm a reduced balance or personal freeleech status", + reason="unconfirmed", + context=wedge_context, + ) + torrent_file = wedged_torrent_file + wedged = True + confirmed_balance = confirmation.get("wedges_after") + wedges_remaining = ( + int(confirmed_balance) + if confirmed_balance is not None + and int(confirmed_balance) < wedges_remaining + else wedges_remaining - 1 + ) + user["wedges"] = wedges_remaining + repository.log_activity( + "downloader", + f"Applied freeleech wedge to MaM #{torrent_id}", + level="success", + context={ + **wedge_context, + "wedges_remaining": wedges_remaining, + }, + ) + except MamWedgeError as error: + confirmation = await _confirm_wedge( + mam, torrent_id, wedges_remaining + ) + failure_context = { + **wedge_context, + **error.context, + **confirmation, + "wedge_reason": error.reason, + "error": f"{type(error).__name__}: {error}", + } + diagnostic_context.update(failure_context) + if confirmation["verified"]: + wedged = True + confirmed_balance = confirmation.get("wedges_after") + wedges_remaining = ( + int(confirmed_balance) + if confirmed_balance is not None + and int(confirmed_balance) < wedges_remaining + else wedges_remaining - 1 + ) + user["wedges"] = wedges_remaining + repository.log_activity( + "downloader", + ( + f"Confirmed freeleech wedge for MaM #{torrent_id} " + "despite an unexpected download response" + ), + level="warning", + context={ + **failure_context, + "wedges_remaining": wedges_remaining, + }, + ) + elif not (config.download_on_wedge_failure or cost == "TryWedge"): + repository.log_activity( + "downloader", + f"Freeleech wedge failed for MaM #{torrent_id}", + level="error", + context=failure_context, + ) + raise MamWedgeError( + str(error), + reason=error.reason, + context=failure_context, + ) from error + else: + wedge_failed_fallback = True + failure_context["fallback_used"] = True + diagnostic_context.update(failure_context) + repository.log_activity( + "downloader", + ( + f"Wedge failed for MaM #{torrent_id}; downloading " + "normally under ratio safeguards" + ), + level="warning", + context=failure_context, + ) + elif wants_wedge and cost == "UseWedge": + raise MamWedgeError( + f"wedge reserve reached ({wedges_remaining} available, " + f"{wedge_buffer} reserved)" + ) + if ( + not existing + and not wedged + and not wedge_failed_fallback + and cost not in {"Ratio", "TryWedge"} + and not currently_free + ): + raise RuntimeError("torrent is no longer free") + uses_ratio = ( + not existing + and not wedged + and not currently_free + and (cost in {"Ratio", "TryWedge"} or wedge_failed_fallback) + ) + if uses_ratio and remaining_buffer - size <= 0: + skipped += 1 + skip_reasons["ratio_buffer"] += 1 + repository.log_activity( + "downloader", + f"Deferred MaM #{torrent_id}: ratio reserve", + level="warning", + context={ + "mam_id": torrent_id, + "torrent_bytes": size, + "ratio_buffer_bytes": max(0, int(remaining_buffer)), + }, + ) + continue + if not existing: + await qbit.add_torrent( + torrent_file, + category=selected.get("category"), + tags=selected.get("tags", []), + paused=config.add_torrents_stopped, + ) + repository.record_started(selected, torrent_hash, wedged=wedged) + downloaded += 1 + if uses_ratio: + remaining_buffer -= size + repository.log_activity( + "downloader", + f"Added MaM #{torrent_id} to qBittorrent", + level="success", + context={ + "mam_id": torrent_id, + "torrent_hash": torrent_hash, + "wedged": wedged, + "wedge_fallback_used": wedge_failed_fallback, + "already_present": bool(existing), + }, + ) + except Exception as error: # noqa: BLE001 - isolate failures per torrent + error_context = dict(diagnostic_context) + if isinstance(error, MamWedgeError): + error_context.update(error.context) + error_context["wedge_reason"] = error.reason + error_context["error"] = f"{type(error).__name__}: {error}" + repository.record_grab_error( + selected, + error, + context=error_context, + ) + failures.append(error_context) + failed += 1 + repository.log_activity( + "downloader", + f"Failed MaM #{selected.get('mam_id')}", + level="error", + context=error_context, + ) + await asyncio.sleep(1) + result = DownloadRun( + downloaded=downloaded, + failed=failed, + skipped=skipped, + skip_reasons={key: value for key, value in skip_reasons.items() if value}, + available_slots=available_slots, + ratio_buffer_bytes=starting_ratio_buffer, + slots_used=min(slots_total, slots_used + downloaded), + slots_total=slots_total, + slot_cap=min( + max(0, slots_total - config.unsat_buffer), + config.max_unsat_slots + if config.max_unsat_slots is not None + else slots_total, + ), + wedges_remaining=wedges_remaining, + wedge_buffer=config.wedge_buffer, + failures=failures, + ) + repository.log_activity( + "downloader", + "Download evaluation complete", + level="success" if not failed else "warning", + context={ + "downloaded": result.downloaded, + "failed": result.failed, + "skipped": result.skipped, + "skip_reasons": result.skip_reasons, + "slots_used": result.slots_used, + "slot_cap": result.slot_cap, + "wedges_remaining": result.wedges_remaining, + "wedge_buffer": result.wedge_buffer, + "failures": result.failures, + }, + ) + return result diff --git a/python/src/mlm/error_guidance.py b/python/src/mlm/error_guidance.py new file mode 100644 index 00000000..a9a6ed70 --- /dev/null +++ b/python/src/mlm/error_guidance.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from typing import Any + + +def error_guidance(message: str) -> dict[str, Any]: + """Turn a stored pipeline exception into useful operator guidance.""" + normalized = message.casefold() + + if "wedge reserve reached" in normalized: + return { + "title": "Freeleech wedge reserve reached", + "reason": ( + "This release requires a wedge, but using one would cross the wedge " + "buffer configured for MyAnonaSuite." + ), + "steps": [ + "Wait until you have more wedges, or lower the wedge buffer in " + "Configuration.", + "If ratio use is acceptable, change the source rule from wedge to " + "try_wedge or all.", + "Retry this release after changing the policy.", + ], + "component": "downloader", + } + + if "wedge" in normalized: + return { + "title": "MyAnonamouse rejected the wedge request", + "reason": ( + "The downloader asked MyAnonamouse to apply a freeleech wedge, but " + "the tracker did not confirm it." + ), + "steps": [ + "Open Downloader diagnostics and inspect the wedge response.", + "Confirm the account has an available wedge above the configured " + "reserve.", + "If ratio fallback is acceptable, enable Download if wedge fails " + "in Configuration and retry.", + "Retry once the tracker account and wedge balance are ready.", + ], + "component": "downloader", + } + + if "no longer free" in normalized or "freeleech" in normalized: + return { + "title": "Release is no longer freeleech", + "reason": ( + "The release stopped being free before it reached qBittorrent, and its " + "current rule does not allow ratio-based downloading." + ), + "steps": [ + "Refresh the source list to look for a different freeleech release.", + "Use a wedge-capable or ratio-capable grab rule if you still want " + "this release.", + "Retry after changing the rule or when the release becomes free again.", + ], + "component": "downloader", + } + + if any( + token in normalized + for token in ("mam_id", "session check", "not authorized", "403 forbidden") + ): + return { + "title": "MyAnonamouse session is not authorized", + "reason": ( + "MyAnonaSuite could not authenticate the configured mam_id cookie with " + "MyAnonamouse." + ), + "steps": [ + "Generate or copy a current API-session mam_id from MyAnonamouse.", + "Replace mam_id in config.toml, then restart MyAnonaSuite.", + "Open Diagnostics to confirm the session check succeeds before " + "retrying.", + ], + "component": "downloader", + } + + if any( + token in normalized + for token in ("qbittorrent", "/api/v2/", "connection refused", "connecterror") + ): + return { + "title": "qBittorrent could not accept the release", + "reason": ( + "MyAnonaSuite could not connect to qBittorrent or qBittorrent rejected " + "the request." + ), + "steps": [ + "Confirm qBittorrent is running and its Web UI is enabled.", + "Check the qBittorrent URL, username, and password in config.toml.", + "Open Downloader diagnostics for the HTTP failure, then retry.", + ], + "component": "downloader", + } + + if any( + token in normalized + for token in ( + "path_mapping", + "save_path", + "no such file", + "cannot find the path", + ) + ): + return { + "title": "Configured file path is unavailable", + "reason": ( + "The organizer or torrent client reported a path that MyAnonaSuite " + "cannot currently access." + ), + "steps": [ + "Confirm the drive or network share is mounted and readable.", + "Check library_dir and qBittorrent path_mapping values in config.toml.", + "Open Organizer diagnostics, correct the path, and run the organizer " + "again.", + ], + "component": "organizer", + } + + if "invalid size" in normalized: + return { + "title": "Source returned an unreadable size", + "reason": ( + "A source list supplied a file-size format that this build could not " + "parse." + ), + "steps": [ + "Refresh the list with the latest MyAnonaSuite build.", + "Open Lists diagnostics and note the source and exact size value.", + "If it repeats, keep this error and report the raw message for a " + "parser fix.", + ], + "component": "lists", + } + + if any( + token in normalized + for token in ( + "file placement", + "source file is", + "library destination", + "size mismatch", + "could not hardlink", + "placed file could not be verified", + ) + ): + return { + "title": "Library file placement failed", + "reason": ( + "HeavyMLM could not safely place and verify every media file. The " + "incomplete staging copy was removed, so Audiobookshelf will not see " + "an empty book folder." + ), + "steps": [ + "Compare the recorded source and destination paths below.", + "Confirm the source drive is mounted and the library drive has free " + "space and write permission.", + "Correct path_mapping or the library method if needed, then run " + "Organize files again.", + ], + "component": "organizer", + } + + return { + "title": "Download processing failed", + "reason": ( + "The release hit an unexpected failure. The exact message below is " + "preserved " + "so the failing service can be identified." + ), + "steps": [ + "Open Downloader diagnostics and inspect the entries at the same time " + "as this error.", + "Verify MyAnonamouse and qBittorrent are reachable, then retry once.", + "If it repeats, keep the raw error and diagnostics details for " + "troubleshooting.", + ], + "component": "downloader", + } diff --git a/python/src/mlm/library.py b/python/src/mlm/library.py new file mode 100644 index 00000000..94c78ef5 --- /dev/null +++ b/python/src/mlm/library.py @@ -0,0 +1,933 @@ +from __future__ import annotations + +import asyncio +import json +import os +import re +import shutil +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from .config import Config, QbitConfig +from .mam import MamClient +from .qbittorrent import QbitClient +from .repository import Repository +from .search import normalize_title, torrent_meta + +INVALID_FILENAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') +DISC_PATTERN = re.compile(r"(?:CD|Disc|Disk)\s*(\d+)", re.IGNORECASE) +ProgressCallback = Callable[ + [str, str, dict[str, object] | None], + None, +] + + +@dataclass +class OrganizerRun: + scanned: int = 0 + linked: int = 0 + already_existing: int = 0 + incomplete: int = 0 + skipped: int = 0 + failed: int = 0 + skip_reasons: dict[str, int] = field(default_factory=dict) + failures: list[dict[str, Any]] = field(default_factory=list) + + def skip(self, reason: str) -> None: + self.skipped += 1 + self.skip_reasons[reason] = self.skip_reasons.get(reason, 0) + 1 + + +class FilePlacementError(RuntimeError): + def __init__( + self, + message: str, + *, + source: Path | None = None, + destination: Path | None = None, + method: str | None = None, + remediation: str, + ) -> None: + super().__init__(message) + self.context: dict[str, object] = { + "remediation": remediation, + } + if source is not None: + self.context["source"] = str(source) + if destination is not None: + self.context["destination"] = str(destination) + if method is not None: + self.context["method"] = method + + +def sanitize_filename(value: str) -> str: + cleaned = INVALID_FILENAME.sub("_", value).strip().rstrip(". ") + return cleaned or "_" + + +def map_path(path_mapping: dict[str, str], save_path: str) -> Path: + source = Path(save_path) + matches = sorted( + ( + (Path(old), Path(new)) + for old, new in path_mapping.items() + if source == Path(old) or Path(old) in source.parents + ), + key=lambda pair: len(pair[0].parts), + reverse=True, + ) + if not matches: + return source + old, new = matches[0] + return new.joinpath(source.relative_to(old)) + + +def find_library(config: Config, torrent: dict[str, Any]) -> dict[str, Any] | None: + torrent_tags = { + tag.strip().casefold() + for tag in str(torrent.get("tags", "")).split(",") + if tag.strip() + } + torrent_category = str(torrent.get("category", "")).strip().casefold() + for library in config.libraries: + by_category = ( + "category" in library + and torrent_category == str(library["category"]).strip().casefold() + ) + by_directory = "download_dir" in library and ( + Path(torrent.get("save_path", "")) == Path(library["download_dir"]) + or Path(library["download_dir"]) + in Path(torrent.get("save_path", "")).parents + ) + if not (by_category or by_directory): + continue + denied = {str(tag).strip().casefold() for tag in library.get("deny_tags", [])} + if torrent_tags.intersection(denied): + continue + allowed = {str(tag).strip().casefold() for tag in library.get("allow_tags", [])} + if allowed and not torrent_tags.intersection(allowed): + continue + return library + return None + + +def _series_parts(meta: dict[str, Any]) -> tuple[str, str] | None: + series_rows = meta.get("series", []) + if not series_rows: + return None + series = next( + (row for row in series_rows if row.get("entries")), + series_rows[0], + ) + name = str(series.get("name", "")).strip() + entries = series.get("entries", []) + number = str(entries[0]) if entries else "" + return name, number + + +def library_directory( + exclude_narrator: bool, library: dict[str, Any], meta: dict[str, Any] +) -> Path | None: + authors = meta.get("authors", []) + if not authors: + return None + author = sanitize_filename(str(authors[0])) + title = str(meta.get("title", "")).strip() + series = _series_parts(meta) + if series: + series_name, number = series + leaf = f"{series_name} #{number} - {title}" if number else title + relative = ( + Path(author) / sanitize_filename(series_name) / sanitize_filename(leaf) + ) + else: + relative = Path(author) / sanitize_filename(title) + edition = meta.get("edition") + if edition: + edition_name = edition[0] if isinstance(edition, list) else str(edition) + relative = relative.with_name( + sanitize_filename(f"{relative.name}, {edition_name}") + ) + narrators = meta.get("narrators", []) + if narrators and not exclude_narrator: + relative = relative.with_name( + sanitize_filename(f"{relative.name} {{{narrators[0]}}}") + ) + return Path(library["library_dir"]) / relative + + +def select_format( + override: list[str] | None, preferred: tuple[str, ...], files: list[dict[str, Any]] +) -> str | None: + for extension in override or list(preferred): + suffix = "." + extension.lower().lstrip(".") + if any(str(row.get("name", "")).lower().endswith(suffix) for row in files): + return suffix + return None + + +def safe_torrent_path(name: str) -> Path: + normalized = name.replace("\\", "/") + parts = [part for part in normalized.split("/") if part not in {"", "."}] + if not parts or any(part == ".." or ":" in part for part in parts): + raise ValueError(f"unsafe torrent path: {name!r}") + return Path(*parts) + + +def _destination_relative(torrent_path: Path) -> Path: + parent = torrent_path.parent.name + match = DISC_PATTERN.search(parent) + return ( + Path(f"Disc {match.group(1)}") / torrent_path.name + if match + else Path(torrent_path.name) + ) + + +def _place_file(source: Path, destination: Path, method: str) -> None: + if destination.exists(): + if method.startswith("hardlink") and os.path.samefile(source, destination): + return + raise FileExistsError(f"library file already exists: {destination}") + if method == "hardlink": + try: + os.link(source, destination) + except OSError as error: + raise OSError( + f"could not hardlink {source} to {destination}; if the download and " + "library are on different drives, set method = " + '"hardlink_or_copy" in that [[library]] section' + ) from error + elif method == "hardlink_or_copy": + try: + os.link(source, destination) + except OSError: + shutil.copy2(source, destination) + elif method == "hardlink_or_symlink": + try: + os.link(source, destination) + except OSError: + destination.symlink_to(source) + elif method == "copy": + shutil.copy2(source, destination) + elif method == "symlink": + destination.symlink_to(source) + elif method != "no_link": + raise ValueError(f"unknown library method: {method}") + + +def _source_size(source: Path, destination: Path, method: str) -> int: + try: + if not source.exists(): + raise FileNotFoundError(source) + if not source.is_file(): + raise IsADirectoryError(source) + size = source.stat().st_size + except OSError as error: + raise FilePlacementError( + f"source file is unavailable: {source} ({error})", + source=source, + destination=destination, + method=method, + remediation=( + "Check qBittorrent's save_path, the configured path_mapping, and " + "that the download drive is mounted and readable." + ), + ) from error + if size <= 0: + raise FilePlacementError( + f"source file is zero bytes: {source}", + source=source, + destination=destination, + method=method, + remediation=( + "Recheck or redownload this torrent in qBittorrent before running " + "the organizer again." + ), + ) + return size + + +def _validate_placed_file( + source: Path, + destination: Path, + method: str, + source_size: int, +) -> None: + try: + if not destination.exists() or not destination.is_file(): + raise FileNotFoundError(destination) + destination_size = destination.stat().st_size + except OSError as error: + raise FilePlacementError( + f"placed file could not be verified: {destination} ({error})", + source=source, + destination=destination, + method=method, + remediation=( + "Check the library drive and Windows permissions, then run the " + "organizer again." + ), + ) from error + if destination_size != source_size: + raise FilePlacementError( + "file placement size mismatch: " + f"source has {source_size} bytes but destination has " + f"{destination_size} bytes", + source=source, + destination=destination, + method=method, + remediation=( + "Check the library drive for free space or I/O errors, remove any " + "partial output, and run the organizer again." + ), + ) + + +def _prepare_staging_directory(target_dir: Path) -> Path: + try: + target_dir.parent.mkdir(parents=True, exist_ok=True) + if target_dir.exists(): + if not target_dir.is_dir(): + raise FilePlacementError( + f"library destination is not a directory: {target_dir}", + destination=target_dir, + remediation=( + "Move or rename the existing item at this path, then run the " + "organizer again." + ), + ) + if any(target_dir.iterdir()): + raise FilePlacementError( + "library destination already exists and is not empty: " + f"{target_dir}", + destination=target_dir, + remediation=( + "Review the existing library folder. HeavyMLM will not " + "overwrite it; merge or rename it before retrying." + ), + ) + target_dir.rmdir() + staging = target_dir.parent / ( + f".{target_dir.name}.heavymlm-staging-{uuid4().hex}" + ) + staging.mkdir() + return staging + except FilePlacementError: + raise + except OSError as error: + raise FilePlacementError( + f"could not prepare library staging folder: {error}", + destination=target_dir, + remediation=( + "Check that the library drive is mounted, has free space, and grants " + "HeavyMLM permission to create folders." + ), + ) from error + + +def _publish_staging_directory(staging: Path, target_dir: Path) -> None: + if target_dir.exists(): + raise FilePlacementError( + f"library destination appeared while files were being placed: {target_dir}", + destination=target_dir, + remediation=( + "Review the competing library process or existing folder, then run " + "the organizer again." + ), + ) + try: + staging.replace(target_dir) + except OSError as error: + raise FilePlacementError( + f"could not publish completed library folder: {error}", + destination=target_dir, + remediation=( + "Check the library drive and permissions, then run the organizer again." + ), + ) from error + + +def _write_metadata(path: Path, metadata: dict[str, Any]) -> None: + path.write_text( + json.dumps(metadata, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + + +def _progress( + callback: ProgressCallback | None, + message: str, + *, + level: str = "info", + context: dict[str, object] | None = None, +) -> None: + if callback: + callback(message, level, context) + + +def _organizer_progress( + callback: ProgressCallback | None, + result: OrganizerRun, + *, + current: int, + total: int, +) -> None: + counts: dict[str, object] = { + "current": current, + "total": total, + "organized": result.linked, + "already_existing": result.already_existing, + "downloading": result.incomplete, + "skipped": result.skipped, + "errors": result.failed, + } + _progress( + callback, + ( + f"Progress {current}/{total} | {result.linked} organized | " + f"{result.already_existing} already existed | " + f"{result.incomplete} downloading | {result.skipped} skipped | " + f"{result.failed} errors" + ), + level="warning" if result.failed else "info", + context=counts, + ) + + +async def _library_scope_torrents( + config: Config, + qbit: QbitClient, +) -> tuple[list[tuple[dict[str, Any], dict[str, Any]]], dict[str, object]]: + categories = list( + dict.fromkeys( + str(library["category"]).strip() + for library in config.libraries + if str(library.get("category", "")).strip() + ) + ) + uses_directory_rules = any( + str(library.get("download_dir", "")).strip() for library in config.libraries + ) + if uses_directory_rules: + candidates = await qbit.torrents() + query_mode = "all_for_directory_rules" + elif categories: + category_results = await asyncio.gather( + *(qbit.torrents(category=category) for category in categories) + ) + candidates = [ + torrent for category_rows in category_results for torrent in category_rows + ] + query_mode = "configured_categories" + else: + candidates = [] + query_mode = "no_library_scope" + + unique: dict[str, dict[str, Any]] = {} + for torrent in candidates: + torrent_hash = str(torrent.get("hash", "")) + if torrent_hash: + unique[torrent_hash] = torrent + scoped = [ + (torrent, library) + for torrent in unique.values() + if (library := find_library(config, torrent)) is not None + ] + return scoped, { + "categories": categories, + "directory_rules": uses_directory_rules, + "query_mode": query_mode, + "returned_by_qbittorrent": len(candidates), + "eligible": len(scoped), + } + + +async def _organize_torrent( + config: Config, + repository: Repository, + qbit_config: QbitConfig, + qbit: QbitClient, + mam: MamClient, + qbit_torrent: dict[str, Any], + library: dict[str, Any], + *, + progress: ProgressCallback | None, +) -> str: + torrent_name = str(qbit_torrent.get("name") or qbit_torrent.get("hash")) + torrent_hash = str(qbit_torrent["hash"]) + context: dict[str, object] = { + "torrent": torrent_name, + "hash": torrent_hash, + "category": qbit_torrent.get("category"), + "save_path": qbit_torrent.get("save_path"), + } + existing = repository.torrent(torrent_hash) + if existing and existing.get("collection_items"): + compatibility_context = { + **context, + "collection_books": len(existing.get("collection_items") or []), + "remediation": ( + "Collection splitting was removed in beta 30. Existing beta 29 " + "library folders were left unchanged to avoid deleting or moving " + "files automatically. Review those folders manually if needed." + ), + } + repository.log_activity( + "organizer", + f"Left beta 29 collection unchanged: {torrent_name}", + level="warning", + context=compatibility_context, + ) + _progress( + progress, + f"Already processed by beta 29; collection left unchanged: {torrent_name}", + level="warning", + context=compatibility_context, + ) + return "already_organized" + if existing and not existing.get("client_status"): + _progress( + progress, + f"Checking tracker state: {torrent_name}", + context=context, + ) + trackers = await qbit.trackers(torrent_hash) + if any( + tracker.get("msg") == "torrent not registered with this tracker" + for tracker in trackers + ): + repository.mark_removed_from_mam(existing) + if existing and existing.get("library_path"): + _progress( + progress, + f"Already organized: {torrent_name}", + level="debug", + context={**context, "library_path": existing.get("library_path")}, + ) + return "already_organized" + + _progress(progress, f"Inspecting files: {torrent_name}", context=context) + files = await qbit.files(torrent_hash) + audio = select_format(library.get("audio_types"), config.audio_types, files) + ebook = select_format(library.get("ebook_types"), config.ebook_types, files) + if not audio and not ebook: + repository.log_activity( + "organizer", + f"Skipped {torrent_name}: no preferred audio or ebook file was found", + level="warning", + context={ + **context, + "files": [row.get("name") for row in files], + "audio_types": list(config.audio_types), + "ebook_types": list(config.ebook_types), + }, + ) + _progress( + progress, + f"Skipped {torrent_name}: no preferred audio or ebook files", + level="warning", + context={**context, "files": len(files)}, + ) + return "no_preferred_files" + + _progress( + progress, + f"Loading MaM metadata: {torrent_name}", + context={**context, "audio_format": audio, "ebook_format": ebook}, + ) + mam_row = await mam.get_torrent_info(torrent_hash) + if not mam_row: + repository.log_activity( + "organizer", + f"Skipped {torrent_name}: MaM metadata lookup returned no torrent", + level="warning", + context=context, + ) + _progress( + progress, + f"Skipped {torrent_name}: MaM metadata was not found", + level="warning", + context=context, + ) + return "missing_mam_metadata" + + meta = torrent_meta(mam_row) + method = str(library.get("method", "hardlink")) + target_dir = ( + None + if method == "no_link" + else library_directory(config.exclude_narrator_in_library_dir, library, meta) + ) + if method != "no_link" and target_dir is None: + author_diagnostics = { + **context, + "author_info_type": type(mam_row.get("author_info")).__name__, + "author_info": mam_row.get("author_info"), + "decoded_authors": meta.get("authors", []), + "metadata_keys": sorted(str(key) for key in mam_row), + } + repository.log_activity( + "organizer", + f"Skipped {torrent_name}: metadata has no author for the library path", + level="warning", + context=author_diagnostics, + ) + _progress( + progress, + f"Skipped {torrent_name}: metadata has no author", + level="warning", + context=author_diagnostics, + ) + return "missing_author" + + library_files: list[str] = [] + if target_dir is not None: + download_root = map_path( + qbit_config.path_mapping, str(qbit_torrent["save_path"]) + ) + selected_files: list[tuple[Path, Path]] = [] + for content in files: + torrent_path = safe_torrent_path(str(content["name"])) + lower_name = torrent_path.name.lower() + if not ( + (audio and lower_name.endswith(audio)) + or (ebook and lower_name.endswith(ebook)) + ): + continue + relative = _destination_relative(torrent_path) + selected_files.append((download_root / torrent_path, relative)) + if not selected_files: + raise FilePlacementError( + "no files remained after selecting the preferred format", + destination=target_dir, + method=method, + remediation=( + "Review the preferred audio and ebook extensions for this " + "library, then run the organizer again." + ), + ) + + planned: list[tuple[Path, Path, int]] = [] + destinations: set[Path] = set() + for file_index, (source, relative) in enumerate(selected_files, start=1): + destination = target_dir / relative + if relative in destinations: + raise FilePlacementError( + "multiple torrent files resolve to the same library path: " + f"{relative}", + source=source, + destination=destination, + method=method, + remediation=( + "Review the torrent's file layout and Disc folder names, then " + "organize this release manually if needed." + ), + ) + destinations.add(relative) + _progress( + progress, + f"Preflight file {file_index}/{len(selected_files)}: {relative}", + context={ + **context, + "method": method, + "source": str(source), + "destination": str(destination), + }, + ) + source_size = await asyncio.to_thread( + _source_size, source, destination, method + ) + planned.append((source, relative, source_size)) + + _progress( + progress, + f"Staging {len(planned)} file(s) for: {target_dir}", + context={ + **context, + "method": method, + "target": str(target_dir), + "files": len(planned), + }, + ) + staging_dir: Path | None = None + try: + staging_dir = await asyncio.to_thread( + _prepare_staging_directory, target_dir + ) + for file_index, (source, relative, source_size) in enumerate( + planned, start=1 + ): + staging_destination = staging_dir / relative + final_destination = target_dir / relative + _progress( + progress, + f"Placing file {file_index}/{len(planned)}: {relative}", + context={ + **context, + "method": method, + "source": str(source), + "destination": str(final_destination), + "source_bytes": source_size, + }, + ) + await asyncio.to_thread( + staging_destination.parent.mkdir, parents=True, exist_ok=True + ) + try: + await asyncio.to_thread( + _place_file, source, staging_destination, method + ) + await asyncio.to_thread( + _validate_placed_file, + source, + staging_destination, + method, + source_size, + ) + except FilePlacementError: + raise + except OSError as error: + raise FilePlacementError( + f"file placement failed: {error}", + source=source, + destination=final_destination, + method=method, + remediation=( + "Check the library drive's free space and Windows " + "permissions. For different drives, use method = " + '"hardlink_or_copy" or "copy".' + ), + ) from error + library_files.append(str(relative)) + _progress( + progress, + f"Verified: {relative} ({source_size} bytes)", + level="success", + context={ + **context, + "source": str(source), + "destination": str(final_destination), + "bytes": source_size, + }, + ) + try: + await asyncio.to_thread( + _write_metadata, + staging_dir / "metadata.json", + {"mam": mam_row, "meta": meta}, + ) + except OSError as error: + raise FilePlacementError( + f"could not write library metadata: {error}", + destination=target_dir / "metadata.json", + method=method, + remediation=( + "Check the library drive's free space and Windows write " + "permissions, then run the organizer again." + ), + ) from error + await asyncio.to_thread(_publish_staging_directory, staging_dir, target_dir) + staging_dir = None + _progress( + progress, + f"Published complete library folder: {target_dir}", + level="success", + context={ + **context, + "destination": str(target_dir), + "files": len(library_files), + }, + ) + finally: + if staging_dir is not None: + await asyncio.to_thread(shutil.rmtree, staging_dir, True) + _progress( + progress, + f"Rolled back incomplete library copy: {target_dir}", + level="warning", + context={**context, "target": str(target_dir)}, + ) + + now = datetime.now(UTC).isoformat() + torrent = { + "id": torrent_hash, + "id_is_hash": True, + "mam_id": meta["mam_id"], + "abs_id": existing.get("abs_id") if existing else None, + "goodreads_id": existing.get("goodreads_id") if existing else None, + "library_path": str(target_dir) if target_dir else None, + "library_files": sorted(library_files), + "linker": library.get("name"), + "category": qbit_torrent.get("category") or None, + "selected_audio_format": audio.lstrip(".") if audio else None, + "selected_ebook_format": ebook.lstrip(".") if ebook else None, + "title_search": normalize_title(meta["title"]), + "meta": meta, + "created_at": existing.get("created_at", now) if existing else now, + "replaced_with": existing.get("replaced_with") if existing else None, + "request_matadata_update": False, + "library_mismatch": None, + "client_status": existing.get("client_status") if existing else None, + } + repository.record_linked(torrent, meta["mam_id"]) + repository.log_activity( + "organizer", + f"Organized {torrent_name} into the library", + context={ + **context, + "library_path": str(target_dir) if target_dir else None, + "files": sorted(library_files), + "method": method, + }, + ) + _progress( + progress, + f"Organized: {torrent_name}", + level="success", + context={ + **context, + "library_path": str(target_dir) if target_dir else None, + "files": len(library_files), + "method": method, + }, + ) + return "linked" + + +async def organize_completed( + config: Config, + repository: Repository, + qbit_config: QbitConfig, + qbit: QbitClient, + mam: MamClient, + *, + progress: ProgressCallback | None = None, +) -> OrganizerRun: + result = OrganizerRun() + scoped_torrents, scope = await _library_scope_torrents(config, qbit) + _progress( + progress, + ( + f"Organizer scope: {len(scoped_torrents)} eligible torrents in " + f"{', '.join(scope['categories']) or 'configured library paths'}" + ), + context=scope, + ) + repository.log_activity( + "organizer", + "Loaded qBittorrent organizer scope", + context=scope, + ) + for index, (qbit_torrent, library) in enumerate(scoped_torrents, start=1): + result.scanned += 1 + torrent_name = str(qbit_torrent.get("name") or qbit_torrent.get("hash")) + context = { + "torrent": torrent_name, + "hash": qbit_torrent.get("hash"), + "category": qbit_torrent.get("category"), + "save_path": qbit_torrent.get("save_path"), + "current": index, + "total": len(scoped_torrents), + } + completion = float(qbit_torrent.get("progress", 0)) + _progress( + progress, + f"[{index}/{len(scoped_torrents)}] Checking {torrent_name}", + context={**context, "completion_percent": round(completion * 100, 1)}, + ) + if completion < 1: + result.incomplete += 1 + _progress( + progress, + f"Waiting for download: {torrent_name} ({completion:.0%})", + level="debug", + context=context, + ) + _organizer_progress( + progress, + result, + current=index, + total=len(scoped_torrents), + ) + continue + try: + outcome = await _organize_torrent( + config, + repository, + qbit_config, + qbit, + mam, + qbit_torrent, + library, + progress=progress, + ) + if outcome == "linked": + result.linked += 1 + elif outcome == "already_organized": + result.already_existing += 1 + else: + result.skip(outcome) + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - isolate failures per torrent + result.failed += 1 + error_text = f"{type(error).__name__}: {error}" + failure = { + **context, + "error": error_text, + "error_type": type(error).__name__, + } + if isinstance(error, FilePlacementError): + failure.update(error.context) + result.failures.append(failure) + repository.record_organizer_error( + str(qbit_torrent.get("hash", "")), + torrent_name, + error_text, + failure, + ) + repository.log_activity( + "organizer", + f"Failed to organize {torrent_name}", + level="error", + context=failure, + ) + _progress( + progress, + f"Failed {torrent_name}: {error_text}", + level="error", + context=failure, + ) + _organizer_progress( + progress, + result, + current=index, + total=len(scoped_torrents), + ) + _progress( + progress, + ( + f"Organizer finished: {result.scanned}/{len(scoped_torrents)} checked | " + f"{result.linked} organized | " + f"{result.already_existing} already existed | " + f"{result.incomplete} downloading | {result.skipped} skipped | " + f"{result.failed} errors" + ), + level="success" if not result.failed else "warning", + context={ + "scanned": result.scanned, + "linked": result.linked, + "already_existing": result.already_existing, + "incomplete": result.incomplete, + "skipped": result.skipped, + "failed": result.failed, + "skip_reasons": result.skip_reasons, + "failures": result.failures, + }, + ) + return result diff --git a/python/src/mlm/lists.py b/python/src/mlm/lists.py new file mode 100644 index 00000000..baa06691 --- /dev/null +++ b/python/src/mlm/lists.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import html +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx + +from .autograbber import select_row +from .config import Config +from .mam import MamClient +from .repository import Repository +from .search import search_pages, torrent_meta + +SERIES_PATTERN = re.compile(r"(.*?) \(([^)]*?),? #?(\d+(?:\.\d+)?)\)$") +BOOK_LINK = re.compile( + r'href=["\']((?:https://www\.goodreads\.com)?/book/show/[^"\']+)' +) +COVER_LINK = re.compile(r']+src=["\']([^"\']+)') +FORMAT_MEDIA_TYPES = { + "audio": ["audiobook", "periodical_audiobook"], + "ebook": ["ebook", "manga", "comic_book", "periodical_ebook"], +} + + +@dataclass(frozen=True) +class ListImportRun: + refreshed: int = 0 + selected: int = 0 + already_grabbed: int = 0 + no_match: int = 0 + removed: int = 0 + + +def _text(element: ET.Element, name: str) -> str | None: + child = next( + (item for item in element if item.tag.rsplit("}", 1)[-1] == name), None + ) + return child.text.strip() if child is not None and child.text else None + + +def goodreads_list_id(url: str) -> str: + parsed = urlparse(url) + user_id = parsed.path.rstrip("/").split("/")[-1] + shelf = parse_qs(parsed.query).get("shelf", [""])[0] + return f"{user_id}:{shelf}" + + +def _allow_media(grabs: list[dict[str, Any]], key: str) -> bool: + for grab in grabs: + categories = grab.get("categories", {}) + value = categories.get(key, True) + if value is True or (isinstance(value, list) and value): + return True + return False + + +async def run_goodreads_import( + config: Config, + repository: Repository, + mam: MamClient, + definition: dict[str, Any], + *, + client: httpx.AsyncClient | None = None, +) -> ListImportRun: + owns_client = client is None + http = client or httpx.AsyncClient( + headers={"User-Agent": "Mozilla/5.0 MLM-Python"}, timeout=30 + ) + try: + response = await http.get(definition["url"]) + response.raise_for_status() + finally: + if owns_client: + await http.aclose() + root = ET.fromstring(response.content) + channel = next( + (item for item in root.iter() if item.tag.rsplit("}", 1)[-1] == "channel"), + root, + ) + title = _text(channel, "title") or definition.get("name") or "Goodreads" + list_id = goodreads_list_id(definition["url"]) + now = datetime.now(UTC).isoformat() + dry_run = definition.get("dry_run", False) + component = "lists" + if not dry_run: + repository.log_activity( + component, + "Refreshing Goodreads source", + context={"list_id": list_id, "title": title}, + ) + if not dry_run: + repository.upsert_list( + {"id": list_id, "title": title, "updated_at": now, "build_date": now} + ) + selected = already_grabbed = no_match = 0 + seen_guids: set[str] = set() + for xml_item in [item for item in channel if item.tag.rsplit("}", 1)[-1] == "item"]: + guid_value = _text(xml_item, "guid") or _text(xml_item, "book_id") + item_title = html.unescape(_text(xml_item, "title") or "") + if not guid_value or not item_title: + continue + series: list[list[Any]] = [] + match = SERIES_PATTERN.fullmatch(item_title) + if match: + item_title = match.group(1) + series = [[html.unescape(match.group(2)), float(match.group(3))]] + description = _text(xml_item, "description") or "" + author = html.unescape(_text(xml_item, "author_name") or "").replace(".", " ") + cover_match = COVER_LINK.search(description) + book_match = BOOK_LINK.search(description) + book_id_text = _text(xml_item, "book_id") + book_id = int(book_id_text) if book_id_text and book_id_text.isdigit() else None + guid = [list_id, guid_value] + seen_guids.add(guid_value) + list_item = repository.list_item(guid) or { + "guid": guid, + "list_id": list_id, + "created_at": now, + "audio_torrent": None, + "ebook_torrent": None, + "marked_done_at": None, + "selected_mam_ids": [], + "selected_formats": [], + "check_count": 0, + } + list_item.update( + { + "title": item_title, + "authors": [author] if author else [], + "series": series, + "cover_url": _text(xml_item, "book_large_image_url") + or (cover_match.group(1) if cover_match else ""), + "book_url": ( + "https://www.goodreads.com" + book_match.group(1) + if book_match and book_match.group(1).startswith("/") + else (book_match.group(1) if book_match else None) + ), + "isbn": _text(xml_item, "isbn"), + "prefer_format": definition.get("prefer_format"), + "allow_audio": _allow_media(definition.get("grab", []), "audio"), + "allow_ebook": _allow_media(definition.get("grab", []), "ebook"), + "last_seen_at": now, + "source_status": "present", + } + ) + grab_both = bool(definition.get("grab_both_formats", config.grab_both_formats)) + known_formats = set(list_item.get("selected_formats", [])) + if list_item.get("audio_torrent"): + known_formats.add("audio") + if list_item.get("ebook_torrent"): + known_formats.add("ebook") + if book_id is not None: + known_formats.update(repository.goodreads_formats(book_id)) + previously_grabbed = bool( + list_item.get("marked_done_at") + or list_item.get("selected_mam_ids") + or list_item.get("audio_torrent") + or list_item.get("ebook_torrent") + or (book_id is not None and repository.has_goodreads_id(book_id)) + ) + desired_formats = [ + format_name + for format_name, allowed in ( + ("audio", list_item["allow_audio"]), + ("ebook", list_item["allow_ebook"]), + ) + if allowed + ] + missing_formats = [ + format_name + for format_name in desired_formats + if format_name not in known_formats + ] + if (not grab_both and previously_grabbed) or ( + grab_both and not missing_formats + ): + already_grabbed += 1 + list_item["status"] = "already_grabbed" + list_item["selected_formats"] = sorted(known_formats) + list_item["last_result"] = ( + "Skipped: audiobook and ebook already selected" + if grab_both + else "Skipped: already selected in an earlier run" + ) + if not dry_run: + repository.upsert_list_item(list_item) + repository.log_activity( + component, + f"Already grabbed: {item_title}", + level="debug", + context={ + "guid": guid_value, + "book_id": book_id, + "formats": sorted(known_formats), + }, + ) + continue + + targets = missing_formats if grab_both else ["any"] + list_item["last_checked_at"] = now + list_item["check_count"] = int(list_item.get("check_count", 0)) + 1 + if not dry_run: + repository.upsert_list_item(list_item) + query = " ".join( + filter(None, [f'"{item_title}"', f'"{author}"' if author else ""]) + ) + matched_ids: list[int] = [] + failed_targets: list[str] = [] + for target in targets: + if not dry_run: + repository.log_activity( + component, + f"Searching MaM for {target}: {item_title}", + level="debug", + context={ + "guid": guid_value, + "book_id": book_id, + "format": target, + "query": query, + "rules": len(definition.get("grab", [])), + }, + ) + matched_torrent_id: int | None = None + matched_format: str | None = None + candidate_count = 0 + for grab in definition.get("grab", []): + rule = { + **grab, + "type": "new", + "query": query, + "search_in": ["title", "author"], + "max_pages": 1, + "unsat_buffer": definition.get("unsat_buffer"), + "wedge_buffer": definition.get("wedge_buffer"), + "dry_run": definition.get("dry_run", False), + "name": definition.get("name", title), + } + if target != "any": + rule["media_type"] = FORMAT_MEDIA_TYPES[target] + async for row in search_pages(mam, rule): + candidate_count += 1 + if await select_row( + config, repository, row, rule, goodreads_id=book_id + ): + selected += 1 + matched_torrent_id = int(row["id"]) + media_type = torrent_meta(row)["media_type"] + matched_format = ( + "audio" + if media_type in FORMAT_MEDIA_TYPES["audio"] + else "ebook" + ) + break + else: + continue + break + if matched_torrent_id is not None and matched_format is not None: + matched_ids.append(matched_torrent_id) + known_formats.add(matched_format) + list_item[f"{matched_format}_torrent"] = matched_torrent_id + message = f"Selected {matched_format}: {item_title}" + activity_level = "success" + else: + failed_targets.append(target) + message = f"No {target} match: {item_title}" + activity_level = "warning" + if not dry_run: + repository.log_activity( + component, + message, + level=activity_level, + context={ + "guid": guid_value, + "book_id": book_id, + "format": target, + "mam_id": matched_torrent_id, + "query": query, + "candidates_evaluated": candidate_count, + }, + ) + + list_item["selected_mam_ids"] = list( + dict.fromkeys([*list_item.get("selected_mam_ids", []), *matched_ids]) + ) + list_item["selected_formats"] = sorted(known_formats) + all_formats_found = bool(desired_formats) and set(desired_formats).issubset( + known_formats + ) + if matched_ids: + list_item["status"] = ( + "selected" if not grab_both or all_formats_found else "partial" + ) + if not grab_both or all_formats_found: + list_item["marked_done_at"] = now + formats_text = ", ".join(sorted(known_formats)) + list_item["last_result"] = f"Selected formats: {formats_text}" + elif known_formats: + list_item["status"] = "partial" + missing_text = ", ".join(failed_targets) + list_item["last_result"] = f"Still missing: {missing_text}" + else: + no_match += 1 + list_item["status"] = "no_match" + list_item["last_result"] = ( + f"No configured {' or '.join(failed_targets)} release matched" + ) + if not dry_run: + repository.upsert_list_item(list_item) + + removed = 0 + for previous in repository.list_items_for_list(list_id) if not dry_run else []: + previous_guid = previous.get("guid", [None, None]) + guid_value = str(previous_guid[1]) if len(previous_guid) > 1 else "" + if guid_value in seen_guids or previous.get("source_status") == "removed": + continue + previous["source_status"] = "removed" + previous["removed_from_source_at"] = now + repository.upsert_list_item(previous) + removed += 1 + repository.log_activity( + component, + f"Removed from source: {previous.get('title', guid_value)}", + level="warning", + context={"guid": guid_value}, + ) + + result = ListImportRun( + refreshed=len(seen_guids), + selected=selected, + already_grabbed=already_grabbed, + no_match=no_match, + removed=removed, + ) + if not dry_run: + repository.upsert_list( + { + "id": list_id, + "title": title, + "updated_at": now, + "build_date": now, + "last_result": { + "refreshed": result.refreshed, + "selected": result.selected, + "already_grabbed": result.already_grabbed, + "no_match": result.no_match, + "removed": result.removed, + }, + } + ) + repository.log_activity( + component, + "Goodreads refresh complete", + level="success", + context={ + "refreshed": result.refreshed, + "selected": result.selected, + "already_grabbed": result.already_grabbed, + "no_match": result.no_match, + "removed": result.removed, + }, + ) + return result + + +async def run_notion_import( + config: Config, + repository: Repository, + mam: MamClient, + definition: dict[str, Any], + *, + client: httpx.AsyncClient | None = None, +) -> int: + owns_client = client is None + http = client or httpx.AsyncClient(timeout=30) + selected = 0 + cursor: str | None = None + try: + while True: + body = {"start_cursor": cursor} if cursor else {} + response = await http.post( + f"https://api.notion.com/v1/data_sources/{definition['data_source']}/query", + headers={ + "Notion-Version": "2025-09-03", + "Authorization": f"Bearer {definition['token']}", + }, + json=body, + ) + response.raise_for_status() + result = response.json() + for item in result.get("results", []): + properties = item.get("properties", {}) + for field in definition.get("mam_fields", []): + value = properties.get(field, {}) + url = value.get("url") if value.get("type") == "url" else None + if not url: + continue + try: + torrent_id = int(url.rstrip("/").split("/")[-1]) + except ValueError: + continue + if repository.has_mam_id(torrent_id): + continue + row = await mam.get_torrent_info_by_id(torrent_id) + if not row: + continue + for grab in definition.get("grab", []): + rule = { + **grab, + "unsat_buffer": definition.get("unsat_buffer"), + "wedge_buffer": definition.get("wedge_buffer"), + "dry_run": definition.get("dry_run", False), + "name": definition.get("name", "Notion"), + } + if await select_row(config, repository, row, rule): + selected += 1 + break + if not result.get("has_more") or not result.get("next_cursor"): + break + cursor = result["next_cursor"] + finally: + if owns_client: + await http.aclose() + return selected diff --git a/python/src/mlm/mam.py b/python/src/mlm/mam.py new file mode 100644 index 00000000..2304d529 --- /dev/null +++ b/python/src/mlm/mam.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any, Self + +import httpx + +from . import __version__ + +USER_AGENT = f"HeavyMLM/{__version__} (+https://github.com/Plungis/MLM)" + + +class MamError(RuntimeError): + pass + + +class MamRateLimitError(MamError): + pass + + +class MamWedgeError(MamError): + def __init__( + self, + message: str, + *, + reason: str = "unknown", + context: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.reason = reason + self.context = context or {} + + +class MamClient: + BASE_URL = "https://www.myanonamouse.net" + + def __init__( + self, + mam_id: str, + *, + client: httpx.AsyncClient | None = None, + timeout: float = 20, + cookie_store: Callable[[str], None] | None = None, + ) -> None: + self._owns_client = client is None + self.client = client or httpx.AsyncClient( + base_url=self.BASE_URL, + headers={"User-Agent": USER_AGENT}, + timeout=timeout, + follow_redirects=True, + ) + self.cookie_store = cookie_store + self.client.cookies.set("mam_id", mam_id, domain="www.myanonamouse.net") + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def close(self) -> None: + if self._owns_client: + await self.client.aclose() + + def set_mam_id(self, mam_id: str) -> None: + """Replace the live API-session cookie after an in-app config save.""" + self.client.cookies.set( + "mam_id", + mam_id, + domain="www.myanonamouse.net", + ) + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + if response.status_code == 429: + raise MamRateLimitError("Myanonamouse rate limit reached") + try: + response.raise_for_status() + except httpx.HTTPStatusError as error: + raise MamError(str(error)) from error + + def _remember_cookie(self) -> None: + if self.cookie_store is None: + return + for cookie in self.client.cookies.jar: + if cookie.name == "mam_id": + self.cookie_store(cookie.value) + return + + async def check_mam_id(self) -> None: + response = await self.client.get("/json/checkCookie.php") + self._raise_for_status(response) + text = response.text.lstrip("\ufeff") + result: dict[str, Any] | None = None + try: + parsed = response.json() + if isinstance(parsed, dict): + result = parsed + except ValueError: + pass + success = result is not None and result.get("Success") is True + if not success: + success = ( + re.search( + r"""["']?Success["']?\s*:\s*true\b""", + text, + flags=re.IGNORECASE, + ) + is not None + ) + if not success: + success = text.strip().casefold().startswith("session type - api session") + if not success: + detail = None + if result is not None: + detail = result.get("Error") or result.get("Message") + if detail: + raise MamError(f"session check rejected the mam_id cookie: {detail}") + content_type = response.headers.get("content-type", "unknown") + raise MamError( + "session check did not report success " + f"(content-type={content_type}, response-bytes={len(response.content)})" + ) + self._remember_cookie() + + async def user_info(self) -> dict[str, Any]: + response = await self.client.get( + "/jsonLoad.php", params={"snatch_summary": "true"} + ) + self._raise_for_status(response) + self._remember_cookie() + return response.json() + + async def request_json( + self, + path: str, + *, + params: Any = None, + ) -> Any: + """Make an authenticated JSON GET request for suite modules.""" + response = await self.client.get(path, params=params) + self._raise_for_status(response) + self._remember_cookie() + try: + return response.json() + except ValueError as error: + raise MamError( + "Myanonamouse returned a non-JSON response " + f"from {path} (content-type=" + f"{response.headers.get('content-type', 'unknown')})" + ) from error + + async def search(self, query: dict[str, Any]) -> dict[str, Any]: + response = await self.client.post("/tor/js/loadSearchJSONbasic.php", json=query) + self._raise_for_status(response) + self._remember_cookie() + result = response.json() + if isinstance(result, dict) and result.get("error"): + if result["error"] == "Nothing returned, out of 0": + return {"data": [], "found": 0} + raise MamError(str(result["error"])) + return result + + async def get_torrent_info_by_id(self, torrent_id: int) -> dict[str, Any] | None: + result = await self.search( + { + "description": True, + "mediaInfo": True, + "isbn": True, + "dlLink": True, + "tor": {"id": torrent_id}, + } + ) + rows = result.get("data", []) + return rows[-1] if rows else None + + async def get_torrent_info(self, torrent_hash: str) -> dict[str, Any] | None: + result = await self.search( + { + "description": True, + "isbn": True, + "tor": {"hash": torrent_hash}, + } + ) + rows = result.get("data", []) + return rows[-1] if rows else None + + async def get_torrent_file( + self, + torrent_id: int, + *, + use_wedge: bool = False, + ) -> bytes: + """Download a torrent through MaM's documented automation endpoint.""" + endpoint = f"/tor/download.php?tid={torrent_id}" + if use_wedge: + # MaM documents `fl` as an empty GET argument. Keep it bare instead of + # using an unrelated bonus-purchase API that rejects API sessions. + endpoint += "&fl" + try: + response = await self.client.get(endpoint) + except Exception as error: + if use_wedge: + raise MamWedgeError( + f"wedge download could not reach MaM: {error}", + reason="request_failed", + context={ + "stage": "wedge_download_request", + "endpoint": endpoint, + "torrent_id": torrent_id, + }, + ) from error + raise + try: + self._raise_for_status(response) + except MamRateLimitError: + raise + except Exception as error: + if use_wedge: + raise MamWedgeError( + ( + "MaM wedge download returned HTTP " + f"{response.status_code}: {error}" + ), + reason="http_error", + context={ + "stage": "wedge_download_http_response", + "endpoint": endpoint, + "torrent_id": torrent_id, + "http_status": response.status_code, + "content_type": response.headers.get("content-type"), + "response_preview": response.text[:500], + }, + ) from error + raise + self._remember_cookie() + if not response.content.startswith(b"d"): + context = { + "stage": ( + "wedge_download_response" + if use_wedge + else "torrent_download_response" + ), + "endpoint": endpoint, + "torrent_id": torrent_id, + "http_status": response.status_code, + "content_type": response.headers.get("content-type"), + "response_preview": response.text[:500], + } + if not use_wedge: + raise MamError( + "MaM torrent download did not return a torrent file " + f"(content-type={context['content_type']})" + ) + raise MamWedgeError( + "MaM did not return a torrent file after the wedge request", + reason="download_rejected", + context=context, + ) + return response.content + + async def snatchlist( + self, kind: str, page: int, cache_timestamp: int + ) -> dict[str, Any]: + user = await self.user_info() + kinds = { + "unsat": "unsat", + "inact_unsat": "inactUnsat", + "seed_unsat": "seedUnsat", + "seed_sat": "sSat", + "inact_sat": "inactSat", + "uploads_active": "upAct", + } + response = await self.client.get( + "https://cdn.myanonamouse.net/json/loadUserDetailsTorrents.php", + params={ + "uid": user["uid"], + "iteration": page, + "type": kinds[kind], + "cacheTime": cache_timestamp, + }, + ) + self._raise_for_status(response) + self._remember_cookie() + return response.json() + + +async def authenticated_mam_client( + configured_mam_id: str, + *, + stored_mam_id: str | None = None, + cookie_store: Callable[[str], None] | None = None, +) -> MamClient: + """Authenticate with a stored cookie, then fall back to the configured one.""" + candidates = [stored_mam_id, configured_mam_id] + attempted: set[str] = set() + last_error: MamError | None = None + for candidate in candidates: + if not candidate or candidate in attempted: + continue + attempted.add(candidate) + client = MamClient(candidate, cookie_store=cookie_store) + try: + await client.check_mam_id() + except MamError as error: + last_error = error + await client.close() + continue + return client + if last_error is not None: + raise last_error + raise MamError("no mam_id cookie is configured") diff --git a/python/src/mlm/migration.py b/python/src/mlm/migration.py new file mode 100644 index 00000000..7cf87d09 --- /dev/null +++ b/python/src/mlm/migration.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sqlite3 +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .database import DATA_TABLES, SCHEMA_VERSION, connect, initialize + +EXPORT_FORMAT = "mlm-native-db-export" +EXPORT_VERSION = 1 + + +class MigrationError(RuntimeError): + """Raised when a migration cannot be proven complete and consistent.""" + + +@dataclass(frozen=True) +class MigrationResult: + destination: Path + source_backup: Path + counts: dict[str, int] + export_sha256: str + + +def canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _timestamped_backup_path(source: Path) -> Path: + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + candidate = source.with_name(f"{source.name}.{stamp}.bak") + suffix = 1 + while candidate.exists(): + candidate = source.with_name(f"{source.name}.{stamp}.{suffix}.bak") + suffix += 1 + return candidate + + +def back_up_source(source: Path) -> Path: + source = source.resolve() + if not source.is_file(): + raise MigrationError(f"source database does not exist: {source}") + backup = _timestamped_backup_path(source) + shutil.copy2(source, backup) + return backup + + +def export_legacy_database( + executable: Path, database_backup: Path, output: Path +) -> None: + executable = executable.resolve() + if not executable.is_file(): + raise MigrationError(f"legacy executable does not exist: {executable}") + + with tempfile.TemporaryDirectory(prefix="mlm-export-config-") as temp_dir: + config_path = Path(temp_dir) / "config.toml" + config_path.write_text('mam_id = ""\n', encoding="utf-8") + environment = os.environ.copy() + environment["MLM_DB_FILE"] = str(database_backup) + environment["MLM_CONFIG_FILE"] = str(config_path) + completed = subprocess.run( + [str(executable), "--export-db", str(output)], + env=environment, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + detail = ( + completed.stderr.strip() or completed.stdout.strip() or "no error output" + ) + raise MigrationError(f"legacy export failed ({completed.returncode}): {detail}") + if not output.is_file(): + raise MigrationError( + "legacy exporter reported success but produced no JSON file" + ) + + +def _load_export(path: Path) -> tuple[dict[str, Any], str]: + raw = path.read_bytes() + digest = hashlib.sha256(raw).hexdigest() + try: + document = json.loads(raw) + except json.JSONDecodeError as error: + raise MigrationError(f"invalid export JSON: {error}") from error + if not isinstance(document, dict): + raise MigrationError("export root must be a JSON object") + if document.get("format") != EXPORT_FORMAT: + raise MigrationError(f"unsupported export format: {document.get('format')!r}") + if document.get("version") != EXPORT_VERSION: + raise MigrationError(f"unsupported export version: {document.get('version')!r}") + + for table in DATA_TABLES: + if not isinstance(document.get(table), list): + raise MigrationError(f"export field {table!r} must be an array") + declared = document.get("counts") + actual = {table: len(document[table]) for table in DATA_TABLES} + if declared != actual: + raise MigrationError( + f"export count mismatch: declared={declared!r}, actual={actual!r}" + ) + return document, digest + + +def _json_field(record: dict[str, Any], key: str) -> str | None: + value = record.get(key) + return None if value is None else canonical_json(value) + + +def _insert_records(connection: sqlite3.Connection, export: dict[str, Any]) -> None: + for row in export["config"]: + connection.execute( + "INSERT INTO config(key, value, payload_json) VALUES (?, ?, ?)", + (row["key"], row["value"], canonical_json(row)), + ) + for row in export["torrents"]: + connection.execute( + """INSERT INTO torrents + (id, mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + row["id"], + row["mam_id"], + row["title_search"], + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + for row in export["selected_torrents"]: + connection.execute( + """INSERT INTO selected_torrents + (mam_id, hash, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + row["mam_id"], + row.get("hash"), + row["title_search"], + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + for row in export["duplicate_torrents"]: + connection.execute( + """INSERT INTO duplicate_torrents + (mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?)""", + ( + row["mam_id"], + row["title_search"], + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + for row in export["errored_torrents"]: + connection.execute( + """INSERT INTO errored_torrents + (id_json, created_at_json, payload_json) VALUES (?, ?, ?)""", + ( + canonical_json(row["id"]), + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + for row in export["events"]: + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(row["id"]), + row.get("torrent_id"), + row.get("mam_id"), + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + for row in export["lists"]: + connection.execute( + "INSERT INTO lists(id, title, payload_json) VALUES (?, ?, ?)", + (row["id"], row["title"], canonical_json(row)), + ) + for row in export["list_items"]: + connection.execute( + """INSERT INTO list_items + (guid_json, list_id, title, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(row["guid"]), + row["list_id"], + row["title"], + _json_field(row, "created_at"), + canonical_json(row), + ), + ) + + +def _validate(connection: sqlite3.Connection, expected: dict[str, int]) -> None: + actual = { + table: connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + for table in DATA_TABLES + } + if actual != expected: + raise MigrationError( + f"SQLite count mismatch: expected={expected!r}, actual={actual!r}" + ) + result = connection.execute("PRAGMA integrity_check").fetchone()[0] + if result != "ok": + raise MigrationError(f"SQLite integrity check failed: {result}") + + +def migrate( + source_database: Path, + destination: Path, + *, + export_json: Path | None = None, + legacy_executable: Path | None = None, +) -> MigrationResult: + source_database = source_database.resolve() + destination = destination.resolve() + if source_database == destination: + raise MigrationError("source and destination must be different files") + if destination.exists(): + raise MigrationError(f"destination already exists: {destination}") + if (export_json is None) == (legacy_executable is None): + raise MigrationError("provide exactly one of export_json or legacy_executable") + + destination.parent.mkdir(parents=True, exist_ok=True) + backup = back_up_source(source_database) + + with tempfile.TemporaryDirectory( + prefix="mlm-migration-", dir=destination.parent + ) as temp_dir: + temp_dir_path = Path(temp_dir) + if legacy_executable is not None: + selected_export = temp_dir_path / "legacy-export.json" + export_legacy_database(legacy_executable, backup, selected_export) + else: + assert export_json is not None + selected_export = export_json.resolve() + if not selected_export.is_file(): + raise MigrationError(f"export JSON does not exist: {selected_export}") + + export, digest = _load_export(selected_export) + expected = {table: len(export[table]) for table in DATA_TABLES} + temporary_database = temp_dir_path / "data.sqlite3" + connection = connect(temporary_database) + try: + initialize(connection) + with connection: + _insert_records(connection, export) + metadata = { + "schema_version": str(SCHEMA_VERSION), + "migrated_at": datetime.now(UTC).isoformat(), + "source_database": str(source_database), + "source_backup": str(backup), + "export_sha256": digest, + "source_counts": canonical_json(expected), + } + connection.executemany( + "INSERT INTO migration_meta(key, value) VALUES (?, ?)", + metadata.items(), + ) + _validate(connection, expected) + except (KeyError, TypeError, ValueError, sqlite3.Error) as error: + raise MigrationError(f"could not import export: {error}") from error + finally: + connection.close() + os.replace(temporary_database, destination) + + return MigrationResult(destination, backup, expected, digest) diff --git a/python/src/mlm/modules/__init__.py b/python/src/mlm/modules/__init__.py new file mode 100644 index 00000000..865be804 --- /dev/null +++ b/python/src/mlm/modules/__init__.py @@ -0,0 +1 @@ +"""Isolated application modules hosted by the MyAnonaSuite shell.""" diff --git a/python/src/mlm/modules/absidekick/README.md b/python/src/mlm/modules/absidekick/README.md new file mode 100644 index 00000000..9acb54d7 --- /dev/null +++ b/python/src/mlm/modules/absidekick/README.md @@ -0,0 +1,185 @@ +# ABSidekick module + +This directory owns Audiobookshelf matching and review behavior. It does not +import HeavyMLM download policy or MAM-Spender purchase behavior. + +## File map + +- `core.py` is the matching engine ported from `Plungis/absidekick-beta` + Beta V.91.1. It owns filtering, scoring, ABS API calls, metadata/tag writes, + job execution, previews, and review decisions. +- `service.py` adapts that engine to the MyAnonaSuite process. It owns module + settings, the current job, review persistence, and the small API used by the + suite web layer. +- `templates/absidekick.html` preserves the source controls and review desk + inside the shared suite shell. +- `static/absidekick.js` owns module interaction and talks only to + `/api/absidekick/*`. +- `static/absidekick.css` scopes ABSidekick's detailed workspace styles under + `.absidekick-module`, so module styling cannot leak into the suite shell. + +## Persistence + +Runtime state lives beside the selected SQLite database in the +`absidekick/` directory: + +- `settings.json` stores module settings and stores the ABS token by default. + Disable **Save URL, library, and API token** only when session-only behavior + is intentional. The UI never reads the saved token back into the browser. +- `review_state.json` remembers approved and rejected review items. + +## Native Google Books provider + +Google Books searches are made directly by `core.py`; they are not proxied +through Audiobookshelf. The optional API key is stored only in this module's +private `settings.json` and is restored after process restarts. Public settings +expose only whether a key exists, +whether its fingerprint matches the last successful test, the validation time, +and a safe error message. Neither the key nor its fingerprint is returned to +the browser. A tested key automatically enables Google as the second-stage +provider for previews, initial jobs, and Review Desk scans: the configured +Audiobookshelf provider runs first, and Google is contacted only if that +provider does not produce a confident match. Manual Google searching in the +Review Desk remains independent and available. Google timeouts, rate limits, +and 5xx responses receive one immediate retry. One exhausted transient search +does not disable Google; three consecutive failures open a circuit breaker for +that run. Authentication and API-configuration failures disable Google +immediately. The next successful search resets the transient failure count. + +The provider is fail-closed. Saving a key clears any previous validation, and +the Google code path refuses to make an outbound request until **Test & Enable** +receives a successful response from the official Books API. Removing or +replacing the key disables the provider again. The Config screen contains the +Google Cloud project, Books API enablement, credential, API restriction, and +optional stable-IP restriction steps. + +## Native Open Library provider + +Open Library searches are also made directly by `core.py`, using the official +`/search.json` API rather than Audiobookshelf's legacy OpenLib provider. It is +enabled by default as the third automatic stage: the configured Audiobookshelf +provider runs first, a tested Google key runs second, and Open Library runs only +when neither earlier stage passes the normal auto-match policy. The same native +provider is available in Review Desk manual search. + +No API key is needed. Config accepts an optional contact email for the +application-identification headers requested by Open Library. Requests are +limited to three per second when identified and one per second when anonymous, +and identical searches are cached for the life of the `ABSClient`. Only the +fields used by candidate scoring and display are requested. Returned work keys, +titles, authors, first publication years, ISBNs, publishers, languages, edition +counts, and cover IDs are converted to the common candidate model. + +Open Library timeouts, rate limits, and 5xx responses get one immediate retry; +three consecutive exhausted transient searches open a run-local circuit +breaker. A non-transient rejection disables the provider for that run and +records the underlying error in search diagnostics. Turning the provider off +in Config prevents both automatic and manual Open Library requests. + +The Review Desk also supports reviewer-controlled searches by title, optional +author, and metadata provider. Manual results are rescored by `core.py` against +the canonical Audiobookshelf item and use the normal review approval path. + +The automatic matcher is intentionally evidence-aware. It derives a bounded, +ranked set of title variants before searching. Strongly evidenced track, +series, and nested release prefixes are peeled in layers—for example, +`Dragonriders of Pern #3 - Pern08-Nerilka's Story` becomes `Nerilka's Story`, +then the intermediate and original forms remain available as fallbacks. Weak or +ambiguous prefixes keep the original title first, so names such as +`Catch 22 - A Novel` are not destructively cleaned. The matcher stops as soon +as a variant passes the normal auto-match policy, then uses native Google and +Open Library stages before optional configured fallback providers. Every +provider attempt records the exact title variant and author it searched, and +labels author-free broadening as `title only`. Decimal/lettered positions, +multi-volume ranges, compact track numbers, bracketed release groups, and disc +suffixes are recognized while numeric work titles remain intact. Identical +current/embedded title variants are collapsed and use their paired embedded +author rather than sending duplicate queries. Missing provider fields are +excluded from the weighted score. Contradictory authors, series +positions, collection status, or a close runner-up prevent automatic writes and +are recorded as explicit Review Desk reasons. When an ASIN or ISBN matches +exactly, a different secondary identifier or stored duration is treated as a +non-blocking edition note. An ISBN mismatch alone is also informational when +both title and author are present and strongly agree. ASIN, duration, +series-position, collection, weak/missing-author, and winner-margin conflicts +remain blocking without an exact identifier. This policy and its thresholds +live under the module's `matching` settings rather than in shared suite code. + +The current ABS title and the final folder name are both title evidence. When a +folder or parsed search title clearly matches the candidate but the current ABS +title is short, incomplete, or incorrect, that disagreement is displayed as a +non-blocking match note. It no longer vetoes an otherwise passing score, author, +signal-count, and winner-margin decision. Author, series-position, collection, +and competing-work conflicts remain blocking. Identifier and duration conflicts +remain blocking unless another identifier matches exactly, except for the +strong-title-and-author ISBN rule above. + +Before metadata-patch mode calls Audiobookshelf, author names are normalized and +deduplicated case-insensitively. If the candidate's normalized author set is +already the same as the book's current author set, the author field is omitted +even when metadata overwrite is enabled. This avoids Audiobookshelf 2.36.0's +`bookAuthors.bookId, bookAuthors.authorId` unique-constraint crash path without +preventing a genuinely different author set from being applied. + +## Embedded file metadata (opt-in) + +`matching.useEmbeddedFileMetadata` is disabled by default. When enabled, +ABSidekick uses the `metaTags` and filenames Audiobookshelf already reports for +the audio files associated with each library item; MyAnonaSuite does not open or +modify the media files. Album/title, artist/album artist, composer, series, +series sequence, year, ASIN, ISBN, publisher, and language are normalized into a +consensus evidence record. Album is preferred over per-track title because MP3 +track titles are commonly chapter names. Repeated filenames can also supply a +title after track counters and clearly numbered release prefixes are removed. A +multi-file value is accepted only when at least half of the files agree, and +chapter/part/track-only names are rejected. + +The embedded title, filename title, and author are tried as explicit +primary-provider queries and are included as alternate matching evidence. +Generic ABS placeholders such as `Star Wars Book 58` also try a distinct folder +basename before the placeholder query. Current ABS metadata and file evidence +are both retained; file evidence never directly overwrites ABS metadata. +The live job log, preview, search path, and Review Desk show how many files had +tags and every field that was used. If the library-items response omits audio +files, the module loads the full ABS item once. That optional lookup fails open, +records an actionable warning, and continues using normal ABS metadata. +List-shaped narrator data returned by Audiobookshelf is normalized before +deduplication and scoring. + +## Series repair + +`matching.repairSeries` is enabled by default and is independent of the broad +`overwriteMetadata` switch. After a match, ABSidekick writes Audiobookshelf's +structured series array with both the series name and its sequence. Audible +search responses use `series` for the series name while stored ABS metadata uses +`name`; the module accepts both shapes, preserves multiple series memberships, +deduplicates repeated entries, and retains decimal sequence values. + +Provider-supplied structured series data has priority. If it is absent, opted-in +embedded file tags may supply the series name and number; a clearly structured +provider subtitle such as `Sword of Truth, Book 1` is the final fallback. +Existing sequence data is preserved when the provider supplies the same series +without a number. Disable **Repair ABS series names and book numbers** in Match +Policy to retain the older fill-empty-only behavior. + +Search execution is deliberately bounded: no more than four evidence-ranked +title variants are produced, duplicate queries are suppressed, optional ABS +fallback providers are opt-in, and search requests use a short timeout. Native +Google and Open Library use a one-retry/three-strike transient-error policy; +other timed-out providers are disabled on that `ABSClient` for the rest of the +job. Provider failures are attached to preview/review rows and written to the +module log. + +The module UI has a persistent Live Activity strip for every server-backed +control. It owns button busy/disabled states, elapsed time, and persistent +success or actionable failure results. Review-tag scans additionally publish +thread-safe backend progress through `/api/absidekick/activity`, including the +loading/search phase, current title, and completed/total count. A refreshed UI +can resume observing an in-progress scan instead of losing its status. + +## Boundaries + +MyAnonaSuite owns routing, local-only access checks, static delivery, and the +shared layout. ABSidekick owns all Audiobookshelf calls and never uses the MaM +session. Blocking ABS API operations run off the FastAPI event loop so the rest +of the suite remains responsive during previews and review scans. diff --git a/python/src/mlm/modules/absidekick/__init__.py b/python/src/mlm/modules/absidekick/__init__.py new file mode 100644 index 00000000..a6e58253 --- /dev/null +++ b/python/src/mlm/modules/absidekick/__init__.py @@ -0,0 +1,6 @@ +"""ABSidekick Audiobookshelf matching module.""" + +from .core import APP_VERSION as SOURCE_VERSION +from .service import ABSidekickService + +__all__ = ["ABSidekickService", "SOURCE_VERSION"] diff --git a/python/src/mlm/modules/absidekick/core.py b/python/src/mlm/modules/absidekick/core.py new file mode 100644 index 00000000..068ab5dd --- /dev/null +++ b/python/src/mlm/modules/absidekick/core.py @@ -0,0 +1,4067 @@ +from __future__ import annotations + +import difflib +import hashlib +import html +import json +import re +import threading +import time +import traceback +import unicodedata +import urllib.error +import urllib.parse +import urllib.request +from collections.abc import Callable +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +APP_VERSION = "Beta V.91.1" +GOOGLE_TRANSIENT_STATUSES = {408, 429, 500, 502, 503, 504} +GOOGLE_TRANSIENT_FAILURE_LIMIT = 3 +OPEN_LIBRARY_TRANSIENT_STATUSES = {408, 429, 500, 502, 503, 504} +OPEN_LIBRARY_TRANSIENT_FAILURE_LIMIT = 3 + +PROVIDERS = [ + "google", + "openlibrary", + "itunes", + "audible", + "audible.ca", + "audible.uk", + "audible.au", + "audible.fr", + "audible.de", + "audible.jp", + "audible.it", + "audible.in", + "audible.es", + "fantlab", +] + +DEFAULT_SETTINGS: dict[str, Any] = { + "connection": { + "baseUrl": "", + "libraryId": "", + "provider": "audible", + "rememberConnection": True, + }, + "providers": { + "googleBooksApiKey": "", + "googleBooksApiKeyValidated": False, + "googleBooksApiKeyFingerprint": "", + "googleBooksApiKeyValidatedAt": "", + "googleBooksLastError": "", + "openLibraryEnabled": True, + "openLibraryContactEmail": "", + }, + "run": { + "dryRun": True, + "limit": 0, + "pageSize": 100, + "sort": "media.metadata.title", + "sortDesc": False, + "requestDelayMs": 150, + "timeoutSeconds": 30, + "searchTimeoutSeconds": 12, + "maxRetries": 2, + "stopOnError": False, + }, + "targeting": { + "mode": "unprocessed", + "includeAuthors": [], + "excludeAuthors": [], + "includeTags": [], + "excludeTags": [], + "includeTagMode": "any", + "titleContains": "", + "pathContains": "", + "missingMetadataOnly": False, + "missingCoverOnly": False, + "noAsinOnly": False, + "noIsbnOnly": False, + "skipMissingItems": True, + "skipInvalidItems": True, + }, + "matching": { + "threshold": 80, + "reviewFloor": 65, + "candidateLimit": 8, + "adaptiveSearch": True, + "automaticFallbackProviders": False, + "fallbackProviders": [], + "strictAutoMatch": True, + "minimumTitleScore": 86, + "minimumAuthorScore": 78, + "minimumWinnerMargin": 6, + "minimumStrongSignals": 2, + "applyMode": "metadata_patch", + "overwriteMetadata": False, + "repairSeries": True, + "coverMode": "if_missing", + "quickMatchFirstResultOnly": True, + "requireAuthor": False, + "requireTitleToken": True, + "durationToleranceMinutes": 7, + "useEmbeddedFileMetadata": False, + }, + "weights": { + "title": 50, + "author": 25, + "series": 8, + "narrator": 6, + "year": 6, + "duration": 5, + }, + "tags": { + "matchedTag": "ABSidekick: AutoMatched", + "unmatchedTag": "ABSidekick: AutoMatch Unmatched", + "reviewTag": "ABSidekick: Needs Review", + "tagMatched": True, + "tagUnmatched": True, + "tagReview": True, + "clearUnmatchedOnMatch": True, + "clearReviewOnMatch": True, + "clearMatchedOnUnmatched": False, + }, + "review": { + "scanLimit": 25, + "candidateLimit": 6, + "rejectAddsUnmatchedTag": True, + "rejectClearsReviewTag": True, + }, +} + +TITLE_NOISE = { + "audiobook", + "audio", + "book", + "edition", + "retail", + "unabridged", + "abridged", + "complete", + "download", + "epub", + "m4a", + "m4b", + "mobi", + "mp3", + "pdf", +} +GENERIC_WORDS = {"a", "an", "the", "book", "audiobook"} +ROMAN_NUMERALS = { + "ii": "2", + "iii": "3", + "iv": "4", + "v": "5", + "vi": "6", + "vii": "7", + "viii": "8", + "ix": "9", + "x": "10", + "xi": "11", + "xii": "12", + "xiii": "13", + "xiv": "14", + "xv": "15", + "xvi": "16", + "xvii": "17", + "xviii": "18", + "xix": "19", + "xx": "20", +} +COLLECTION_PATTERN = re.compile( + r"\b(?:box(?:ed)?\s+set|omnibus|complete\s+series|collection|books?\s+\d+\s*[-–]\s*\d+)\b", + re.IGNORECASE, +) + + +def utc_now() -> str: + return datetime.now(UTC).isoformat(timespec="seconds") + + +def deep_merge(base: dict[str, Any], incoming: dict[str, Any] | None) -> dict[str, Any]: + merged = deepcopy(base) + if not incoming: + return merged + for key, value in incoming.items(): + if isinstance(value, dict) and isinstance(merged.get(key), dict): + merged[key] = deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def load_settings(path: Path) -> dict[str, Any]: + if not path.exists(): + return deepcopy(DEFAULT_SETTINGS) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return deepcopy(DEFAULT_SETTINGS) + return deep_merge(DEFAULT_SETTINGS, data) + + +def save_settings( + path: Path, settings: dict[str, Any], token: str | None = None +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + data = deepcopy(settings) + data.setdefault("connection", {}) + if token and data["connection"].get("rememberConnection"): + data["connection"]["token"] = token + else: + data["connection"].pop("token", None) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(data, indent=2), encoding="utf-8") + temporary.replace(path) + + +def public_settings(settings: dict[str, Any], has_token: bool) -> dict[str, Any]: + data = deepcopy(settings) + token = data.get("connection", {}).pop("token", None) + data.setdefault("connection", {}) + data["connection"]["hasToken"] = bool(has_token or token) + providers = data.setdefault("providers", {}) + google_key = str(providers.pop("googleBooksApiKey", "") or "") + fingerprint = str(providers.pop("googleBooksApiKeyFingerprint", "") or "") + providers["hasGoogleBooksApiKey"] = bool(google_key) + providers["googleBooksReady"] = bool( + google_key + and providers.get("googleBooksApiKeyValidated") + and fingerprint == google_books_key_fingerprint(google_key) + ) + return data + + +def google_books_key_fingerprint(api_key: str) -> str: + return hashlib.sha256(str(api_key or "").encode("utf-8")).hexdigest() + + +def google_books_key_is_ready(settings: dict[str, Any]) -> bool: + providers = settings.get("providers", {}) + api_key = str(providers.get("googleBooksApiKey") or "") + return bool( + api_key + and providers.get("googleBooksApiKeyValidated") + and str(providers.get("googleBooksApiKeyFingerprint") or "") + == google_books_key_fingerprint(api_key) + ) + + +def normalize_text(value: Any) -> str: + if value is None: + return "" + value = ( + html.unescape(str(value)).lower().replace("&", " and ").replace("+", " and ") + ) + value = unicodedata.normalize("NFKD", value) + value = "".join( + character for character in value if not unicodedata.combining(character) + ) + value = re.sub(r"['’`]", "", value) + value = re.sub(r"[^a-z0-9]+", " ", value) + words = [ROMAN_NUMERALS.get(word, word) for word in value.split()] + words = [word for word in words if word not in GENERIC_WORDS] + return " ".join(words) + + +def normalize_identifier(value: Any) -> str: + return re.sub(r"[^a-z0-9]", "", str(value or "").lower()) + + +def normalize_title(value: Any) -> str: + return " ".join( + word for word in normalize_text(value).split() if word not in TITLE_NOISE + ) + + +def title_tokens(value: Any) -> set[str]: + return {word for word in normalize_title(value).split() if word} + + +def token_similarity(left: Any, right: Any) -> float: + left_tokens = title_tokens(left) + right_tokens = title_tokens(right) + if not left_tokens and not right_tokens: + return 100.0 + if not left_tokens or not right_tokens: + return 0.0 + intersection = len(left_tokens & right_tokens) + precision = intersection / len(right_tokens) + recall = intersection / len(left_tokens) + return ( + 100.0 * (2 * precision * recall / (precision + recall)) if intersection else 0.0 + ) + + +def split_people(value: Any) -> list[str]: + if not value: + return [] + if isinstance(value, list): + people: list[str] = [] + for item in value: + if isinstance(item, dict): + people.append(str(item.get("name") or item.get("author") or "")) + else: + people.append(str(item)) + else: + text = str(value) + for sep in [";", " and ", " & ", " / "]: + text = text.replace(sep, ",") + people = text.split(",") + + unique: list[str] = [] + seen: set[str] = set() + for value in people: + person = value.strip() + identity = normalize_text(person) + if not identity or identity in seen: + continue + unique.append(person) + seen.add(identity) + return unique + + +def first_present(*values: Any) -> Any: + for value in values: + if value not in (None, "", [], {}): + return value + return None + + +def ratio(a: Any, b: Any) -> float: + a_norm = normalize_title(a) + b_norm = normalize_title(b) + if not a_norm and not b_norm: + return 100.0 + if not a_norm or not b_norm: + return 0.0 + return difflib.SequenceMatcher(None, a_norm, b_norm).ratio() * 100.0 + + +def title_similarity(left: Any, right: Any) -> float: + # Do not reward pure containment here. A short title such as "Dune" is fully + # contained in "Dune Messiah", but they are different books. Known packaging + # suffixes are handled explicitly by title_match_variants instead. + return max(ratio(left, right), token_similarity(left, right)) + + +def title_match_variants(value: Any) -> list[str]: + raw = html.unescape(str(value or "")).strip() + if not raw: + return [] + variants = [raw] + suffix_marker = re.compile( + r"\b(?:audio(?:book)?|abridged|unabridged|book|edition|series|saga|" + r"trilogy|vol(?:ume)?|narrated)\b", + re.IGNORECASE, + ) + for match in re.finditer(r"\s*(?::|\s(?:-|\||\u2013|\u2014)\s)\s*", raw): + head = raw[: match.start()].strip() + tail = raw[match.end() :].strip() + if len(title_tokens(head)) >= 2 and suffix_marker.search(tail): + variants.append(head) + parenthetical = re.match(r"^(.*?)\s*[\(\[]([^\)\]]+)[\)\]]\s*$", raw) + if parenthetical and suffix_marker.search(parenthetical.group(2)): + variants.append(parenthetical.group(1).strip()) + return list(dict.fromkeys(variant for variant in variants if variant)) + + +def best_people_ratio(left: Any, right: Any) -> float: + left_people = split_people(left) + right_people = split_people(right) + if not left_people and not right_people: + return 100.0 + if not left_people or not right_people: + return 0.0 + return max( + max(ratio(a, b), token_similarity(a, b)) + for a in left_people + for b in right_people + ) + + +def year_score(left: Any, right: Any) -> float: + try: + left_year = int(str(left or "")[:4]) + right_year = int(str(right or "")[:4]) + except ValueError: + return 0.0 if left or right else 100.0 + delta = abs(left_year - right_year) + if delta == 0: + return 100.0 + if delta == 1: + return 80.0 + if delta <= 3: + return 55.0 + return 0.0 + + +def duration_score( + item_seconds: Any, candidate_duration: Any, tolerance_minutes: int +) -> float: + if not item_seconds or not candidate_duration: + return 0.0 + try: + item_minutes = float(item_seconds) / 60.0 + candidate_minutes = float(candidate_duration) + if candidate_minutes > 20000: + candidate_minutes = candidate_minutes / 60.0 + except (TypeError, ValueError): + return 0.0 + delta = abs(item_minutes - candidate_minutes) + tolerance = max(1.0, float(tolerance_minutes)) + if delta <= tolerance: + return 100.0 + if delta <= tolerance * 2: + return 75.0 + if delta <= tolerance * 4: + return 35.0 + return 0.0 + + +def item_metadata(item: dict[str, Any]) -> dict[str, Any]: + return item.get("media", {}).get("metadata", {}) or {} + + +def item_tags(item: dict[str, Any]) -> list[str]: + tags = item.get("media", {}).get("tags", []) or [] + return [str(tag) for tag in tags if tag] + + +def item_title(item: dict[str, Any]) -> str: + metadata = item_metadata(item) + return str( + first_present( + metadata.get("title"), + item.get("title"), + Path(str(item.get("path") or "")).name, + "", + ) + or "" + ) + + +def item_author(item: dict[str, Any]) -> str: + metadata = item_metadata(item) + return ", ".join( + split_people( + first_present(metadata.get("authorName"), metadata.get("authors"), "") + ) + ) + + +def _normalized_file_tag_key(value: Any) -> str: + key = re.sub(r"[^a-z0-9]+", "", str(value or "").casefold()) + return key.removeprefix("tag") + + +def _audio_file_tags(audio_file: dict[str, Any]) -> dict[str, Any]: + raw = first_present( + audio_file.get("metaTags"), + (audio_file.get("metadata") or {}).get("metaTags"), + {}, + ) + if not isinstance(raw, dict): + return {} + return { + _normalized_file_tag_key(key): value + for key, value in raw.items() + if _normalized_file_tag_key(key) and str(value or "").strip() + } + + +def _first_file_tag(tags: dict[str, Any], *names: str) -> str: + for name in names: + value = tags.get(_normalized_file_tag_key(name)) + if isinstance(value, list): + value = ", ".join( + str(entry).strip() for entry in value if str(entry).strip() + ) + text = str(value or "").strip() + if text: + return text + return "" + + +def _consensus_file_tag(values: list[str]) -> str: + cleaned = [str(value).strip() for value in values if str(value).strip()] + if not cleaned: + return "" + groups: dict[str, dict[str, Any]] = {} + for value in cleaned: + key = normalize_text(value) + if not key: + continue + row = groups.setdefault(key, {"value": value, "count": 0}) + row["count"] += 1 + if not groups: + return "" + winner = max(groups.values(), key=lambda row: int(row["count"])) + if len(cleaned) == 1 or len(groups) == 1: + return str(winner["value"]) + # Multi-track title tags are commonly chapter names. Only trust a repeated + # value when at least half of the tagged files agree. + if int(winner["count"]) >= max(2, (len(cleaned) + 1) // 2): + return str(winner["value"]) + return "" + + +def _audio_file_name(audio_file: dict[str, Any]) -> str: + metadata = audio_file.get("metadata") or {} + return str( + first_present( + metadata.get("filename"), + metadata.get("relPath"), + audio_file.get("filename"), + audio_file.get("relPath"), + "", + ) + or "" + ).strip() + + +def _path_basename(value: Any) -> str: + raw = str(value or "").strip().rstrip("/\\") + return re.split(r"[/\\]", raw)[-1].strip() if raw else "" + + +def _title_is_generic_placeholder(value: Any) -> bool: + normalized = normalize_title(value) + raw_normalized = re.sub( + r"[^a-z0-9]+", + " ", + unicodedata.normalize("NFKD", html.unescape(str(value or ""))) + .encode("ascii", "ignore") + .decode("ascii") + .casefold(), + ).strip() + if not normalized: + return True + if raw_normalized in {"unknown", "untitled", "unknown title", "audio book"}: + return True + if re.fullmatch(r"\d{1,4}", raw_normalized): + return True + return bool( + re.fullmatch( + r"(?:[a-z0-9]+\s+){0,4}(?:audio\s*)?book\s+\d{1,4}", + raw_normalized, + ) + ) + + +def _usable_evidence_title(value: Any, *, author: str = "") -> bool: + title = html.unescape(str(value or "")).strip(" ._-|[]()") + normalized = normalize_title(title) + if not normalized or _title_is_generic_placeholder(title): + return False + if re.fullmatch( + r"(?:chapter|chap|track|part|pt|disc|disk|cd|volume|vol)\s*\d*", + normalized, + ): + return False + words = title_tokens(title) + if not words or words <= { + "chapter", + "chap", + "track", + "part", + "disc", + "disk", + "audio", + "audiobook", + }: + return False + return not author or best_people_ratio(title, author) < 96 + + +def _filename_title(value: Any, *, strip_bare_counter: bool) -> str: + filename = _path_basename(value) + stem = re.sub(r"\.[a-z0-9]{2,5}$", "", filename, flags=re.IGNORECASE).strip() + if not stem: + return "" + cleaned = _leading_track_title(stem) + cleaned = re.sub( + r"\s*(?:-|_|\.)?\s*(?:chapter|chap|track|part|pt|disc|disk|cd)\s*" + r"#?\s*\d{1,4}(?:\s*(?:of|/)\s*\d{1,4})?\s*$", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ._-|") + if strip_bare_counter: + cleaned = re.sub( + r"\s*(?:-|_|\.)\s*0*\d{1,4}(?:\s*(?:of|/)\s*\d{1,4})?\s*$", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ._-|") + return cleaned + + +def _strip_filename_author_suffix(value: str, author: str) -> str: + if not value or not author: + return value + separators = list( + re.finditer(r"\s+(?:-|\u2013|\u2014|\||\bby\b)\s+", value, re.IGNORECASE) + ) + for separator in reversed(separators): + suffix = value[separator.end() :].strip() + prefix = value[: separator.start()].strip() + if prefix and best_people_ratio(suffix, author) >= 90: + return prefix + return value + + +def _filename_release_title(value: str) -> str: + """Extract a work title from a clearly numbered multi-part release name.""" + + parts = [ + part.strip() + for part in re.split(r"\s+(?:-|\u2013|\u2014|\|)\s+", value) + if part.strip() + ] + if len(parts) < 2: + return value + prefix = " - ".join(parts[:-1]) + suffix = parts[-1] + numbered_release = len(parts) >= 3 and bool(re.search(r"\b\d{1,3}\b", prefix)) + explicit_series_number = bool( + re.search( + r"(?:#|\b(?:book|volume|vol|series)\s*)\d{1,3}\s*$", + prefix, + flags=re.IGNORECASE, + ) + ) + suffix_words = title_tokens(suffix) + if ( + (numbered_release or explicit_series_number) + and suffix_words + and not suffix_words <= {"novel", "edition", "audiobook", "audio"} + ): + return suffix + return value + + +def _audio_filename_title_candidates( + audio_files: list[dict[str, Any]], *, author: str = "" +) -> list[dict[str, Any]]: + filenames = [_audio_file_name(audio_file) for audio_file in audio_files] + filenames = [filename for filename in filenames if filename] + if not filenames: + return [] + + if len(filenames) == 1: + candidate = _filename_title(filenames[0], strip_bare_counter=False) + candidate = _strip_filename_author_suffix(candidate, author) + candidate = _filename_release_title(candidate) + if _usable_evidence_title(candidate, author=author): + return [ + { + "title": candidate, + "source": "single audio filename", + "supportingFileCount": 1, + } + ] + return [] + + groups: dict[str, dict[str, Any]] = {} + for filename in filenames: + candidate = _filename_title(filename, strip_bare_counter=True) + candidate = _strip_filename_author_suffix(candidate, author) + candidate = _filename_release_title(candidate) + if not _usable_evidence_title(candidate, author=author): + continue + key = normalize_title(candidate) + row = groups.setdefault(key, {"title": candidate, "count": 0}) + row["count"] += 1 + if not groups: + return [] + winner = max(groups.values(), key=lambda row: int(row["count"])) + minimum_support = max(2, (len(filenames) + 1) // 2) + if int(winner["count"]) < minimum_support: + return [] + return [ + { + "title": str(winner["title"]), + "source": "repeated audio filename consensus", + "supportingFileCount": int(winner["count"]), + } + ] + + +def extract_embedded_file_metadata(item: dict[str, Any]) -> dict[str, Any]: + """Summarize audio tags already extracted by Audiobookshelf.""" + + media = item.get("media") or {} + raw_files = media.get("audioFiles") or [] + audio_files = [ + audio_file + for audio_file in raw_files + if isinstance(audio_file, dict) and not audio_file.get("exclude") + ] + tagged_files: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for audio_file in audio_files: + tags = _audio_file_tags(audio_file) + if tags: + tagged_files.append((audio_file, tags)) + + def consensus(*names: str) -> str: + return _consensus_file_tag( + [_first_file_tag(tags, *names) for _audio_file, tags in tagged_files] + ) + + title = consensus("album") + if not title: + title = consensus("title") + series_name = consensus("series", "mvnm") + series_sequence = consensus("seriespart", "mvin") + values = { + "title": title, + "subtitle": consensus("subtitle"), + "author": consensus("artist", "albumartist"), + "narrator": consensus("composer"), + "series": series_name, + "seriesSequence": series_sequence, + "publishedYear": consensus("year", "date"), + "asin": consensus("asin", "audibleasin"), + "isbn": consensus("isbn"), + "publisher": consensus("publisher"), + "language": consensus("language", "lang"), + } + values = {key: value for key, value in values.items() if value} + file_title_candidates = _audio_filename_title_candidates( + audio_files, author=str(values.get("author") or item_author(item)) + ) + source_files: list[str] = [] + for audio_file in audio_files[:5]: + filename = _audio_file_name(audio_file) + if filename: + source_files.append(str(filename)) + fields = list(values) + if file_title_candidates: + fields.append("fileTitleCandidates") + return { + **values, + "fileTitleCandidates": file_title_candidates, + "fileCount": len(audio_files), + "taggedFileCount": len(tagged_files), + "sourceFiles": source_files, + "fields": fields, + "status": "found" if values or file_title_candidates else "empty", + } + + +def embedded_item_metadata(item: dict[str, Any]) -> dict[str, Any]: + value = item.get("_absidekickEmbeddedMetadata") or {} + return value if isinstance(value, dict) else {} + + +def prepare_item_for_matching( + client: Any, item: dict[str, Any], settings: dict[str, Any] +) -> dict[str, Any]: + """Optionally hydrate and attach ABS-extracted audio-file metadata.""" + + if not settings.get("matching", {}).get("useEmbeddedFileMetadata", False): + return item + prepared = deepcopy(item) + media = prepared.get("media") or {} + if "audioFiles" not in media and prepared.get("id"): + try: + prepared = deepcopy(get_library_item(client, str(prepared["id"]))) + except Exception as error: # noqa: BLE001 - optional evidence must fail open + prepared["_absidekickEmbeddedMetadata"] = { + "status": "error", + "error": str(error), + "fileCount": 0, + "taggedFileCount": 0, + "fields": [], + "sourceFiles": [], + } + return prepared + prepared["_absidekickEmbeddedMetadata"] = extract_embedded_file_metadata(prepared) + return prepared + + +def item_author_values(item: dict[str, Any]) -> list[str]: + return list( + dict.fromkeys( + value + for value in (item_author(item), embedded_item_metadata(item).get("author")) + if value + ) + ) + + +def candidate_author(candidate: dict[str, Any]) -> Any: + return first_present( + candidate.get("author"), candidate.get("authors"), candidate.get("authorName") + ) + + +def candidate_narrator(candidate: dict[str, Any]) -> Any: + return first_present(candidate.get("narrator"), candidate.get("narrators")) + + +def candidate_series(candidate: dict[str, Any]) -> Any: + return ", ".join( + f"{entry['name']} #{entry['sequence']}" + if entry.get("sequence") + else str(entry["name"]) + for entry in series_payload(candidate) + ) + + +def _normalized_series_sequence(value: Any) -> str: + """Normalize a series position without destroying decimals or ranges.""" + + raw = html.unescape(str(value or "")).strip() + raw = re.sub( + r"^\s*(?:(?:book|volume|vol)\.?\s*#?\s*|#\s*)", + "", + raw, + flags=re.IGNORECASE, + ).strip() + if not raw: + return "" + + roman = raw.lower() + if re.fullmatch(r"[ivxlcdm]+", roman): + if roman == "i": + return "1" + if roman in ROMAN_NUMERALS: + return ROMAN_NUMERALS[roman] + + numeric = re.fullmatch(r"0*(\d+)(?:\.(\d+))?", raw) + if numeric: + whole = str(int(numeric.group(1))) + fraction = str(numeric.group(2) or "").rstrip("0") + return f"{whole}.{fraction}" if fraction else whole + return re.sub(r"\s+", " ", raw).strip() + + +def _series_entries(value: Any) -> list[tuple[str, str]]: + if not value: + return [] + if not isinstance(value, list): + value = [value] + entries: list[tuple[str, str]] = [] + for entry in value: + if isinstance(entry, dict): + name = str(entry.get("name") or entry.get("series") or "").strip() + sequence = str( + entry.get("sequence") or entry.get("seq") or entry.get("number") or "" + ).strip() + else: + name = str(entry).strip() + sequence = "" + if name: + entries.append((name, _normalized_series_sequence(sequence))) + return entries + + +def item_series_entries(item: dict[str, Any]) -> list[tuple[str, str]]: + metadata = item_metadata(item) + entries = _series_entries(metadata.get("series")) + if not entries: + name = first_present(metadata.get("seriesName"), metadata.get("series")) + sequence = first_present( + metadata.get("seriesSequence"), + metadata.get("sequence"), + metadata.get("seriesNumber"), + ) + entries = _series_entries({"name": name, "sequence": sequence}) + embedded = embedded_item_metadata(item) + embedded_entries = _series_entries( + { + "name": embedded.get("series"), + "sequence": embedded.get("seriesSequence"), + } + ) + return list(dict.fromkeys([*entries, *embedded_entries])) + + +def candidate_series_entries(candidate: dict[str, Any]) -> list[tuple[str, str]]: + return _series_entries(candidate.get("series")) + + +def best_series_score( + left: list[tuple[str, str]], right: list[tuple[str, str]] +) -> float | None: + if not left or not right: + return None + return max(title_similarity(a[0], b[0]) for a in left for b in right) + + +def series_sequence_conflict( + left: list[tuple[str, str]], right: list[tuple[str, str]] +) -> bool: + comparisons: list[bool] = [] + for left_name, left_sequence in left: + for right_name, right_sequence in right: + if ( + left_sequence + and right_sequence + and title_similarity(left_name, right_name) >= 80 + ): + comparisons.append(left_sequence == right_sequence) + return bool(comparisons) and not any(comparisons) + + +def item_identifier(item: dict[str, Any], name: str) -> str: + values = item_identifier_values(item, name) + return values[0] if values else "" + + +def item_identifier_values(item: dict[str, Any], name: str) -> list[str]: + metadata = item_metadata(item) + embedded = embedded_item_metadata(item) + return list( + dict.fromkeys( + normalized + for raw in ( + metadata.get(name), + metadata.get(f"{name}13"), + metadata.get(f"{name}10"), + embedded.get(name), + embedded.get(f"{name}13"), + embedded.get(f"{name}10"), + ) + if (normalized := normalize_identifier(raw)) + ) + ) + + +def candidate_identifier(candidate: dict[str, Any], name: str) -> str: + return normalize_identifier( + first_present( + candidate.get(name), + candidate.get(f"{name}13"), + candidate.get(f"{name}10"), + ) + ) + + +def title_values(item: dict[str, Any]) -> list[str]: + metadata = item_metadata(item) + title = item_title(item) + subtitle = str(metadata.get("subtitle") or "").strip() + values = [title] + if subtitle: + values.append(f"{title} {subtitle}") + raw_path = str(first_present(item.get("path"), item.get("relPath"), "") or "") + path_title = re.split(r"[/\\]", raw_path.rstrip("/\\"))[-1].strip() + if path_title and normalize_title(path_title) != normalize_title(title): + values.append(path_title) + embedded = embedded_item_metadata(item) + embedded_title = str(embedded.get("title") or "").strip() + if embedded_title: + values.append(embedded_title) + for candidate in embedded.get("fileTitleCandidates") or []: + candidate_title = str( + candidate.get("title") if isinstance(candidate, dict) else candidate + ).strip() + if candidate_title: + values.append(candidate_title) + return list( + dict.fromkeys( + variant + for value in values + for variant in title_match_variants(value) + if variant + ) + ) + + +def candidate_title_values(candidate: dict[str, Any]) -> list[str]: + title = str(candidate.get("title") or "").strip() + subtitle = str(candidate.get("subtitle") or "").strip() + values = [title, f"{title} {subtitle}" if subtitle else title] + return list( + dict.fromkeys( + variant + for value in values + for variant in title_match_variants(value) + if variant + ) + ) + + +def best_title_score(item: dict[str, Any], candidate: dict[str, Any]) -> float: + source_titles = title_values(item) + searched_title = str( + (candidate.get("_absidekickSearch") or {}).get("queryTitle") or "" + ).strip() + if searched_title: + source_titles = list(dict.fromkeys([*source_titles, searched_title])) + return max( + title_similarity(left, right) + for left in source_titles + for right in candidate_title_values(candidate) + ) + + +def title_token_gate(item_title_value: str, candidate_title_value: str) -> bool: + item_words = title_tokens(item_title_value) + candidate_words = title_tokens(candidate_title_value) + if not item_words or not candidate_words: + return True + important = {word for word in item_words if len(word) >= 4} + if not important: + important = item_words + return bool(important & candidate_words) + + +def score_candidate( + item: dict[str, Any], + candidate: dict[str, Any], + settings: dict[str, Any], + index: int = 0, +) -> dict[str, Any]: + metadata = item_metadata(item) + weights = settings.get("weights", {}) + matching = settings.get("matching", {}) + + title = item_title(item) + author_values = item_author_values(item) + author = author_values[0] if author_values else "" + embedded = embedded_item_metadata(item) + narrator_values = list( + dict.fromkeys( + person + for value in ( + first_present(metadata.get("narratorName"), metadata.get("narrators")), + embedded.get("narrator"), + ) + for person in split_people(value) + if person + ) + ) + + candidate_title = candidate.get("title") or "" + candidate_author_value = candidate_author(candidate) + candidate_narrator_value = candidate_narrator(candidate) + item_series = item_series_entries(item) + result_series = candidate_series_entries(candidate) + item_year_values = list( + dict.fromkeys( + value + for value in (metadata.get("publishedYear"), embedded.get("publishedYear")) + if value + ) + ) + candidate_year = candidate.get("publishedYear") + item_duration = item.get("media", {}).get("duration") + candidate_duration = candidate.get("duration") + title_score = best_title_score(item, candidate) + parts: dict[str, float | None] = { + "title": title_score, + "author": ( + max( + best_people_ratio(source_author, candidate_author_value) + for source_author in author_values + ) + if author_values and candidate_author_value + else None + ), + "series": best_series_score(item_series, result_series), + "narrator": ( + max( + best_people_ratio(source_narrator, candidate_narrator_value) + for source_narrator in narrator_values + ) + if narrator_values and candidate_narrator_value + else None + ), + "year": ( + max(year_score(item_year, candidate_year) for item_year in item_year_values) + if item_year_values and candidate_year + else None + ), + "duration": ( + duration_score( + item_duration, + candidate_duration, + int(matching.get("durationToleranceMinutes", 7)), + ) + if item_duration and candidate_duration + else None + ), + } + + searched_title = str( + (candidate.get("_absidekickSearch") or {}).get("queryTitle") or title + ) + if matching.get("requireTitleToken", True) and not title_token_gate( + searched_title, str(candidate_title) + ): + parts["title"] = min(float(parts["title"] or 0), 35.0) + + if ( + matching.get("requireAuthor", False) + and parts["author"] is not None + and parts["author"] < 70 + ): + parts["author"] = 0.0 + parts["title"] = min(float(parts["title"] or 0), 60.0) + + total_weight = 0.0 + weighted = 0.0 + for key, value in parts.items(): + if value is None: + continue + weight = float(weights.get(key, 0) or 0) + if weight <= 0: + continue + total_weight += weight + weighted += value * weight + score = weighted / total_weight if total_weight else 0.0 + + exact_identifiers: list[str] = [] + conflicts: list[str] = [] + advisories: list[str] = [] + for name in ("asin", "isbn"): + left_identifiers = item_identifier_values(item, name) + right_identifier = candidate_identifier(candidate, name) + if left_identifiers and right_identifier: + if right_identifier in left_identifiers: + exact_identifiers.append(name.upper()) + else: + conflicts.append(f"{name.upper()} differs") + if exact_identifiers: + score = max(score, 99.0) + + primary_title_score = max( + title_similarity(source_title, result_title) + for source_title in {item_title(item), searched_title} + for result_title in candidate_title_values(candidate) + ) + if primary_title_score < 55 <= title_score: + advisories.append( + "current ABS title differs from the matching folder/search title" + ) + if series_sequence_conflict(item_series, result_series): + conflicts.append("series sequence differs") + item_collection = bool(COLLECTION_PATTERN.search(" ".join(title_values(item)))) + candidate_collection = bool( + COLLECTION_PATTERN.search(" ".join(candidate_title_values(candidate))) + ) + if item_collection != candidate_collection: + conflicts.append("collection/box-set status differs") + if parts["duration"] == 0.0: + conflicts.append("duration differs substantially") + + signals: list[str] = [] + if exact_identifiers: + signals.append("exact identifier") + for name, minimum in ( + ("title", 92), + ("author", 90), + ("series", 90), + ("narrator", 90), + ("year", 80), + ("duration", 75), + ): + value = parts.get(name) + if value is not None and value >= minimum: + signals.append(name) + + return { + "index": index, + "score": round(score, 2), + "parts": { + key: round(value, 2) if value is not None else None + for key, value in parts.items() + }, + "evidenceCount": sum(value is not None for value in parts.values()), + "strongSignals": signals, + "exactIdentifiers": exact_identifiers, + "conflicts": conflicts, + "advisories": advisories, + "search": deepcopy(candidate.get("_absidekickSearch") or {}), + "source": { + "title": title, + "author": author, + "authorValues": author_values, + "embeddedMetadata": embedded, + }, + "candidate": candidate, + } + + +def rank_candidates( + item: dict[str, Any], + candidates: list[dict[str, Any]], + settings: dict[str, Any], +) -> list[dict[str, Any]]: + scored = [ + score_candidate(item, candidate, settings, index) + for index, candidate in enumerate(candidates) + ] + scored.sort( + key=lambda row: ( + row["score"], + len(row.get("exactIdentifiers") or []), + len(row.get("strongSignals") or []), + int(row.get("evidenceCount") or 0), + -int(row.get("index") or 0), + ), + reverse=True, + ) + return scored + + +def candidates_represent_same_work(left: dict[str, Any], right: dict[str, Any]) -> bool: + """Return true when two scored results are duplicate listings of one work.""" + + left_candidate = left.get("candidate") or {} + right_candidate = right.get("candidate") or {} + + for name in ("asin", "isbn"): + left_identifier = candidate_identifier(left_candidate, name) + right_identifier = candidate_identifier(right_candidate, name) + if left_identifier and left_identifier == right_identifier: + return True + + left_title = normalize_title(left_candidate.get("title")) + right_title = normalize_title(right_candidate.get("title")) + if not left_title or left_title != right_title: + return False + + left_collection = bool( + COLLECTION_PATTERN.search(" ".join(candidate_title_values(left_candidate))) + ) + right_collection = bool( + COLLECTION_PATTERN.search(" ".join(candidate_title_values(right_candidate))) + ) + if left_collection != right_collection: + return False + + if series_sequence_conflict( + candidate_series_entries(left_candidate), + candidate_series_entries(right_candidate), + ): + return False + + left_author = candidate_author(left_candidate) + right_author = candidate_author(right_candidate) + if left_author and right_author: + return best_people_ratio(left_author, right_author) >= 95 + if not left_author and not right_author: + return False + + # One provider may omit the author. It is not a competing match when the + # other result verifies the author and both return the exact same title. + known_author_score = max( + float((left.get("parts") or {}).get("author") or 0), + float((right.get("parts") or {}).get("author") or 0), + ) + return known_author_score >= 90 + + +def match_decision( + ranked: list[dict[str, Any]], settings: dict[str, Any] +) -> dict[str, Any]: + matching = settings.get("matching", {}) + threshold = float(matching.get("threshold", 80)) + review_floor = float(matching.get("reviewFloor", 65)) + strict = bool(matching.get("strictAutoMatch", True)) + title_minimum = float(matching.get("minimumTitleScore", 86)) + author_minimum = float(matching.get("minimumAuthorScore", 78)) + margin_minimum = float(matching.get("minimumWinnerMargin", 6)) + signal_minimum = int(matching.get("minimumStrongSignals", 2)) + policy = { + "autoThreshold": threshold, + "reviewFloor": review_floor, + "strict": strict, + "minimumTitleScore": title_minimum, + "minimumAuthorScore": author_minimum, + "minimumWinnerMargin": margin_minimum, + "minimumStrongSignals": signal_minimum, + } + if not ranked: + return { + "action": "unmatched", + "confidence": "none", + "margin": 0.0, + "score": 0.0, + "scorePassed": False, + "safetyPassed": False, + "strongSignalCount": 0, + "strongSignals": [], + "equivalentCandidateCount": 0, + "competingCandidateCount": 0, + "policy": policy, + "reasons": ["no metadata candidates returned"], + } + + best = ranked[0] + equivalent_candidates = [ + row for row in ranked[1:] if candidates_represent_same_work(best, row) + ] + competing_candidates = [ + row for row in ranked[1:] if not candidates_represent_same_work(best, row) + ] + runner_score = ( + float(competing_candidates[0]["score"]) if competing_candidates else 0.0 + ) + margin = round(float(best["score"]) - runner_score, 2) + parts = best.get("parts") or {} + reported_conflicts = list(best.get("conflicts") or []) + advisories = list(best.get("advisories") or []) + score = float(best["score"]) + strong_signals = list(best.get("strongSignals") or []) + + exact_identifier = bool(best.get("exactIdentifiers")) + title_score = float(parts.get("title") or 0) + author_value = parts.get("author") + item_has_author = bool(item_author_from_scored(best)) + candidate_has_author = bool(candidate_author(best.get("candidate") or {})) + edition_conflicts = [ + reason + for reason in reported_conflicts + if reason in {"ASIN differs", "ISBN differs", "duration differs substantially"} + ] + title_author_confirm_work = bool( + item_has_author + and candidate_has_author + and title_score >= 92 + and float(author_value or 0) >= 90 + ) + # An exact ASIN or ISBN identifies the selected edition more reliably than + # stale duration or alternate-edition identifiers already stored in ABS. + # A strongly agreeing title and author identify the work but not a specific + # edition, so only an ISBN mismatch becomes informational in that case. + # ASIN, duration, series, collection, author/title, and winner-margin gates + # remain blocking unless an exact identifier independently overrides the + # edition-level conflict. + overridden_edition_conflicts = ( + list(edition_conflicts) + if exact_identifier + else ( + [reason for reason in edition_conflicts if reason == "ISBN differs"] + if title_author_confirm_work + else [] + ) + ) + edition_conflict_override = bool(overridden_edition_conflicts) + safety_reasons = [ + reason + for reason in reported_conflicts + if reason not in overridden_edition_conflicts + ] + if edition_conflict_override: + if exact_identifier: + exact_label = ", ".join(best.get("exactIdentifiers") or []) + advisories.extend( + f"{reason} (non-blocking because {exact_label} matched exactly)" + for reason in overridden_edition_conflicts + ) + else: + advisories.extend( + f"{reason} (non-blocking because title and author strongly match)" + for reason in overridden_edition_conflicts + ) + if title_score < title_minimum and not exact_identifier: + safety_reasons.append( + f"title similarity {title_score:g} is below the required {title_minimum:g}" + ) + if ( + item_has_author + and candidate_has_author + and float(author_value or 0) < author_minimum + ): + safety_reasons.append( + "author evidence: similarity " + f"{float(author_value or 0):g} is below the required " + f"{author_minimum:g}" + ) + elif item_has_author and not candidate_has_author and not exact_identifier: + safety_reasons.append("candidate returned no author to verify") + if margin < margin_minimum and not exact_identifier: + safety_reasons.append( + f"winner margin: lead {margin:g} is below the required {margin_minimum:g}; " + "the top meaningfully different candidates are too close" + ) + if len(strong_signals) < signal_minimum and not exact_identifier: + signal_label = ", ".join(strong_signals) if strong_signals else "none" + safety_reasons.append( + f"only {len(strong_signals)} strong signal(s) ({signal_label}); " + f"{signal_minimum} required" + ) + quick_match_compatible = matching.get("applyMode") != "quick_match" or bool( + (best.get("search") or {}).get("quickMatchEligible") + ) + if not quick_match_compatible: + safety_reasons.append( + "candidate requires metadata patch mode because it did not come from " + "the first precise Audiobookshelf provider result" + ) + + auto_allowed = ( + score >= threshold + and quick_match_compatible + and (not strict or not safety_reasons) + ) + reasons = list(safety_reasons) + if score < threshold: + reasons.insert( + 0, + f"similarity score {score:g} is below the auto-match threshold " + f"{threshold:g}", + ) + decision_details = { + "score": score, + "scorePassed": score >= threshold, + "safetyPassed": not safety_reasons, + "titleScore": title_score, + "authorScore": (float(author_value) if author_value is not None else None), + "margin": margin, + "strongSignalCount": len(strong_signals), + "strongSignals": strong_signals, + "equivalentCandidateCount": len(equivalent_candidates), + "competingCandidateCount": len(competing_candidates), + "exactIdentifier": exact_identifier, + "editionConflictOverride": edition_conflict_override, + "editionConflicts": edition_conflicts, + "overriddenEditionConflicts": overridden_edition_conflicts, + "advisories": advisories, + "policy": policy, + } + if auto_allowed: + return { + "action": "auto", + "confidence": "high", + "reasons": [], + **decision_details, + } + if score >= review_floor or exact_identifier: + return { + "action": "review", + "confidence": "review", + "reasons": list(dict.fromkeys(reasons)) or ["below automatic threshold"], + **decision_details, + } + return { + "action": "unmatched", + "confidence": "low", + "reasons": list(dict.fromkeys(reasons)) or ["insufficient matching evidence"], + **decision_details, + } + + +def item_author_from_scored(scored: dict[str, Any]) -> str: + return str((scored.get("source") or {}).get("author") or "") + + +def has_any(haystack: list[str], needles: list[str]) -> bool: + hay = {normalize_text(value) for value in haystack} + return any(normalize_text(needle) in hay for needle in needles if needle) + + +def has_all(haystack: list[str], needles: list[str]) -> bool: + hay = {normalize_text(value) for value in haystack} + return all(normalize_text(needle) in hay for needle in needles if needle) + + +def item_authors_for_filter(item: dict[str, Any]) -> list[str]: + metadata = item_metadata(item) + authors = split_people( + first_present(metadata.get("authors"), metadata.get("authorName")) + ) + return authors + + +def should_process_item( + item: dict[str, Any], settings: dict[str, Any] +) -> tuple[bool, str]: + targeting = settings.get("targeting", {}) + tags_settings = settings.get("tags", {}) + tags = item_tags(item) + tags_norm = {normalize_text(tag) for tag in tags} + mode = targeting.get("mode", "unprocessed") + matched_tag = normalize_text(tags_settings.get("matchedTag")) + unmatched_tag = normalize_text(tags_settings.get("unmatchedTag")) + review_tag = normalize_text(tags_settings.get("reviewTag")) + + if targeting.get("skipMissingItems", True) and item.get("isMissing"): + return False, "missing item" + if targeting.get("skipInvalidItems", True) and item.get("isInvalid"): + return False, "invalid item" + if item.get("mediaType") != "book": + return False, "not a book" + + if mode == "unprocessed" and ({matched_tag, unmatched_tag, review_tag} & tags_norm): + return False, "already processed" + if mode == "unmatched" and unmatched_tag not in tags_norm: + return False, "not tagged unmatched" + if mode == "matched" and matched_tag not in tags_norm: + return False, "not tagged matched" + if mode == "review" and review_tag not in tags_norm: + return False, "not tagged review" + if mode == "not_matched" and matched_tag in tags_norm: + return False, "tagged matched" + + authors = item_authors_for_filter(item) + include_authors = targeting.get("includeAuthors") or [] + exclude_authors = targeting.get("excludeAuthors") or [] + if include_authors and not has_any(authors, include_authors): + return False, "author not included" + if exclude_authors and has_any(authors, exclude_authors): + return False, "author excluded" + + include_tags = targeting.get("includeTags") or [] + exclude_tags = targeting.get("excludeTags") or [] + if include_tags: + if targeting.get("includeTagMode") == "all": + if not has_all(tags, include_tags): + return False, "required tags missing" + elif not has_any(tags, include_tags): + return False, "required tag missing" + if exclude_tags and has_any(tags, exclude_tags): + return False, "excluded tag" + + title_filter = normalize_text(targeting.get("titleContains")) + if title_filter and title_filter not in normalize_text(item_title(item)): + return False, "title filter" + + path_filter = normalize_text(targeting.get("pathContains")) + path_value = normalize_text( + first_present(item.get("path"), item.get("relPath"), "") + ) + if path_filter and path_filter not in path_value: + return False, "path filter" + + metadata = item_metadata(item) + if ( + targeting.get("missingMetadataOnly") + and metadata.get("title") + and item_author(item) + ): + return False, "metadata present" + if targeting.get("missingCoverOnly") and item.get("media", {}).get("coverPath"): + return False, "cover present" + if targeting.get("noAsinOnly") and metadata.get("asin"): + return False, "asin present" + if targeting.get("noIsbnOnly") and metadata.get("isbn"): + return False, "isbn present" + + return True, "eligible" + + +def add_remove_tags( + existing: list[str], add: list[str] | None = None, remove: list[str] | None = None +) -> list[str]: + add = [tag for tag in (add or []) if tag] + remove_norm = {normalize_text(tag) for tag in (remove or []) if tag} + tags: list[str] = [] + seen: set[str] = set() + for tag in existing: + norm = normalize_text(tag) + if not norm or norm in remove_norm or norm in seen: + continue + tags.append(tag) + seen.add(norm) + for tag in add: + norm = normalize_text(tag) + if norm and norm not in seen: + tags.append(tag) + seen.add(norm) + return tags + + +def empty_value(value: Any) -> bool: + return value in (None, "", [], {}) + + +def series_payload(candidate: dict[str, Any]) -> list[dict[str, str | None]]: + """Translate provider series shapes to ABS Series Sequence objects. + + Audiobookshelf's Audible search returns ``{"series": name, "sequence": n}``, + while stored book metadata and some other providers use ``name``. Accept both + without flattening multiple series memberships. + """ + + raw_series = candidate.get("series") + values = raw_series if isinstance(raw_series, list) else [raw_series] + if empty_value(raw_series) and candidate.get("seriesName"): + values = [ + { + "name": candidate.get("seriesName"), + "sequence": candidate.get("seriesSequence"), + } + ] + + payload: list[dict[str, str | None]] = [] + positions: dict[str, int] = {} + for item in values: + if isinstance(item, dict): + name = str( + first_present( + item.get("name"), item.get("series"), item.get("title"), "" + ) + or "" + ).strip() + sequence = first_present( + item.get("sequence"), + item.get("seq"), + item.get("number"), + item.get("position"), + item.get("seriesSequence"), + ) + else: + name = str(item or "").strip() + sequence = candidate.get("seriesSequence") + identity = normalize_text(name) + if not identity: + continue + normalized_sequence = _normalized_series_sequence(sequence) or None + if identity in positions: + existing = payload[positions[identity]] + if not existing.get("sequence") and normalized_sequence: + existing["sequence"] = normalized_sequence + continue + positions[identity] = len(payload) + payload.append({"name": name, "sequence": normalized_sequence}) + return payload + + +def _existing_series_payload( + existing_metadata: dict[str, Any], +) -> list[dict[str, str | None]]: + rows = series_payload({"series": existing_metadata.get("series")}) + if rows: + return rows + return series_payload( + { + "seriesName": existing_metadata.get("seriesName"), + "seriesSequence": first_present( + existing_metadata.get("seriesSequence"), + existing_metadata.get("sequence"), + existing_metadata.get("seriesNumber"), + ), + } + ) + + +def _series_payload_identity( + rows: list[dict[str, str | None]], +) -> list[tuple[str, str]]: + return [ + ( + normalize_text(row.get("name")), + _normalized_series_sequence(row.get("sequence")).casefold(), + ) + for row in rows + if normalize_text(row.get("name")) + ] + + +def _preserve_known_series_sequences( + candidate_rows: list[dict[str, str | None]], + existing_rows: list[dict[str, str | None]], +) -> list[dict[str, str | None]]: + existing_by_name = { + normalize_text(row.get("name")): row + for row in existing_rows + if normalize_text(row.get("name")) + } + merged = deepcopy(candidate_rows) + for row in merged: + existing = existing_by_name.get(normalize_text(row.get("name"))) + if existing and not row.get("sequence") and existing.get("sequence"): + row["sequence"] = existing["sequence"] + return merged + + +def _series_from_subtitle(candidate: dict[str, Any]) -> list[dict[str, str | None]]: + subtitle = html.unescape(str(candidate.get("subtitle") or "")).strip() + if not subtitle or len(subtitle) > 180: + return [] + patterns = ( + re.compile( + r"^(?P.+?)(?:\s*[,|:\-–—]\s*)" + r"(?:book|volume|vol)\.?\s*#?\s*" + r"(?P\d{1,3}(?:\.\d+)?|[ivxlcdm]{1,8})$", + re.IGNORECASE, + ), + re.compile( + r"^(?:book|volume|vol)\.?\s*#?\s*" + r"(?P\d{1,3}(?:\.\d+)?|[ivxlcdm]{1,8})\s+" + r"(?:of|in)\s+(?:the\s+)?(?P.+?)(?:\s+series)?$", + re.IGNORECASE, + ), + ) + for pattern in patterns: + match = pattern.fullmatch(subtitle) + if not match: + continue + name = match.group("name").strip(" ,:|-–—") + if len(normalize_text(name)) < 3: + continue + return [ + { + "name": name, + "sequence": _normalized_series_sequence(match.group("sequence")), + } + ] + return [] + + +def enrich_candidate_series( + candidate: dict[str, Any], + item: dict[str, Any] | None = None, + settings: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Attach normalized, high-confidence series metadata to a candidate.""" + + rows = series_payload(candidate) + source = "provider" + if ( + not rows + and item + and (settings or {}).get("matching", {}).get("useEmbeddedFileMetadata", False) + ): + embedded = embedded_item_metadata(item) + rows = series_payload( + { + "seriesName": embedded.get("series"), + "seriesSequence": embedded.get("seriesSequence"), + } + ) + source = "embedded file metadata" + if not rows: + rows = _series_from_subtitle(candidate) + source = "provider subtitle" + if rows: + candidate["series"] = rows + candidate["_absidekickSeriesSource"] = source + return candidate + + +def candidate_metadata_payload( + existing_metadata: dict[str, Any], + candidate: dict[str, Any], + overwrite: bool, + repair_series: bool = True, +) -> dict[str, Any]: + mapped: dict[str, Any] = { + "title": candidate.get("title"), + "subtitle": candidate.get("subtitle"), + "authors": [ + {"name": person} for person in split_people(candidate_author(candidate)) + ], + "narrators": split_people(candidate_narrator(candidate)), + "series": series_payload(candidate), + "genres": candidate.get("genres") + if isinstance(candidate.get("genres"), list) + else split_people(candidate.get("genres")), + "publishedYear": candidate.get("publishedYear"), + "publishedDate": candidate.get("publishedDate"), + "publisher": candidate.get("publisher"), + "description": candidate.get("description"), + "isbn": candidate.get("isbn"), + "asin": candidate.get("asin"), + "language": candidate.get("language"), + } + if "explicit" in candidate: + mapped["explicit"] = bool(candidate.get("explicit")) + + payload: dict[str, Any] = {} + for key, value in mapped.items(): + if empty_value(value): + continue + existing_value = existing_metadata.get(key) + if key == "authors": + existing_value = first_present( + existing_metadata.get("authors"), + existing_metadata.get("authorName"), + ) + existing_authors = { + normalize_text(person) for person in split_people(existing_value) + } + candidate_authors = { + normalize_text(person) for person in split_people(value) + } + if existing_authors and existing_authors == candidate_authors: + # ABS 2.36.0 can crash while inserting duplicate book-author + # relationships. Do not invoke its author update path when the + # normalized relationship set is already correct. + continue + if key == "narrators" and existing_metadata.get("narratorName"): + existing_value = existing_metadata.get("narratorName") + if key == "series": + existing_series = _existing_series_payload(existing_metadata) + value = _preserve_known_series_sequences(value, existing_series) + if repair_series: + if _series_payload_identity(value) != _series_payload_identity( + existing_series + ): + payload[key] = value + continue + existing_value = existing_series + if overwrite or empty_value(existing_value): + payload[key] = value + return payload + + +class ABSAPIError(RuntimeError): + def __init__( + self, message: str, status: int | None = None, body: str | None = None + ) -> None: + super().__init__(message) + self.status = status + self.body = body + + +def _google_error_message(status: int, body: str) -> str: + detail = "" + try: + payload = json.loads(body) + detail = str((payload.get("error") or {}).get("message") or "").strip() + except (json.JSONDecodeError, AttributeError): + detail = "" + if status in {401, 403}: + guidance = ( + "Google rejected the API key. Enable the Books API and check the " + "key's API and IP restrictions." + ) + elif status == 429: + guidance = "Google Books quota is exhausted or temporarily rate-limited." + elif status == 400: + guidance = "Google rejected the Books API request or API key." + else: + guidance = f"Google Books returned HTTP {status}." + return f"{guidance}{f' Google says: {detail}' if detail else ''}" + + +def google_error_is_transient(error: ABSAPIError) -> bool: + return error.status is None or error.status in GOOGLE_TRANSIENT_STATUSES + + +def _google_books_payload( + api_key: str, + query: str, + *, + limit: int, + timeout_seconds: int, +) -> dict[str, Any]: + if not api_key: + raise ABSAPIError( + "Native Google Books is disabled: add and test an API key in " + "ABSidekick Config before selecting Google." + ) + params = urllib.parse.urlencode( + { + "q": query, + "key": api_key, + "maxResults": max(1, min(40, int(limit))), + "printType": "books", + "projection": "full", + } + ) + request = urllib.request.Request( + f"https://www.googleapis.com/books/v1/volumes?{params}", + headers={"Accept": "application/json", "User-Agent": "MyAnonaSuite/ABSidekick"}, + method="GET", + ) + try: + with urllib.request.urlopen( + request, timeout=max(3, min(60, int(timeout_seconds))) + ) as response: + raw = response.read() + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ABSAPIError( + _google_error_message(error.code, body), error.code, body + ) from error + except (urllib.error.URLError, TimeoutError) as error: + raise ABSAPIError(f"Google Books request failed: {error}") from error + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ABSAPIError("Google Books returned invalid JSON") from error + if not isinstance(payload, dict): + raise ABSAPIError("Google Books returned an invalid response") + if payload.get("error"): + body = json.dumps(payload) + raise ABSAPIError(_google_error_message(400, body), 400, body) + return payload + + +def _google_image_url(image_links: Any) -> str | None: + if not isinstance(image_links, dict): + return None + for name in ( + "extraLarge", + "large", + "medium", + "small", + "thumbnail", + "smallThumbnail", + ): + value = image_links.get(name) + if value: + return str(value).replace("http://", "https://", 1) + return None + + +def _google_isbn(identifiers: Any) -> str | None: + if not isinstance(identifiers, list): + return None + by_type = { + str(row.get("type") or ""): str(row.get("identifier") or "") + for row in identifiers + if isinstance(row, dict) + } + return by_type.get("ISBN_13") or by_type.get("ISBN_10") or None + + +def _google_candidate(item: Any) -> dict[str, Any] | None: + if not isinstance(item, dict) or not isinstance(item.get("volumeInfo"), dict): + return None + info = item["volumeInfo"] + title = str(info.get("title") or "").strip() + if not title: + return None + authors = info.get("authors") or [] + return { + "id": item.get("id"), + "title": title, + "subtitle": info.get("subtitle"), + "author": ", ".join(str(author) for author in authors) if authors else None, + "publisher": info.get("publisher"), + "publishedYear": str(info.get("publishedDate") or "")[:4] or None, + "publishedDate": info.get("publishedDate"), + "description": info.get("description"), + "cover": _google_image_url(info.get("imageLinks")), + "genres": info.get("categories") + if isinstance(info.get("categories"), list) + else None, + "isbn": _google_isbn(info.get("industryIdentifiers")), + "language": info.get("language"), + } + + +def search_google_books( + api_key: str, + title: str, + author: str = "", + *, + limit: int = 10, + timeout_seconds: int = 12, +) -> list[dict[str, Any]]: + query_parts = [f"intitle:{title}"] + if author: + query_parts.append(f"inauthor:{author}") + payload = _google_books_payload( + api_key, + " ".join(query_parts), + limit=limit, + timeout_seconds=timeout_seconds, + ) + candidates = [_google_candidate(item) for item in payload.get("items") or []] + return [candidate for candidate in candidates if candidate is not None] + + +def test_google_books_api_key( + api_key: str, *, timeout_seconds: int = 12 +) -> dict[str, Any]: + for attempt in range(2): + try: + payload = _google_books_payload( + api_key, + "isbn:9780547928227", + limit=1, + timeout_seconds=timeout_seconds, + ) + break + except ABSAPIError as error: + if google_error_is_transient(error) and attempt == 0: + time.sleep(0.5) + continue + raise + return { + "valid": True, + "sampleResults": len(payload.get("items") or []), + "message": "Google Books API key tested successfully.", + } + + +def _open_library_error_message(status: int, body: str) -> str: + detail = "" + try: + payload = json.loads(body) + detail = str(payload.get("error") or payload.get("message") or "").strip() + except (json.JSONDecodeError, AttributeError): + detail = "" + if status in {401, 403}: + guidance = ( + "Open Library rejected the request. Check the configured contact " + "email and allow the rate limiter to cool down." + ) + elif status == 429: + guidance = "Open Library is temporarily rate-limiting this installation." + else: + guidance = f"Open Library returned HTTP {status}." + return f"{guidance}{f' Open Library says: {detail}' if detail else ''}" + + +def open_library_error_is_transient(error: ABSAPIError) -> bool: + return error.status is None or error.status in OPEN_LIBRARY_TRANSIENT_STATUSES + + +def _open_library_user_agent(contact_email: str) -> str: + contact = re.sub(r"[\r\n]", "", str(contact_email or "").strip()) + project = "+https://github.com/Plungis/MLM" + return ( + f"MyAnonaSuite/ABSidekick ({contact}; {project})" + if contact + else f"MyAnonaSuite/ABSidekick ({project})" + ) + + +def _open_library_payload( + title: str, + author: str, + *, + limit: int, + timeout_seconds: int, + contact_email: str = "", +) -> dict[str, Any]: + safe_contact = re.sub(r"[\r\n]", "", str(contact_email or "").strip()) + params: dict[str, Any] = { + "title": title, + "limit": max(1, min(30, int(limit))), + "fields": ( + "key,title,subtitle,author_name,first_publish_year,cover_i,isbn," + "publisher,language,edition_count" + ), + } + if author: + params["author"] = author + headers = { + "Accept": "application/json", + "User-Agent": _open_library_user_agent(safe_contact), + } + if safe_contact: + headers["From"] = safe_contact + request = urllib.request.Request( + f"https://openlibrary.org/search.json?{urllib.parse.urlencode(params)}", + headers=headers, + method="GET", + ) + try: + with urllib.request.urlopen( + request, timeout=max(3, min(60, int(timeout_seconds))) + ) as response: + raw = response.read() + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise ABSAPIError( + _open_library_error_message(error.code, body), error.code, body + ) from error + except (urllib.error.URLError, TimeoutError) as error: + raise ABSAPIError(f"Open Library request failed: {error}") from error + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ABSAPIError("Open Library returned invalid JSON") from error + if not isinstance(payload, dict) or not isinstance(payload.get("docs", []), list): + raise ABSAPIError("Open Library returned an invalid response") + return payload + + +def _open_library_isbn(values: Any) -> str | None: + if not isinstance(values, list): + return None + normalized = [str(value).strip() for value in values if str(value).strip()] + return next( + (value for value in normalized if len(normalize_identifier(value)) == 13), None + ) or next( + (value for value in normalized if len(normalize_identifier(value)) == 10), + None, + ) + + +def _open_library_candidate(document: Any) -> dict[str, Any] | None: + if not isinstance(document, dict): + return None + title = str(document.get("title") or "").strip() + if not title: + return None + key = str(document.get("key") or "").strip() + authors = document.get("author_name") or [] + publishers = document.get("publisher") or [] + languages = document.get("language") or [] + cover_id = document.get("cover_i") + return { + "id": key or None, + "bookId": key.rsplit("/", 1)[-1] if key else None, + "title": title, + "subtitle": document.get("subtitle"), + "author": ", ".join(str(author) for author in authors) + if isinstance(authors, list) + else str(authors or "") or None, + "publisher": ( + str(publishers[0]) + if isinstance(publishers, list) and publishers + else str(publishers or "") or None + ), + "publishedYear": str(document.get("first_publish_year") or "") or None, + "cover": ( + f"https://covers.openlibrary.org/b/id/{cover_id}-L.jpg" + if cover_id + else None + ), + "isbn": _open_library_isbn(document.get("isbn")), + "language": languages if isinstance(languages, list) else [str(languages)], + "editionCount": document.get("edition_count"), + "openLibraryUrl": f"https://openlibrary.org{key}" if key else None, + } + + +def search_open_library( + title: str, + author: str = "", + *, + limit: int = 10, + timeout_seconds: int = 12, + contact_email: str = "", +) -> list[dict[str, Any]]: + payload = _open_library_payload( + title, + author, + limit=limit, + timeout_seconds=timeout_seconds, + contact_email=contact_email, + ) + candidates = [ + _open_library_candidate(document) for document in payload.get("docs") or [] + ] + return [candidate for candidate in candidates if candidate is not None] + + +class ABSClient: + def __init__( + self, + base_url: str, + token: str, + timeout_seconds: int = 30, + max_retries: int = 2, + request_delay_ms: int = 150, + google_books_api_key: str = "", + google_books_ready: bool = False, + open_library_enabled: bool = True, + open_library_contact_email: str = "", + ) -> None: + if not base_url: + raise ValueError("Audiobookshelf base URL is required") + if not token: + raise ValueError("Audiobookshelf API token is required") + self.base_url = base_url.rstrip("/") + self.token = token + self.timeout_seconds = timeout_seconds + self.max_retries = max_retries + self.request_delay_ms = request_delay_ms + self.google_books_api_key = google_books_api_key + self.google_books_ready = google_books_ready + self.open_library_enabled = open_library_enabled + self.open_library_contact_email = open_library_contact_email + self.open_library_cache: dict[tuple[str, str, int], list[dict[str, Any]]] = {} + self.open_library_next_request_at = 0.0 + self.open_library_rate_lock = threading.Lock() + self.disabled_search_providers: dict[str, str] = {} + self.transient_search_failures: dict[str, int] = {} + + def url(self, path: str, params: dict[str, Any] | None = None) -> str: + if not path.startswith("/"): + path = "/" + path + query = "" + if params: + clean_params = { + key: value + for key, value in params.items() + if value is not None and value != "" and value != [] + } + query = urllib.parse.urlencode(clean_params, doseq=True) + return f"{self.base_url}{path}" + (f"?{query}" if query else "") + + def request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + timeout_seconds: int | None = None, + max_retries: int | None = None, + ) -> Any: + url = self.url(path, params) + data = None + headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + } + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + request_timeout = max(1, int(timeout_seconds or self.timeout_seconds)) + retry_count = self.max_retries if max_retries is None else max(0, max_retries) + last_error: Exception | None = None + for attempt in range(retry_count + 1): + if self.request_delay_ms > 0: + time.sleep(self.request_delay_ms / 1000.0) + request = urllib.request.Request( + url, data=data, headers=headers, method=method.upper() + ) + try: + with urllib.request.urlopen( + request, timeout=request_timeout + ) as response: + raw = response.read() + if not raw: + return None + content_type = response.headers.get("Content-Type", "") + if "json" in content_type or raw[:1] in (b"{", b"["): + return json.loads(raw.decode("utf-8")) + return raw.decode("utf-8", errors="replace") + except urllib.error.HTTPError as error: + body_text = error.read().decode("utf-8", errors="replace") + if error.code in {429, 500, 502, 503, 504} and attempt < retry_count: + time.sleep(min(8, 2**attempt)) + last_error = error + continue + raise ABSAPIError( + f"ABS API returned HTTP {error.code} for {path}", + error.code, + body_text, + ) from error + except (urllib.error.URLError, TimeoutError) as error: + if "WRONG_VERSION_NUMBER" in str(error): + raise ABSAPIError( + "SSL failed while connecting to Audiobookshelf. Your " + "ABS URL probably uses https:// for a server that only " + "speaks http://. Try changing the ABS URL to http://...", + ) from error + last_error = error + if attempt < retry_count: + time.sleep(min(8, 2**attempt)) + continue + raise ABSAPIError( + f"ABS API request failed for {path}: {error}" + ) from error + raise ABSAPIError(f"ABS API request failed for {path}: {last_error}") + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return self.request("GET", path, params=params) + + def search_books( + self, params: dict[str, Any], timeout_seconds: int + ) -> list[dict[str, Any]]: + """Run one provider search with native-provider safety limits.""" + + provider = str(params.get("provider") or "") + if provider in self.disabled_search_providers: + return [] + try: + if provider == "google": + if not self.google_books_ready: + raise ABSAPIError( + "Native Google Books is disabled: add and successfully " + "test an API key in ABSidekick Config first. No Google " + "request was sent." + ) + for attempt in range(2): + try: + result = search_google_books( + self.google_books_api_key, + str(params.get("title") or ""), + str(params.get("author") or ""), + limit=int(params.get("limit") or 10), + timeout_seconds=timeout_seconds, + ) + break + except ABSAPIError as error: + if google_error_is_transient(error) and attempt == 0: + time.sleep(0.5) + continue + raise + elif provider == "openlibrary": + if not self.open_library_enabled: + raise ABSAPIError( + "Native Open Library is disabled in ABSidekick Config." + ) + cache_key = ( + normalize_title(params.get("title")), + normalize_text(params.get("author")), + int(params.get("limit") or 10), + ) + cached = self.open_library_cache.get(cache_key) + if cached is not None: + return deepcopy(cached) + with self.open_library_rate_lock: + wait_seconds = self.open_library_next_request_at - time.monotonic() + if wait_seconds > 0: + time.sleep(wait_seconds) + interval = 0.35 if self.open_library_contact_email else 1.05 + self.open_library_next_request_at = time.monotonic() + interval + for attempt in range(2): + try: + result = search_open_library( + str(params.get("title") or ""), + str(params.get("author") or ""), + limit=int(params.get("limit") or 10), + timeout_seconds=timeout_seconds, + contact_email=self.open_library_contact_email, + ) + break + except ABSAPIError as error: + if open_library_error_is_transient(error) and attempt == 0: + time.sleep(0.5) + continue + raise + self.open_library_cache[cache_key] = deepcopy(result) + else: + result = self.request( + "GET", + "/api/search/books", + params=params, + timeout_seconds=timeout_seconds, + max_retries=0, + ) + except ABSAPIError as error: + is_transient = ( + provider == "google" and google_error_is_transient(error) + ) or (provider == "openlibrary" and open_library_error_is_transient(error)) + if not is_transient: + self.disabled_search_providers[provider] = str(error) + else: + failures = self.transient_search_failures.get(provider, 0) + 1 + self.transient_search_failures[provider] = failures + failure_limit = ( + GOOGLE_TRANSIENT_FAILURE_LIMIT + if provider == "google" + else OPEN_LIBRARY_TRANSIENT_FAILURE_LIMIT + ) + if failures >= failure_limit: + self.disabled_search_providers[provider] = ( + f"{error} ({failures} consecutive transient failures)" + ) + raise + self.transient_search_failures.pop(provider, None) + return result if isinstance(result, list) else [] + + def post( + self, + path: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> Any: + return self.request("POST", path, params=params, body=body) + + def patch(self, path: str, body: dict[str, Any]) -> Any: + return self.request("PATCH", path, body=body) + + +def create_client(settings: dict[str, Any], token: str | None = None) -> ABSClient: + connection = settings.get("connection", {}) + run = settings.get("run", {}) + providers = settings.get("providers", {}) + token = token or connection.get("token") or "" + return ABSClient( + base_url=connection.get("baseUrl", ""), + token=token, + timeout_seconds=int(run.get("timeoutSeconds", 30)), + max_retries=int(run.get("maxRetries", 2)), + request_delay_ms=int(run.get("requestDelayMs", 150)), + google_books_api_key=str(providers.get("googleBooksApiKey") or ""), + google_books_ready=google_books_key_is_ready(settings), + open_library_enabled=bool(providers.get("openLibraryEnabled", True)), + open_library_contact_email=str(providers.get("openLibraryContactEmail") or ""), + ) + + +def fetch_library_items( + client: ABSClient, settings: dict[str, Any] +) -> list[dict[str, Any]]: + connection = settings.get("connection", {}) + run = settings.get("run", {}) + library_id = connection.get("libraryId") + if not library_id: + raise ValueError("Select a library before running a job") + + page_size = max(1, int(run.get("pageSize", 100))) + limit = max(0, int(run.get("limit", 0))) + page = 0 + items: list[dict[str, Any]] = [] + while True: + payload = client.get( + f"/api/libraries/{urllib.parse.quote(str(library_id))}/items", + params={ + "limit": page_size, + "page": page, + "sort": run.get("sort") or "media.metadata.title", + "desc": 1 if run.get("sortDesc") else 0, + "minified": 0, + "collapseseries": 0, + }, + ) + results = payload.get("results", []) if isinstance(payload, dict) else [] + if not results: + break + for item in results: + ok, _reason = should_process_item(item, settings) + if ok: + items.append(item) + if limit and len(items) >= limit: + return items + total = int(payload.get("total", 0) or 0) + page += 1 + if page * page_size >= total: + break + return items + + +class CandidateResults(list[dict[str, Any]]): + def __init__( + self, + values: list[dict[str, Any]] | None = None, + diagnostics: list[dict[str, Any]] | None = None, + attempts: list[dict[str, Any]] | None = None, + ) -> None: + super().__init__(values or []) + self.diagnostics = diagnostics or [] + self.attempts = attempts or [] + + +def _series_prefix_details( + value: Any, + series_entries: list[tuple[str, str]] | None = None, +) -> dict[str, Any] | None: + title = html.unescape(str(value or "")).strip() + match = re.match( + r"^\s*(?P.{1,80}?)\s+" + r"(?P(?:(?:book|volume|vol)\s*#?\s*|#\s*)?)" + r"(?P\d{1,3}(?:\.\d+)?|[ivxlcdm]{1,8})\s*" + r"(?:-|–|—|–|—|:|\|)\s*(?P.+?)\s*$", + title, + flags=re.IGNORECASE, + ) + if not match: + return None + + series_name = match.group("series").strip() + sequence = _normalized_series_sequence(match.group("sequence")) + remainder = match.group("title").strip() + remainder_tokens = title_tokens(remainder) + if not remainder_tokens: + return None + + metadata_match = False + for known_name, known_sequence in series_entries or []: + normalized_known_sequence = _normalized_series_sequence(known_sequence) + name_matches = title_similarity(series_name, known_name) >= 88 + sequence_matches = ( + not normalized_known_sequence or normalized_known_sequence == sequence + ) + if name_matches and sequence_matches: + metadata_match = True + break + + series_words = { + word + for word in title_tokens(series_name) + if len(word) >= 4 and word not in {"saga", "series", "volume"} + } + repeated_series_word = bool(series_words & title_tokens(remainder)) + explicit_marker = bool(str(match.group("marker") or "").strip()) + strong = bool( + metadata_match + or repeated_series_word + or (explicit_marker and len(remainder_tokens) >= 2) + ) + if len(remainder_tokens) < 2 and not metadata_match: + strong = False + return { + "original": title, + "series": series_name, + "sequence": sequence, + "title": remainder, + "strong": strong, + "metadataMatch": metadata_match, + "repeatedSeriesWord": repeated_series_word, + "explicitMarker": explicit_marker, + } + + +def _series_label_matches(label: str, series_names: list[str]) -> bool: + normalized_label = normalize_title(label) + if not normalized_label: + return False + label_tokens = title_tokens(label) + for series_name in series_names: + normalized_series = normalize_title(series_name) + if not normalized_series: + continue + if normalized_label == normalized_series: + return True + series_tokens = title_tokens(series_name) + if label_tokens and label_tokens <= series_tokens: + return True + if len(normalized_label) >= 4 and normalized_label in series_tokens: + return True + if title_similarity(label, series_name) >= 88: + return True + return False + + +def _compact_series_code_details( + value: Any, series_names: list[str] +) -> dict[str, str] | None: + title = html.unescape(str(value or "")).strip() + match = re.match( + r"^\s*(?P<series>[a-z][a-z .&'’-]{1,50}?)\s*#?\s*" + r"0*(?P<sequence>\d{1,3}(?:\.\d+)?)\s*" + r"(?:-|–|—|–|—|:|\|)\s*(?P<title>.+?)\s*$", + title, + flags=re.IGNORECASE, + ) + if not match or not _series_label_matches(match.group("series"), series_names): + return None + remainder = match.group("title").strip() + if not title_tokens(remainder): + return None + return { + "series": match.group("series").strip(), + "sequence": _normalized_series_sequence(match.group("sequence")), + "title": remainder, + } + + +def _leading_track_title(value: Any) -> str: + title = html.unescape(str(value or "")).strip() + # Release folders frequently contain a bracketed group/source marker that + # is not part of the book title (for example, "[HH Anthology] Title"). + cleaned = re.sub(r"^\s*\[[^\]\r\n]{1,80}\]\s+", "", title).strip() + numbered_prefixes = ( + # Multi-disc/track counters: 01(3), 05-06. + r"^\s*(?:(?:book|disc|disk|track|cd)\s*)?" + r"0*\d{1,3}\s*\(\s*\d{1,3}\s*\)\s*[-_.:]?\s+", + r"^\s*(?:(?:book|disc|disk|track|cd)\s*)?" + r"0*\d{1,3}\s*[-–—]\s*0*\d{1,3}\s+", + # Series decimals and lettered entries: 01.5, 14b. + r"^\s*(?:(?:book|disc|disk|track|cd)\s*)?" + r"0*\d{1,3}(?:\.\d+)+\s*[-_.:]?\s+", + r"^\s*(?:(?:book|disc|disk|track|cd)\s*)?" + r"0*\d{1,3}[a-z]\s*[-_.:]?\s+", + # Ordinary separated track/book numbers and attached separators. + r"^\s*(?:(?:book|disc|disk|track|cd)\s*)?" + r"0*\d{1,3}\s*[-_.:]?\s+", + r"^\s*0*\d{1,3}\s*[-_.:]\s*(?=[a-z])", + ) + for pattern in numbered_prefixes: + candidate = re.sub(pattern, "", cleaned, flags=re.IGNORECASE).strip() + if candidate != cleaned: + cleaned = candidate + break + # Handle compact tags such as "01The Notebook" without damaging numeric + # titles like "1984", "11(22)63", or "1Q84". + compact = re.sub(r"^\s*0*\d{1,2}(?=[A-Z][a-z])", "", cleaned).strip() + if compact != cleaned: + cleaned = compact + # Disc suffixes narrow provider searches without identifying the work. + cleaned = re.sub( + r"\s+(?:cd|disc|disk)\s*#?\s*\d{1,3}" + r"(?:\s*(?:of|/)\s*\d{1,3})?\s*$", + "", + cleaned, + flags=re.IGNORECASE, + ).strip() + return cleaned or title + + +def title_search_variants( + value: Any, series_entries: list[tuple[str, str]] | None = None +) -> list[dict[str, Any]]: + """Return bounded, evidence-ranked provider-search variants for a title.""" + + original = html.unescape(str(value or "")).strip() + if not original: + return [] + known_series = [name for name, _sequence in series_entries or [] if name] + base = _leading_track_title(original) + track_cleaned = normalize_title(base) != normalize_title(original) + prefix = _series_prefix_details(base, series_entries) + intermediate = str(prefix.get("title") or "") if prefix else "" + if prefix and prefix.get("series"): + known_series.append(str(prefix["series"])) + compact = _compact_series_code_details(intermediate or base, known_series) + deepest = str(compact.get("title") or "") if compact else intermediate + strong = bool(track_cleaned or (prefix and prefix.get("strong")) or compact) + rows: list[dict[str, Any]] = [] + + def add(title: str, strategy: str, *, quick: bool, only_if_empty: bool) -> None: + clean = str(title or "").strip() + identity = normalize_title(clean) + if not clean or not identity: + return + if any(normalize_title(row["title"]) == identity for row in rows): + return + rows.append( + { + "title": clean, + "strategy": strategy, + "quickMatchEligible": quick, + "onlyIfEmpty": only_if_empty, + } + ) + + if strong: + if deepest and normalize_title(deepest) != normalize_title(base): + add( + deepest, + ( + "parsed nested series title + author" + if compact + else "parsed title + author" + ), + quick=True, + only_if_empty=False, + ) + if ( + intermediate + and normalize_title(intermediate) != normalize_title(deepest) + and normalize_title(intermediate) != normalize_title(base) + ): + add( + intermediate, + "intermediate parsed title + author", + quick=False, + only_if_empty=False, + ) + if track_cleaned and normalize_title(base) != normalize_title(deepest): + add( + base, + "track-number-cleaned title + author", + quick=True, + only_if_empty=False, + ) + add( + original, + "original unparsed title + author", + quick=False, + only_if_empty=bool(track_cleaned and not prefix), + ) + else: + add(original, "precise title + author", quick=True, only_if_empty=False) + if intermediate and normalize_title(intermediate) != normalize_title(original): + add( + intermediate, + "possible series-prefix title + author", + quick=False, + only_if_empty=True, + ) + return rows[:4] + + +def clean_search_title( + value: Any, series_entries: list[tuple[str, str]] | None = None +) -> str: + variants = title_search_variants(value, series_entries) + return str(variants[0]["title"]) if variants else "" + + +def evidence_title_candidates(item: dict[str, Any]) -> list[dict[str, Any]]: + """Return bounded, trustworthy titles discovered outside ABS book metadata.""" + + title = item_title(item) + author_values = item_author_values(item) + author = author_values[-1] if author_values else "" + embedded = embedded_item_metadata(item) + ignored = { + normalize_title(title), + normalize_title(embedded.get("title")), + } + rows: list[dict[str, Any]] = [] + + def add(value: Any, source: str, supporting_file_count: int = 0) -> None: + candidate = str(value or "").strip() + identity = normalize_title(candidate) + if ( + not identity + or identity in ignored + or any(normalize_title(row["title"]) == identity for row in rows) + or not _usable_evidence_title(candidate, author=author) + ): + return + rows.append( + { + "title": candidate, + "source": source, + "supportingFileCount": supporting_file_count, + } + ) + + raw_path = first_present(item.get("path"), item.get("relPath"), "") + path_title = _path_basename(raw_path) + if re.search(r"\.[a-z0-9]{2,5}$", path_title, flags=re.IGNORECASE): + path_title = _filename_title(path_title, strip_bare_counter=False) + if _title_is_generic_placeholder(title): + add(path_title, "Audiobookshelf folder name") + + for candidate in embedded.get("fileTitleCandidates") or []: + if isinstance(candidate, dict): + add( + candidate.get("title"), + str(candidate.get("source") or "audio filename"), + int(candidate.get("supportingFileCount") or 0), + ) + else: + add(candidate, "audio filename") + return rows[:3] + + +def _search_books_once( + client: ABSClient, + params: dict[str, Any], + timeout_seconds: int, +) -> tuple[list[dict[str, Any]], Exception | None]: + provider = str(params.get("provider") or "unknown") + if isinstance(client, ABSClient): + if provider in client.disabled_search_providers: + return [], None + try: + return client.search_books(params, timeout_seconds), None + except ABSAPIError as error: + return [], error + try: + results = client.get("/api/search/books", params=params) + except Exception as error: # noqa: BLE001 - provider failure is recoverable + return [], error + return ( + [candidate for candidate in results if isinstance(candidate, dict)] + if isinstance(results, list) + else [], + None, + ) + + +def _search_failure_message( + client: ABSClient, + provider: str, + *, + fallback: bool, +) -> str: + label = { + "google": "Google Books", + "openlibrary": "Open Library", + }.get(provider, provider) + operation = ( + "third-stage search" + if fallback and provider == "openlibrary" + else "second-stage search" + if fallback and provider == "google" + else "metadata search" + ) + if not isinstance(client, ABSClient): + return f"{label} {operation} failed for this item" + if provider in client.disabled_search_providers: + failures = client.transient_search_failures.get(provider, 0) + failure_limit = ( + GOOGLE_TRANSIENT_FAILURE_LIMIT + if provider == "google" + else OPEN_LIBRARY_TRANSIENT_FAILURE_LIMIT + if provider == "openlibrary" + else 0 + ) + if failure_limit and failures >= failure_limit: + return ( + f"{label} {operation} disabled for the rest of this run after " + f"{failures} consecutive transient failures" + ) + return ( + f"{label} {operation} disabled for the rest of this run because " + "the provider rejected the request or configuration" + ) + failures = client.transient_search_failures.get(provider, 0) + failure_limit = ( + GOOGLE_TRANSIENT_FAILURE_LIMIT + if provider == "google" + else OPEN_LIBRARY_TRANSIENT_FAILURE_LIMIT + if provider == "openlibrary" + else 0 + ) + if failure_limit and failures: + return ( + f"{label} {operation} failed for this item; it will retry on the " + f"next item ({failures}/{failure_limit} transient " + "failures)" + ) + return f"{label} {operation} failed for this item" + + +def search_candidates( + client: ABSClient, item: dict[str, Any], settings: dict[str, Any] +) -> CandidateResults: + connection = settings.get("connection", {}) + matching = settings.get("matching", {}) + title = item_title(item) + author = item_author(item) + embedded = embedded_item_metadata(item) + embedded_title = str(embedded.get("title") or "").strip() + embedded_author = str(embedded.get("author") or "").strip() + candidate_limit = max(1, int(matching.get("candidateLimit", 8))) + primary_provider = str(connection.get("provider") or "audible") + adaptive = bool(matching.get("adaptiveSearch", True)) + search_timeout = max( + 3, min(60, int(settings.get("run", {}).get("searchTimeoutSeconds", 12))) + ) + + series_entries = item_series_entries(item) + inferred_prefix = _series_prefix_details(title, series_entries) + variant_series_entries = list(series_entries) + if ( + inferred_prefix + and inferred_prefix.get("strong") + and inferred_prefix.get("series") + ): + variant_series_entries.append( + ( + str(inferred_prefix["series"]), + str(inferred_prefix.get("sequence") or ""), + ) + ) + current_variants = title_search_variants(title, variant_series_entries) + embedded_variants = ( + title_search_variants(embedded_title, variant_series_entries) + if embedded_title + else [] + ) + if not adaptive: + current_variants = current_variants[:1] + embedded_variants = embedded_variants[:1] + + primary_author = author or embedded_author + evidence_rows = evidence_title_candidates(item) + evidence_queries: list[tuple[str, str, str, str, bool, bool]] = [] + for evidence in evidence_rows[:2]: + evidence_variants = title_search_variants( + evidence["title"], variant_series_entries + ) + if not adaptive: + evidence_variants = evidence_variants[:1] + for variant in evidence_variants[:2]: + evidence_queries.append( + ( + primary_provider, + str(variant["title"]), + embedded_author or author, + f"{evidence['source']} / {variant['strategy']}", + False, + bool(variant["quickMatchEligible"]), + ) + ) + embedded_variant_ids = { + normalize_title(str(variant["title"])) for variant in embedded_variants + } + current_variant_ids = { + normalize_title(str(variant["title"])) for variant in current_variants + } + primary_queries: list[tuple[str, str, str, str, bool, bool]] = [] + for variant in current_variants: + variant_title = str(variant["title"]) + variant_author = ( + embedded_author + if embedded_author + and normalize_title(variant_title) in embedded_variant_ids + else primary_author + ) + primary_queries.append( + ( + primary_provider, + variant_title, + variant_author, + str(variant["strategy"]), + bool(variant["onlyIfEmpty"]), + bool(variant["quickMatchEligible"]), + ) + ) + for variant in embedded_variants: + if normalize_title(str(variant["title"])) in current_variant_ids: + # The paired embedded author was already preferred for the same + # parsed title above. Avoid repeating the title with stale ABS + # author metadata. + continue + primary_queries.append( + ( + primary_provider, + str(variant["title"]), + embedded_author or author, + f"embedded file metadata / {variant['strategy']}", + bool(variant["onlyIfEmpty"]), + bool(variant["quickMatchEligible"]), + ) + ) + if ( + embedded_author + and not embedded_title + and normalize_text(embedded_author) != normalize_text(author) + ): + primary_queries.append( + ( + primary_provider, + str(current_variants[0]["title"] if current_variants else title), + embedded_author, + "ABS title + embedded file metadata author", + False, + True, + ) + ) + + if _title_is_generic_placeholder(title) and evidence_queries: + # A placeholder such as "Star Wars Book 58" is poor identifying + # evidence. Try trustworthy folder/file titles first so an ambiguous + # generic query cannot dominate the candidate set. + primary_queries = [*evidence_queries, *primary_queries] + else: + primary_queries.extend(evidence_queries) + + broad_title = str(current_variants[0]["title"] if current_variants else title) + broad_author = str(primary_queries[0][2] if primary_queries else primary_author) + if _title_is_generic_placeholder(title) and evidence_rows: + evidence_variants = title_search_variants( + evidence_rows[0]["title"], variant_series_entries + ) + broad_title = str( + evidence_variants[0]["title"] + if evidence_variants + else evidence_rows[0]["title"] + ) + broad_author = embedded_author or author + if ( + embedded_variants + and normalize_title(broad_title) == normalize_title(title) + and normalize_title(embedded_variants[0]["title"]) != normalize_title(title) + ): + broad_title = str(embedded_variants[0]["title"]) + broad_author = embedded_author or author + if adaptive: + primary_queries.append( + (primary_provider, broad_title, "", "best parsed title only", True, False) + ) + + configured_fallbacks = matching.get("fallbackProviders") or [] + if isinstance(configured_fallbacks, str): + configured_fallbacks = [ + value.strip() for value in configured_fallbacks.split(",") if value.strip() + ] + second_pass_providers: list[str] = [] + google_ready = google_books_key_is_ready(settings) + open_library_enabled = bool( + settings.get("providers", {}).get("openLibraryEnabled", True) + ) + if google_ready and primary_provider != "google": + # Google is an automatic second pass, not an optional fallback. It runs + # immediately whenever the primary ABS provider cannot auto-match. + second_pass_providers.append("google") + if open_library_enabled and primary_provider != "openlibrary": + # Open Library is the native third stage. It runs only after both the + # primary ABS search and the optional Google second stage fail policy. + second_pass_providers.append("openlibrary") + if adaptive and bool(matching.get("automaticFallbackProviders", False)): + for configured_provider in configured_fallbacks: + provider = str(configured_provider).strip() + if ( + provider in PROVIDERS + and provider != primary_provider + and (provider != "google" or google_ready) + and (provider != "openlibrary" or open_library_enabled) + and provider not in second_pass_providers + ): + second_pass_providers.append(provider) + + candidates: dict[str, dict[str, Any]] = {} + diagnostics: list[dict[str, Any]] = [] + attempts: list[dict[str, Any]] = [] + if matching.get("useEmbeddedFileMetadata", False): + embedded_status = str(embedded.get("status") or "empty") + embedded_message = ( + f"Read {int(embedded.get('taggedFileCount') or 0)} tagged audio file(s); " + f"usable fields: {', '.join(embedded.get('fields') or []) or 'none'}" + ) + if embedded.get("error"): + embedded_message = ( + f"Could not load embedded file metadata: {embedded['error']}" + ) + diagnostics.append( + { + "provider": "embedded", + "strategy": "ABS-extracted audio-file tags", + "error": str(embedded["error"]), + "message": embedded_message, + } + ) + attempts.append( + { + "provider": "embedded", + "stage": "Embedded file metadata", + "strategy": "ABS-extracted audio-file tags", + "queryTitle": embedded_title, + "queryAuthor": embedded_author, + "resultCount": int(embedded.get("taggedFileCount") or 0), + "status": ( + "evidence" + if embedded_status == "found" + else "error" + if embedded_status == "error" + else "no_metadata" + ), + "message": embedded_message, + "metadata": embedded, + } + ) + seen_queries: set[tuple[str, str, str]] = set() + for ( + provider, + query_title, + query_author, + strategy, + only_if_empty, + quick_match_eligible, + ) in primary_queries: + if only_if_empty and candidates: + continue + query_key = ( + provider, + normalize_title(query_title), + normalize_text(query_author), + ) + if query_key in seen_queries or not query_key[1]: + continue + seen_queries.add(query_key) + params = { + "title": query_title, + "author": query_author, + "provider": provider, + "limit": candidate_limit, + } + was_disabled = ( + isinstance(client, ABSClient) + and provider in client.disabled_search_providers + ) + results, error = _search_books_once(client, params, search_timeout) + attempt = { + "provider": provider, + "stage": ( + "Open Library primary search" + if provider == "openlibrary" + else "Google Books primary search" + if provider == "google" + else "ABS primary search" + ), + "strategy": strategy, + "queryTitle": query_title, + "queryAuthor": query_author, + "resultCount": len(results), + "status": ( + "disabled" + if was_disabled + else "error" + if error + else "results" + if results + else "no_results" + ), + } + if error: + attempt["error"] = str(error) + attempt["message"] = _search_failure_message( + client, provider, fallback=False + ) + attempts.append(attempt) + diagnostics.append( + { + "provider": provider, + "strategy": strategy, + "error": str(error), + "message": _search_failure_message( + client, provider, fallback=False + ), + } + ) + continue + if was_disabled: + attempt["message"] = _search_failure_message( + client, provider, fallback=False + ) + attempts.append(attempt) + for provider_rank, candidate in enumerate(results[:candidate_limit]): + if not isinstance(candidate, dict): + continue + candidate = deepcopy(candidate) + enrich_candidate_series(candidate, item, settings) + candidate["_absidekickSearch"] = { + "provider": provider, + "strategy": strategy, + "queryTitle": query_title, + "queryAuthor": query_author, + "originalTitle": title, + "providerRank": provider_rank + 1, + "quickMatchEligible": ( + provider == primary_provider + and provider not in {"google", "openlibrary"} + and quick_match_eligible + and provider_rank == 0 + ), + } + identity = candidate_identity(candidate) + existing = candidates.get(identity) + if existing is None: + candidates[identity] = candidate + else: + existing_score = score_candidate(item, existing, settings)["score"] + candidate_score = score_candidate(item, candidate, settings)["score"] + existing_search = existing.get("_absidekickSearch") or {} + candidate_search = candidate.get("_absidekickSearch") or {} + if candidate_score > existing_score or ( + candidate_score == existing_score + and candidate_search.get("quickMatchEligible") + and not existing_search.get("quickMatchEligible") + ): + # The same provider work can appear under a vague query and + # a later evidence-backed query. Keep the provenance that + # best identifies the item so scoring and quick-match use + # the real title rather than the first placeholder search. + candidates[identity] = candidate + + if ( + candidates + and match_decision( + rank_candidates(item, list(candidates.values()), settings), settings + )["action"] + == "auto" + ): + break + + primary_ranked = rank_candidates(item, list(candidates.values()), settings) + if match_decision(primary_ranked, settings)["action"] == "auto": + return CandidateResults( + [row["candidate"] for row in primary_ranked[:candidate_limit]], + diagnostics, + attempts, + ) + + if primary_provider != "google" and not google_ready: + attempts.append( + { + "provider": "google", + "stage": "Google second pass", + "strategy": "after ABS did not auto-match", + "queryTitle": broad_title, + "queryAuthor": broad_author, + "resultCount": 0, + "status": "skipped", + "message": "Google Books skipped: add and test an API key in Providers", + } + ) + if primary_provider != "openlibrary" and not open_library_enabled: + attempts.append( + { + "provider": "openlibrary", + "stage": "Open Library third stage", + "strategy": "after ABS and Google did not auto-match", + "queryTitle": broad_title, + "queryAuthor": broad_author, + "resultCount": 0, + "status": "skipped", + "message": "Open Library skipped: enable it in Providers", + } + ) + + for provider in second_pass_providers: + query_key = ( + provider, + normalize_title(broad_title), + normalize_text(broad_author), + ) + if query_key in seen_queries or not query_key[1]: + continue + seen_queries.add(query_key) + strategy = ( + "immediate Google second pass after no confident Audiobookshelf match " + "(ABS did not auto-match)" + if provider == "google" + else "native Open Library third stage after ABS and Google did not " + "auto-match" + if provider == "openlibrary" + else "fallback title + author" + ) + params = { + "title": broad_title, + "author": broad_author, + "provider": provider, + "limit": candidate_limit, + } + was_disabled = ( + isinstance(client, ABSClient) + and provider in client.disabled_search_providers + ) + results, error = _search_books_once(client, params, search_timeout) + attempt = { + "provider": provider, + "stage": ( + "Google second pass" + if provider == "google" + else "Open Library third stage" + if provider == "openlibrary" + else "additional provider fallback" + ), + "strategy": strategy, + "queryTitle": broad_title, + "queryAuthor": broad_author, + "resultCount": len(results), + "status": ( + "disabled" + if was_disabled + else "error" + if error + else "results" + if results + else "no_results" + ), + } + if error: + attempt["error"] = str(error) + attempt["message"] = _search_failure_message( + client, provider, fallback=True + ) + attempts.append(attempt) + diagnostics.append( + { + "provider": provider, + "strategy": strategy, + "error": str(error), + "message": _search_failure_message(client, provider, fallback=True), + } + ) + continue + if was_disabled: + attempt["message"] = _search_failure_message( + client, provider, fallback=True + ) + attempts.append(attempt) + + fallback_candidates: list[dict[str, Any]] = [] + for provider_rank, candidate in enumerate(results[:candidate_limit]): + if not isinstance(candidate, dict): + continue + candidate = deepcopy(candidate) + enrich_candidate_series(candidate, item, settings) + candidate["_absidekickSearch"] = { + "provider": provider, + "strategy": strategy, + "queryTitle": broad_title, + "queryAuthor": broad_author, + "originalTitle": title, + "providerRank": provider_rank + 1, + "quickMatchEligible": False, + } + fallback_candidates.append(candidate) + + fallback_ranked = rank_candidates(item, fallback_candidates, settings) + if match_decision(fallback_ranked, settings)["action"] == "auto": + return CandidateResults( + [row["candidate"] for row in fallback_ranked[:candidate_limit]], + diagnostics, + attempts, + ) + for candidate in fallback_candidates: + identity = candidate_identity(candidate) + if identity not in candidates: + candidates[identity] = candidate + + ranked = rank_candidates(item, list(candidates.values()), settings) + return CandidateResults( + [row["candidate"] for row in ranked[:candidate_limit]], + diagnostics, + attempts, + ) + + +def candidate_identity(candidate: dict[str, Any]) -> str: + for name in ("asin", "isbn", "id", "bookId", "audibleId"): + value = normalize_identifier(candidate.get(name)) + if value: + return f"{name}:{value}" + return "|".join( + ( + normalize_title(candidate.get("title")), + normalize_text(candidate_author(candidate)), + normalize_identifier(candidate.get("publishedYear")), + normalize_identifier(candidate.get("duration")), + ) + ) + + +def search_review_candidates( + client: ABSClient, + item_id: str, + query: dict[str, Any], + settings: dict[str, Any], +) -> dict[str, Any]: + """Run a reviewer-controlled ABS metadata search for one library item.""" + + if not isinstance(query, dict): + raise ValueError("query must be an object") + title = str(query.get("title") or "").strip() + author = str(query.get("author") or "").strip() + provider = str( + query.get("provider") + or settings.get("connection", {}).get("provider") + or "audible" + ).strip() + try: + limit = int(query.get("limit") or 20) + except (TypeError, ValueError) as error: + raise ValueError("manual search limit must be a number") from error + + if not title and not author: + raise ValueError("enter a title or author to search") + if len(title) > 300 or len(author) > 200: + raise ValueError("manual search terms are too long") + if provider not in PROVIDERS: + raise ValueError(f"unknown Audiobookshelf metadata provider: {provider}") + if not 1 <= limit <= 30: + raise ValueError("manual search limit must be between 1 and 30") + + item = prepare_item_for_matching( + client, get_library_item(client, item_id), settings + ) + results, search_error = _search_books_once( + client, + { + "title": title, + "author": author, + "provider": provider, + "limit": limit, + }, + max( + 3, + min( + 60, + int(settings.get("run", {}).get("searchTimeoutSeconds", 12)), + ), + ), + ) + if search_error: + raise ValueError(f"{provider} metadata search failed: {search_error}") + scoring_item = deepcopy(item) + scoring_media = scoring_item.setdefault("media", {}) + if not isinstance(scoring_media, dict): + scoring_media = {} + scoring_item["media"] = scoring_media + scoring_metadata = scoring_media.setdefault("metadata", {}) + if not isinstance(scoring_metadata, dict): + scoring_metadata = {} + scoring_media["metadata"] = scoring_metadata + scoring_metadata["title"] = title + scoring_metadata["authorName"] = author + scoring_metadata.pop("authors", None) + normalized_results = [] + for result in results: + if not isinstance(result, dict): + continue + candidate = deepcopy(result) + enrich_candidate_series(candidate, item, settings) + normalized_results.append(candidate) + ranked = rank_candidates(scoring_item, normalized_results[:limit], settings) + candidates = [ + { + **scored, + "searchSource": "manual", + "searchProvider": provider, + } + for scored in ranked + ] + decision = match_decision(ranked, settings) + best_candidate = candidates[0] if candidates else None + if decision["action"] == "auto": + outcome_message = "This result passes the automatic matching policy." + elif candidates: + outcome_message = ( + "No result passed the automatic matching policy. Review the candidates " + "and approve one manually, or reject this item." + ) + else: + outcome_message = ( + "No metadata candidates were returned. Change the search terms or " + "provider, or reject this item." + ) + return { + "item": summarize_item(item), + "query": { + "title": title, + "author": author, + "provider": provider, + "limit": limit, + }, + "candidates": candidates, + "resultCount": len(candidates), + "decision": decision, + "manualMatch": { + "status": decision["action"], + "isConfidentMatch": decision["action"] == "auto", + "requiresReview": decision["action"] != "auto", + "message": outcome_message, + "bestCandidate": best_candidate, + "scoredAgainst": {"title": title, "author": author}, + }, + } + + +def get_library_item(client: ABSClient, item_id: str) -> dict[str, Any]: + payload = client.get(f"/api/items/{urllib.parse.quote(str(item_id))}") + if isinstance(payload, dict) and isinstance(payload.get("libraryItem"), dict): + return payload["libraryItem"] + if isinstance(payload, dict) and payload.get("id"): + return payload + raise ValueError(f"Could not load library item {item_id}") + + +def summarize_item(item: dict[str, Any]) -> dict[str, Any]: + metadata = item_metadata(item) + item_id = item.get("id") + series = ", ".join( + f"{name} #{sequence}" if sequence else name + for name, sequence in item_series_entries(item) + ) + return { + "id": item_id, + "title": item_title(item), + "author": item_author(item), + "series": series, + "narrator": first_present( + metadata.get("narratorName"), metadata.get("narrators"), "" + ), + "year": metadata.get("publishedYear"), + "asin": metadata.get("asin"), + "isbn": metadata.get("isbn"), + "publisher": metadata.get("publisher"), + "description": metadata.get("description"), + "tags": item_tags(item), + "path": first_present(item.get("path"), item.get("relPath"), ""), + "duration": item.get("media", {}).get("duration"), + "coverPath": item.get("media", {}).get("coverPath"), + "coverUrl": ( + f"/api/absidekick/item-cover/{urllib.parse.quote(str(item_id))}" + if item_id + else "" + ), + "embeddedMetadata": embedded_item_metadata(item), + } + + +def build_review_row( + item: dict[str, Any], + ranked: list[dict[str, Any]], + settings: dict[str, Any], + search_diagnostics: list[dict[str, Any]] | None = None, + search_attempts: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + review_settings = settings.get("review", {}) + candidate_limit = max(1, int(review_settings.get("candidateLimit", 6))) + return { + "item": summarize_item(item), + "candidates": ranked[:candidate_limit], + "decision": match_decision(ranked, settings), + "searchDiagnostics": search_diagnostics or [], + "searchAttempts": search_attempts or [], + "createdAt": utc_now(), + } + + +def scan_review_items( + client: ABSClient, + settings: dict[str, Any], + limit: int | None = None, + excluded_ids: set[str] | None = None, + progress: Callable[[dict[str, Any]], None] | None = None, +) -> dict[str, Any]: + scan_settings = deepcopy(settings) + scan_settings.setdefault("targeting", {}) + scan_settings.setdefault("run", {}) + scan_settings.setdefault("matching", {}) + scan_limit = int(limit or scan_settings.get("review", {}).get("scanLimit", 25)) + excluded_ids = {str(item_id) for item_id in (excluded_ids or set())} + scan_settings["targeting"]["mode"] = "review" + scan_settings["run"]["dryRun"] = True + scan_settings["run"]["limit"] = 0 + scan_settings["matching"]["candidateLimit"] = int( + scan_settings.get("review", {}).get("candidateLimit", 6) + ) + if progress: + progress( + { + "phase": "loading", + "detail": "Loading review-tagged items from Audiobookshelf…", + "current": 0, + "total": 0, + "currentTitle": "", + } + ) + items = fetch_library_items(client, scan_settings) + scan_items = [item for item in items if str(item.get("id")) not in excluded_ids][ + :scan_limit + ] + if progress: + progress( + { + "phase": "searching", + "detail": ( + f"Loaded {len(items)} review-tagged item(s); searching " + f"metadata for {len(scan_items)} pending item(s)." + ), + "current": 0, + "total": len(scan_items), + "currentTitle": "", + } + ) + rows = [] + for index, item in enumerate(scan_items, start=1): + item = prepare_item_for_matching(client, item, scan_settings) + title = item_title(item) + if progress: + progress( + { + "phase": "searching", + "detail": f"Searching providers for {title}", + "current": index - 1, + "total": len(scan_items), + "currentTitle": title, + } + ) + candidates = search_candidates(client, item, scan_settings) + ranked = rank_candidates(item, candidates, scan_settings) + rows.append( + build_review_row( + item, + ranked, + scan_settings, + candidates.diagnostics, + candidates.attempts, + ) + ) + if progress: + progress( + { + "phase": "searching", + "detail": f"Finished {title}", + "current": index, + "total": len(scan_items), + "currentTitle": title, + } + ) + primary_provider = str( + scan_settings.get("connection", {}).get("provider") or "audible" + ) + if ( + isinstance(client, ABSClient) + and primary_provider in client.disabled_search_providers + and not ranked + ): + break + return {"totalReviewItems": len(items), "rows": rows} + + +def patch_item_tags(client: ABSClient, item: dict[str, Any], tags: list[str]) -> Any: + return client.patch( + f"/api/items/{urllib.parse.quote(str(item['id']))}/media", {"tags": tags} + ) + + +def writable_review_settings(settings: dict[str, Any]) -> dict[str, Any]: + writable = deepcopy(settings) + writable.setdefault("run", {}) + writable["run"]["dryRun"] = False + return writable + + +def approve_review_candidate( + client: ABSClient, + item_id: str, + scored_candidate: dict[str, Any], + settings: dict[str, Any], +) -> dict[str, Any]: + if not scored_candidate or not isinstance(scored_candidate.get("candidate"), dict): + raise ValueError("A candidate is required to approve a review match") + item = get_library_item(client, item_id) + writable = writable_review_settings(settings) + writable.setdefault("matching", {}) + if writable["matching"].get("applyMode") == "quick_match": + writable["matching"]["applyMode"] = "metadata_patch" + return apply_match(client, item, scored_candidate, writable) + + +def reject_review_item( + client: ABSClient, item_id: str, settings: dict[str, Any] +) -> list[str]: + item = get_library_item(client, item_id) + review_settings = settings.get("review", {}) + tags_settings = settings.get("tags", {}) + add_tags: list[str] = [] + remove_tags: list[str] = [] + if review_settings.get("rejectAddsUnmatchedTag", True): + add_tags.append( + tags_settings.get("unmatchedTag", "ABSidekick: AutoMatch Unmatched") + ) + if review_settings.get("rejectClearsReviewTag", True): + remove_tags.append(tags_settings.get("reviewTag", "ABSidekick: Needs Review")) + if tags_settings.get("clearMatchedOnUnmatched", False): + remove_tags.append(tags_settings.get("matchedTag", "ABSidekick: AutoMatched")) + new_tags = add_remove_tags(item_tags(item), add=add_tags, remove=remove_tags) + patch_item_tags(client, item, new_tags) + return new_tags + + +def apply_match( + client: ABSClient, + item: dict[str, Any], + scored: dict[str, Any], + settings: dict[str, Any], +) -> dict[str, Any]: + matching = settings.get("matching", {}) + tags_settings = settings.get("tags", {}) + candidate = scored["candidate"] + apply_mode = matching.get("applyMode", "metadata_patch") + item_id = urllib.parse.quote(str(item["id"])) + + existing_tags = item_tags(item) + remove_tags: list[str] = [] + add_tags: list[str] = [] + if tags_settings.get("tagMatched", True): + add_tags.append(tags_settings.get("matchedTag", "ABSidekick: AutoMatched")) + if tags_settings.get("clearUnmatchedOnMatch", True): + remove_tags.append( + tags_settings.get("unmatchedTag", "ABSidekick: AutoMatch Unmatched") + ) + if tags_settings.get("clearReviewOnMatch", True): + remove_tags.append(tags_settings.get("reviewTag", "ABSidekick: Needs Review")) + new_tags = add_remove_tags(existing_tags, add=add_tags, remove=remove_tags) + + if settings.get("run", {}).get("dryRun", True): + return {"updated": False, "dryRun": True, "tags": new_tags} + + if apply_mode == "quick_match": + search = candidate.get("_absidekickSearch") or {} + if matching.get("quickMatchFirstResultOnly", True) and not search.get( + "quickMatchEligible" + ): + raise ABSAPIError( + "Quick match mode is limited to the first result from an exact " + "or evidence-backed parsed primary-provider search for safety" + ) + client.post( + f"/api/items/{item_id}/match", + params={ + "provider": settings.get("connection", {}).get("provider") or "google", + "title": search.get("queryTitle") or item_title(item), + "author": search.get("queryAuthor") or item_author(item), + "overrideDefaults": "true" + if matching.get("overwriteMetadata") + else "false", + }, + ) + followup_payload: dict[str, Any] = {"tags": new_tags} + if matching.get("repairSeries", True): + repaired_series = candidate_metadata_payload( + item_metadata(item), + candidate, + overwrite=False, + repair_series=True, + ).get("series") + if repaired_series: + followup_payload["metadata"] = {"series": repaired_series} + result = client.patch(f"/api/items/{item_id}/media", followup_payload) + return result or { + "updated": True, + "tags": new_tags, + } + + if apply_mode == "tags_only": + return patch_item_tags(client, item, new_tags) or { + "updated": True, + "tags": new_tags, + } + + payload: dict[str, Any] = {"tags": new_tags} + metadata = candidate_metadata_payload( + item_metadata(item), + candidate, + overwrite=bool(matching.get("overwriteMetadata", False)), + repair_series=bool(matching.get("repairSeries", True)), + ) + if metadata: + payload["metadata"] = metadata + result = client.patch(f"/api/items/{item_id}/media", payload) + + cover_mode = matching.get("coverMode", "if_missing") + has_cover = bool(item.get("media", {}).get("coverPath")) + cover_url = candidate.get("cover") + if ( + cover_url + and cover_mode in {"always", "if_missing"} + and (cover_mode == "always" or not has_cover) + ): + client.post(f"/api/items/{item_id}/cover", body={"url": cover_url}) + return result or {"updated": True, "tags": new_tags} + + +def mark_unmatched( + client: ABSClient, + item: dict[str, Any], + settings: dict[str, Any], + review: bool = False, +) -> list[str]: + tags_settings = settings.get("tags", {}) + if settings.get("run", {}).get("dryRun", True): + return item_tags(item) + add_tags: list[str] = [] + remove_tags: list[str] = [] + if review and tags_settings.get("tagReview", True): + add_tags.append(tags_settings.get("reviewTag", "ABSidekick: Needs Review")) + if not review and tags_settings.get("tagUnmatched", True): + add_tags.append( + tags_settings.get("unmatchedTag", "ABSidekick: AutoMatch Unmatched") + ) + if tags_settings.get("clearMatchedOnUnmatched", False): + remove_tags.append(tags_settings.get("matchedTag", "ABSidekick: AutoMatched")) + new_tags = add_remove_tags(item_tags(item), add=add_tags, remove=remove_tags) + patch_item_tags(client, item, new_tags) + return new_tags + + +class MatchJob: + def __init__( + self, job_id: str, client: ABSClient, settings: dict[str, Any] + ) -> None: + self.job_id = job_id + self.client = client + self.settings = deepcopy(settings) + self.thread: threading.Thread | None = None + self.cancel_event = threading.Event() + self.pause_event = threading.Event() + self.lock = threading.Lock() + self.status = "queued" + self.created_at = utc_now() + self.started_at: str | None = None + self.finished_at: str | None = None + self.stats = { + "total": 0, + "processed": 0, + "matched": 0, + "unmatched": 0, + "review": 0, + "skipped": 0, + "errors": 0, + } + self.logs: list[dict[str, Any]] = [] + self.review_items: list[dict[str, Any]] = [] + self.latest: dict[str, Any] | None = None + + def start(self) -> None: + self.thread = threading.Thread( + target=self.run, name=f"absidekick-{self.job_id}", daemon=True + ) + self.thread.start() + + def log(self, level: str, message: str, **extra: Any) -> None: + entry = {"time": utc_now(), "level": level, "message": message, **extra} + with self.lock: + self.logs.append(entry) + self.logs = self.logs[-500:] + + def snapshot(self) -> dict[str, Any]: + with self.lock: + return { + "id": self.job_id, + "status": self.status, + "createdAt": self.created_at, + "startedAt": self.started_at, + "finishedAt": self.finished_at, + "stats": deepcopy(self.stats), + "latest": deepcopy(self.latest), + "logs": deepcopy(self.logs[-500:]), + "reviewQueue": deepcopy(self.review_items[-250:]), + "settings": public_settings(self.settings, has_token=True), + } + + def remove_review_item(self, item_id: str) -> None: + with self.lock: + self.review_items = [ + row + for row in self.review_items + if str(row.get("item", {}).get("id")) != str(item_id) + ] + + def pause(self) -> None: + self.pause_event.set() + with self.lock: + if self.status == "running": + self.status = "paused" + self.log("info", "Job paused") + + def resume(self) -> None: + self.pause_event.clear() + with self.lock: + if self.status == "paused": + self.status = "running" + self.log("info", "Job resumed") + + def cancel(self) -> None: + self.cancel_event.set() + self.pause_event.clear() + self.log("warning", "Cancel requested") + + def wait_if_paused(self) -> None: + while self.pause_event.is_set() and not self.cancel_event.is_set(): + time.sleep(0.2) + + def run(self) -> None: + with self.lock: + self.status = "running" + self.started_at = utc_now() + try: + self.log("info", "Loading target items from Audiobookshelf") + items = fetch_library_items(self.client, self.settings) + with self.lock: + self.stats["total"] = len(items) + self.log("info", f"Loaded {len(items)} eligible item(s)") + + for item in items: + self.wait_if_paused() + if self.cancel_event.is_set(): + with self.lock: + self.status = "cancelled" + self.log("warning", "Job cancelled") + return + + item = prepare_item_for_matching(self.client, item, self.settings) + title = item_title(item) + author = item_author(item) + with self.lock: + self.latest = { + "id": item.get("id"), + "title": title, + "author": author, + } + if self.settings.get("matching", {}).get( + "useEmbeddedFileMetadata", False + ): + embedded = embedded_item_metadata(item) + if embedded.get("status") == "found": + self.log( + "info", + ( + "Embedded file metadata found for " + f"{title}: {', '.join(embedded.get('fields') or [])}" + ), + itemId=item.get("id"), + title=title, + embeddedMetadata=embedded, + ) + elif embedded.get("status") == "error": + self.log( + "warning", + f"Embedded file metadata unavailable for {title}", + itemId=item.get("id"), + title=title, + error=embedded.get("error"), + ) + + try: + candidates = search_candidates(self.client, item, self.settings) + for diagnostic in candidates.diagnostics: + self.log( + "warning", + diagnostic["message"], + itemId=item.get("id"), + title=title, + provider=diagnostic["provider"], + strategy=diagnostic["strategy"], + error=diagnostic["error"], + ) + ranked = rank_candidates(item, candidates, self.settings) + best = ranked[0] if ranked else None + best_score = float(best["score"]) if best else 0.0 + decision = match_decision(ranked, self.settings) + primary_provider = str( + self.settings.get("connection", {}).get("provider") or "audible" + ) + if ( + isinstance(self.client, ABSClient) + and primary_provider in self.client.disabled_search_providers + and not best + ): + with self.lock: + self.stats["errors"] += 1 + self.stats["processed"] += 1 + self.status = "failed" + self.log( + "error", + ( + f"Matching stopped: primary provider " + f"{primary_provider} is unavailable. No remaining " + "items were changed." + ), + itemId=item.get("id"), + title=title, + provider=primary_provider, + error=self.client.disabled_search_providers[ + primary_provider + ], + ) + return + + if best and decision["action"] == "auto": + apply_result = apply_match( + self.client, item, best, self.settings + ) + with self.lock: + self.stats["matched"] += 1 + self.log( + "success", + f"Matched {title}", + itemId=item.get("id"), + title=title, + author=author, + score=best_score, + candidate=best["candidate"].get("title"), + series=series_payload(best["candidate"]), + seriesSource=best["candidate"].get( + "_absidekickSeriesSource" + ), + confidence=decision["confidence"], + margin=decision["margin"], + signals=best.get("strongSignals", []), + search=best.get("search", {}), + searchAttempts=candidates.attempts, + decision=decision, + result=apply_result, + ) + elif best and decision["action"] == "review": + mark_unmatched(self.client, item, self.settings, review=True) + with self.lock: + self.stats["review"] += 1 + self.review_items.append( + build_review_row( + item, + ranked, + self.settings, + candidates.diagnostics, + candidates.attempts, + ) + ) + self.log( + "warning", + f"Needs review: {title}", + itemId=item.get("id"), + title=title, + author=author, + score=best_score, + candidate=best["candidate"].get("title"), + reasons=decision["reasons"], + margin=decision["margin"], + signals=best.get("strongSignals", []), + search=best.get("search", {}), + searchAttempts=candidates.attempts, + decision=decision, + ) + else: + mark_unmatched(self.client, item, self.settings, review=False) + with self.lock: + self.stats["unmatched"] += 1 + self.log( + "info", + f"No confident match: {title}", + itemId=item.get("id"), + title=title, + author=author, + score=best_score, + candidate=best["candidate"].get("title") if best else None, + reasons=decision["reasons"], + margin=decision["margin"], + searchAttempts=candidates.attempts, + decision=decision, + ) + with self.lock: + self.stats["processed"] += 1 + except Exception as error: # noqa: BLE001 - job runner must isolate item failures + with self.lock: + self.stats["errors"] += 1 + self.stats["processed"] += 1 + self.log( + "error", + f"Error processing {title}: {error}", + itemId=item.get("id"), + title=title, + traceback=traceback.format_exc(limit=4), + ) + if self.settings.get("run", {}).get("stopOnError", False): + raise + with self.lock: + self.status = "completed" + self.log("success", "Job completed") + except Exception as error: # noqa: BLE001 + with self.lock: + self.status = "failed" + self.stats["errors"] += 1 + self.log("error", f"Job failed: {error}", traceback=traceback.format_exc()) + finally: + with self.lock: + if self.status == "running": + self.status = "completed" + self.finished_at = utc_now() + + +def preview_matches( + client: ABSClient, settings: dict[str, Any], limit: int = 10 +) -> dict[str, Any]: + preview_settings = deepcopy(settings) + preview_settings.setdefault("run", {}) + preview_settings["run"]["dryRun"] = True + if limit: + preview_settings["run"]["limit"] = limit + items = fetch_library_items(client, preview_settings) + rows = [] + for item in items[:limit]: + item = prepare_item_for_matching(client, item, preview_settings) + candidates = search_candidates(client, item, preview_settings) + ranked = rank_candidates(item, candidates, preview_settings) + rows.append( + { + "id": item.get("id"), + "title": item_title(item), + "author": item_author(item), + "tags": item_tags(item), + "best": ranked[0] if ranked else None, + "candidateCount": len(candidates), + "decision": match_decision(ranked, preview_settings), + "searchDiagnostics": candidates.diagnostics, + "searchAttempts": candidates.attempts, + "embeddedMetadata": embedded_item_metadata(item), + } + ) + primary_provider = str( + preview_settings.get("connection", {}).get("provider") or "audible" + ) + if ( + isinstance(client, ABSClient) + and primary_provider in client.disabled_search_providers + and not ranked + ): + break + return {"totalEligible": len(items), "rows": rows} diff --git a/python/src/mlm/modules/absidekick/service.py b/python/src/mlm/modules/absidekick/service.py new file mode 100644 index 00000000..4dc56601 --- /dev/null +++ b/python/src/mlm/modules/absidekick/service.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import contextlib +import json +import re +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from copy import deepcopy +from pathlib import Path +from typing import Any + +from .core import ( + APP_VERSION, + ABSAPIError, + ABSClient, + MatchJob, + approve_review_candidate, + create_client, + deep_merge, + google_books_key_fingerprint, + google_books_key_is_ready, + google_error_is_transient, + load_settings, + preview_matches, + public_settings, + reject_review_item, + save_settings, + scan_review_items, + search_review_candidates, + test_google_books_api_key, + utc_now, +) + + +def _load_json(path: Path, fallback: dict[str, Any]) -> dict[str, Any]: + if not path.exists(): + return fallback + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return fallback + + +def _save_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2), encoding="utf-8") + temporary.replace(path) + + +class ABSidekickService: + """Owns ABSidekick state while the suite owns HTTP and process lifecycle.""" + + def __init__(self, data_dir: Path) -> None: + self.data_dir = data_dir + self.settings_path = data_dir / "settings.json" + self.review_state_path = data_dir / "review_state.json" + self.settings = load_settings(self.settings_path) + self.token = str(self.settings.get("connection", {}).get("token", "")) + self.job: MatchJob | None = None + self.review_state = _load_json(self.review_state_path, {"decisions": {}}) + self._lock = threading.RLock() + self.activity: dict[str, Any] = { + "id": "", + "kind": "", + "status": "idle", + "title": "Ready", + "detail": "No ABSidekick operation is currently running.", + "phase": "idle", + "current": 0, + "total": 0, + "currentTitle": "", + "startedAt": "", + "finishedAt": "", + } + + def merged_settings(self, incoming: dict[str, Any] | None = None) -> dict[str, Any]: + return deep_merge(self.settings, incoming or {}) + + def client( + self, + settings: dict[str, Any] | None = None, + token: str | None = None, + ) -> ABSClient: + return create_client(settings or self.settings, token=token or self.token) + + def reviewed_ids(self) -> set[str]: + decisions = self.review_state.get("decisions", {}) + return {str(item_id) for item_id in decisions} + + def filter_review_rows(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + reviewed = self.reviewed_ids() + return [ + row for row in rows if str(row.get("item", {}).get("id")) not in reviewed + ] + + def job_snapshot(self) -> dict[str, Any] | None: + with self._lock: + if not self.job: + return None + snapshot = self.job.snapshot() + snapshot["reviewQueue"] = self.filter_review_rows( + snapshot.get("reviewQueue", []) + ) + return snapshot + + def public_state(self) -> dict[str, Any]: + with self._lock: + return { + "ok": True, + "version": APP_VERSION, + "settings": public_settings(self.settings, bool(self.token)), + "job": self.job_snapshot(), + "activity": dict(self.activity), + } + + def activity_snapshot(self) -> dict[str, Any]: + with self._lock: + return {"ok": True, "activity": dict(self.activity)} + + def _begin_activity(self, kind: str, title: str, detail: str) -> str: + activity_id = str(time.time_ns()) + with self._lock: + self.activity = { + "id": activity_id, + "kind": kind, + "status": "running", + "title": title, + "detail": detail, + "phase": "starting", + "current": 0, + "total": 0, + "currentTitle": "", + "startedAt": utc_now(), + "finishedAt": "", + } + return activity_id + + def _update_activity(self, activity_id: str, update: dict[str, Any]) -> None: + with self._lock: + if self.activity.get("id") != activity_id: + return + for key in ("phase", "detail", "current", "total", "currentTitle"): + if key in update: + self.activity[key] = update[key] + + def _finish_activity(self, activity_id: str, status: str, detail: str) -> None: + with self._lock: + if self.activity.get("id") != activity_id: + return + self.activity.update( + { + "status": status, + "detail": detail, + "phase": "finished", + "finishedAt": utc_now(), + } + ) + + def _incoming(self, payload: dict[str, Any]) -> tuple[dict[str, Any], str]: + incoming = payload.get("settings") or {} + if not isinstance(incoming, dict): + raise ValueError("settings must be an object") + incoming = json.loads(json.dumps(incoming)) + incoming.pop("providers", None) + settings = self.merged_settings(incoming) + incoming_token = incoming.get("connection", {}).get("token", "") + token = str(payload.get("token") or incoming_token or self.token) + return settings, token + + def _apply_google_key_input( + self, settings: dict[str, Any], payload: dict[str, Any] + ) -> None: + if "googleBooksApiKey" not in payload: + return + api_key = str(payload.get("googleBooksApiKey") or "").strip() + if len(api_key) > 500: + raise ValueError("Google Books API key is too long") + providers = settings.setdefault("providers", {}) + current = str(providers.get("googleBooksApiKey") or "") + if api_key == current: + return + providers.update( + { + "googleBooksApiKey": api_key, + "googleBooksApiKeyValidated": False, + "googleBooksApiKeyFingerprint": "", + "googleBooksApiKeyValidatedAt": "", + "googleBooksLastError": ( + "API key has not been tested." if api_key else "" + ), + } + ) + + def _apply_open_library_input( + self, settings: dict[str, Any], payload: dict[str, Any] + ) -> None: + providers_payload = ( + payload.get("providers") + if isinstance(payload.get("providers"), dict) + else {} + ) + incoming_settings = ( + payload.get("settings") if isinstance(payload.get("settings"), dict) else {} + ) + incoming_providers = ( + incoming_settings.get("providers") + if isinstance(incoming_settings.get("providers"), dict) + else {} + ) + has_enabled = ( + "openLibraryEnabled" in payload + or "openLibraryEnabled" in providers_payload + or "openLibraryEnabled" in incoming_providers + ) + has_contact = ( + "openLibraryContactEmail" in payload + or "openLibraryContactEmail" in providers_payload + or "openLibraryContactEmail" in incoming_providers + ) + if not has_enabled and not has_contact: + return + + providers = settings.setdefault("providers", {}) + enabled_val = ( + payload.get("openLibraryEnabled") + if "openLibraryEnabled" in payload + else providers_payload.get("openLibraryEnabled") + if "openLibraryEnabled" in providers_payload + else incoming_providers.get( + "openLibraryEnabled", providers.get("openLibraryEnabled", True) + ) + ) + contact_val = ( + payload.get("openLibraryContactEmail") + if "openLibraryContactEmail" in payload + else providers_payload.get("openLibraryContactEmail") + if "openLibraryContactEmail" in providers_payload + else incoming_providers.get( + "openLibraryContactEmail", + providers.get("openLibraryContactEmail", ""), + ) + ) + enabled = bool(enabled_val) + contact_email = str(contact_val or "").strip() + if len(contact_email) > 254: + raise ValueError("Open Library contact email is too long") + if contact_email and not re.fullmatch( + r"[^\s@]+@[^\s@]+\.[^\s@]+", contact_email + ): + raise ValueError("Enter a valid Open Library contact email") + providers.update( + { + "openLibraryEnabled": enabled, + "openLibraryContactEmail": contact_email, + } + ) + + def _store(self, settings: dict[str, Any], token: str) -> None: + with self._lock: + self.settings = settings + if token: + self.token = token + save_settings(self.settings_path, self.settings, token=self.token) + + def connect(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + self._apply_google_key_input(settings, payload) + self._apply_open_library_input(settings, payload) + libraries = self.client(settings, token).get("/api/libraries") + self._store(settings, token) + return { + "ok": True, + "message": "Connected to Audiobookshelf", + "libraries": libraries, + "settings": public_settings(self.settings, bool(self.token)), + } + + def save(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + self._apply_google_key_input(settings, payload) + self._apply_open_library_input(settings, payload) + self._store(settings, token) + return { + "ok": True, + "settings": public_settings(self.settings, bool(self.token)), + } + + def test_google_books(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + self._apply_google_key_input(settings, payload) + providers = settings.setdefault("providers", {}) + api_key = str(providers.get("googleBooksApiKey") or "").strip() + if not api_key: + raise ValueError("Enter a Google Books API key before testing it") + was_ready = google_books_key_is_ready(settings) + try: + result = test_google_books_api_key( + api_key, + timeout_seconds=int( + settings.get("run", {}).get("searchTimeoutSeconds", 12) + ), + ) + except ABSAPIError as error: + providers["googleBooksLastError"] = str(error) + if not (was_ready and google_error_is_transient(error)): + providers.update( + { + "googleBooksApiKeyValidated": False, + "googleBooksApiKeyFingerprint": "", + "googleBooksApiKeyValidatedAt": "", + } + ) + self._store(settings, token) + raise + providers.update( + { + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint(api_key), + "googleBooksApiKeyValidatedAt": utc_now(), + "googleBooksLastError": "", + } + ) + self._store(settings, token) + return { + "ok": True, + **result, + "settings": public_settings(self.settings, bool(self.token)), + } + + def clear_google_books(self) -> dict[str, Any]: + settings = self.merged_settings() + settings.setdefault("providers", {}).update( + { + "googleBooksApiKey": "", + "googleBooksApiKeyValidated": False, + "googleBooksApiKeyFingerprint": "", + "googleBooksApiKeyValidatedAt": "", + "googleBooksLastError": "", + } + ) + self._store(settings, self.token) + return { + "ok": True, + "message": "Google Books API key removed; Google searches are disabled.", + "settings": public_settings(self.settings, bool(self.token)), + } + + def libraries(self) -> dict[str, Any]: + return {"ok": True, "libraries": self.client().get("/api/libraries")} + + def filter_data(self, library_id: str) -> dict[str, Any]: + library_id = library_id or str( + self.settings.get("connection", {}).get("libraryId", "") + ) + if not library_id: + raise ValueError("libraryId is required") + payload = self.client().get( + f"/api/libraries/{urllib.parse.quote(library_id)}/filterdata" + ) + return {"ok": True, "filterData": payload} + + def preview(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + limit = int(payload.get("limit") or 10) + preview = preview_matches(self.client(settings, token), settings, limit=limit) + return {"ok": True, "preview": preview} + + def scan_review(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + limit = int( + payload.get("limit") or settings.get("review", {}).get("scanLimit", 25) + ) + activity_id = self._begin_activity( + "review-scan", + "Scanning Review Tags", + "Connecting to Audiobookshelf and loading review-tagged items…", + ) + try: + rows = scan_review_items( + self.client(settings, token), + settings, + limit=limit, + excluded_ids=self.reviewed_ids(), + progress=lambda update: self._update_activity(activity_id, update), + ) + except Exception as error: + self._finish_activity( + activity_id, + "error", + f"Review scan failed: {error}", + ) + raise + self._finish_activity( + activity_id, + "success", + f"Review scan finished: loaded {len(rows['rows'])} item(s).", + ) + return { + "ok": True, + "review": rows, + "jobReviewQueue": (self.job_snapshot() or {}).get("reviewQueue", []), + } + + def search_review(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + item_id = str(payload.get("itemId") or "").strip() + if not item_id: + raise ValueError("itemId is required") + result = search_review_candidates( + self.client(settings, token), + item_id, + payload.get("query") or {}, + settings, + ) + return {"ok": True, **result} + + def _mark_review_decision( + self, + item_id: str, + action: str, + row: dict[str, Any] | None = None, + ) -> None: + with self._lock: + decisions = self.review_state.setdefault("decisions", {}) + decisions[str(item_id)] = { + "action": action, + "time": utc_now(), + "item": (row or {}).get("item", {}), + } + if self.job: + self.job.remove_review_item(str(item_id)) + _save_json(self.review_state_path, self.review_state) + + def approve_review(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + item_id = str(payload.get("itemId") or "") + if not item_id: + raise ValueError("itemId is required") + result = approve_review_candidate( + self.client(settings, token), + item_id, + payload.get("candidate"), + settings, + ) + self._mark_review_decision(item_id, "approved", payload.get("row")) + return { + "ok": True, + "message": "Review match approved", + "result": result, + } + + def reject_review(self, payload: dict[str, Any]) -> dict[str, Any]: + settings, token = self._incoming(payload) + item_id = str(payload.get("itemId") or "") + if not item_id: + raise ValueError("itemId is required") + tags = reject_review_item(self.client(settings, token), item_id, settings) + self._mark_review_decision(item_id, "rejected", payload.get("row")) + return { + "ok": True, + "message": "Review match rejected", + "tags": tags, + } + + def start(self, payload: dict[str, Any]) -> dict[str, Any]: + with self._lock: + if self.job and self.job.status in {"queued", "running", "paused"}: + raise RuntimeError("A job is already running") + settings, token = self._incoming(payload) + job = MatchJob( + str(int(time.time() * 1000)), + self.client(settings, token), + settings, + ) + self._store(settings, token) + with self._lock: + self.job = job + job.start() + return {"ok": True, "job": self.job_snapshot()} + + def job_action(self, action: str) -> dict[str, Any]: + with self._lock: + if self.job: + if action == "pause": + self.job.pause() + elif action == "resume": + self.job.resume() + elif action == "cancel": + self.job.cancel() + else: + raise ValueError(f"unknown job action: {action}") + return {"ok": True, "job": self.job_snapshot()} + + def cover(self, item_id: str) -> tuple[bytes, str]: + if not item_id: + raise ValueError("item id is required") + client = self.client() + request = urllib.request.Request( + client.url(f"/api/items/{urllib.parse.quote(item_id)}/cover"), + headers={ + "Authorization": f"Bearer {client.token}", + "Accept": "image/*,*/*", + }, + method="GET", + ) + try: + with urllib.request.urlopen( + request, timeout=client.timeout_seconds + ) as response: + return ( + response.read(), + response.headers.get("Content-Type", "image/jpeg"), + ) + except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError) as error: + raise LookupError("Audiobookshelf cover is unavailable") from error + + def auto_sync_library(self) -> dict[str, Any]: + """Trigger an Audiobookshelf library scan and start an auto-match job.""" + with self._lock: + if self.job and self.job.status in {"queued", "running", "paused"}: + return { + "ok": False, + "message": "An ABSidekick matching job is already running", + } + library_id = str( + self.settings.get("connection", {}).get("libraryId", "") + ).strip() + if not library_id: + return { + "ok": False, + "message": "No library selected in ABSidekick configuration", + } + client = self.client() + with contextlib.suppress(Exception): + client.post(f"/api/libraries/{urllib.parse.quote(library_id)}/scan") + + job_settings = deepcopy(self.settings) + job_settings.setdefault("targeting", {})["mode"] = "unprocessed" + job = MatchJob( + str(int(time.time() * 1000)), + client, + job_settings, + ) + with self._lock: + self.job = job + job.start() + return {"ok": True, "job": self.job_snapshot()} diff --git a/python/src/mlm/modules/heavymlm/__init__.py b/python/src/mlm/modules/heavymlm/__init__.py new file mode 100644 index 00000000..09c197d0 --- /dev/null +++ b/python/src/mlm/modules/heavymlm/__init__.py @@ -0,0 +1 @@ +"""HeavyMLM-specific web presentation and application boundaries.""" diff --git a/python/src/mlm/modules/heavymlm/abs_sync.py b/python/src/mlm/modules/heavymlm/abs_sync.py new file mode 100644 index 00000000..5459a2ad --- /dev/null +++ b/python/src/mlm/modules/heavymlm/abs_sync.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import urllib.parse +from typing import Any + +from ...repository import Repository +from ..absidekick.core import ( + create_client, + first_present, + item_metadata, + item_series_entries, + item_title, + split_people, +) + + +def fetch_all_abs_library_books( + settings: dict[str, Any], + token: str | None = None, +) -> list[dict[str, Any]]: + """Fetch all audiobooks from an Audiobookshelf library without filtering.""" + connection = settings.get("connection", {}) + library_id = connection.get("libraryId") + if not library_id: + raise ValueError( + "Audiobookshelf library ID is not configured. " + "Please select a library in ABSidekick settings." + ) + + base_url = connection.get("baseUrl") + auth_token = token or connection.get("token") or "" + if not base_url or not auth_token: + raise ValueError( + "Audiobookshelf URL or API token is missing. " + "Please configure your ABS connection in ABSidekick settings." + ) + + client = create_client(settings, token=auth_token) + page_size = 100 + page = 0 + normalized_books: list[dict[str, Any]] = [] + + while True: + payload = client.get( + f"/api/libraries/{urllib.parse.quote(str(library_id))}/items", + params={ + "limit": page_size, + "page": page, + "minified": 0, + "collapseseries": 0, + }, + ) + results = payload.get("results", []) if isinstance(payload, dict) else [] + if not results: + break + + for item in results: + metadata = item_metadata(item) + title = item_title(item) + if not title: + continue + + authors = split_people( + first_present(metadata.get("authors"), metadata.get("authorName"), "") + ) + + raw_series = item_series_entries(item) + series_list = [ + {"name": s_name, "entries": [s_seq] if s_seq else []} + for s_name, s_seq in raw_series + if s_name + ] + + library_path = str( + first_present(item.get("path"), item.get("relPath"), "") + ).strip() + + asin = str(metadata.get("asin") or "").strip() + isbn = str(metadata.get("isbn") or "").strip() + + normalized_books.append( + { + "id": str(item.get("id")), + "abs_id": str(item.get("id")), + "title": title, + "authors": authors, + "series": series_list, + "library_path": library_path, + "asin": asin, + "isbn": isbn, + "media_type": "audiobook", + "meta": { + "title": title, + "authors": authors, + "series": series_list, + "media_type": "audiobook", + "asin": asin, + "isbn": isbn, + }, + "source": "audiobookshelf", + } + ) + + total = int(payload.get("total", 0) or 0) + page += 1 + if page * page_size >= total: + break + + return normalized_books + + +def sync_audiobookshelf_library( + repository: Repository, + settings: dict[str, Any], + token: str | None = None, +) -> dict[str, Any]: + """Synchronize Audiobookshelf library books into the HeavyMLM repository.""" + books = fetch_all_abs_library_books(settings, token=token) + count = repository.upsert_abs_books(books) + + repository.log_activity( + "absidekick", + f"Synced {count} audiobook(s) from Audiobookshelf library into catalog", + level="success", + context={"count": count}, + ) + + return { + "ok": True, + "total_synced": count, + "books": books, + } diff --git a/python/src/mlm/modules/heavymlm/search.py b/python/src/mlm/modules/heavymlm/search.py new file mode 100644 index 00000000..fbb880aa --- /dev/null +++ b/python/src/mlm/modules/heavymlm/search.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import html +from typing import Any + +from ...search import ( + LANGUAGE_BY_ID, + as_bool, + as_int, + decode_name_mapping, + decode_series_info, + parse_size, +) + + +def _format_bytes(value: Any) -> str: + try: + size = max(0, parse_size(value)) + except ValueError: + return str(value).strip() or "Unknown" + amount = float(size) + units = ("B", "KiB", "MiB", "GiB", "TiB") + unit = units[0] + for candidate in units: + unit = candidate + if amount < 1024 or candidate == units[-1]: + break + amount /= 1024 + precision = 0 if amount.is_integer() else 1 + return f"{amount:.{precision}f} {unit}" + + +def _availability(row: dict[str, Any]) -> list[dict[str, str]]: + badges = [] + if as_bool(row.get("free")): + badges.append({"label": "Global freeleech", "tone": "free"}) + if as_bool(row.get("personal_freeleech")): + badges.append({"label": "Personal freeleech", "tone": "free"}) + if as_bool(row.get("vip")) or as_bool(row.get("fl_vip")): + badges.append({"label": "VIP freeleech", "tone": "vip"}) + if not badges: + badges.append({"label": "Ratio download", "tone": "ratio"}) + return badges + + +def _availability_keys(row: dict[str, Any]) -> list[str]: + keys = [] + if as_bool(row.get("free")): + keys.append("global") + if as_bool(row.get("personal_freeleech")): + keys.append("personal") + if as_bool(row.get("vip")) or as_bool(row.get("fl_vip")): + keys.append("vip") + if keys: + keys.append("freeleech") + else: + keys.append("ratio") + return keys + + +def _contains(values: list[str], wanted: str) -> bool: + needle = html.unescape(wanted).strip().casefold() + return not needle or any(needle in value.casefold() for value in values) + + +def search_seed(filters: dict[str, Any]) -> tuple[str, list[str]]: + """Pick one broad MaM query; remaining fields are ANDed locally.""" + candidates = ( + ("q", []), + ("author", ["author"]), + ("series", ["series"]), + ("title", ["title"]), + ("narrator", ["narrator"]), + ) + for name, fields in candidates: + value = str(filters.get(name, "")).strip() + if value: + return value, fields + return "", [] + + +def filter_search_results( + rows: list[dict[str, Any]], filters: dict[str, Any] +) -> list[dict[str, Any]]: + """Apply independent manual-search dimensions with AND semantics.""" + matched = [] + for row in rows: + searchable = [ + row["title"], + *row["authors"], + *row["narrators"], + *(series["name"] for series in row["series"]), + *row["filetypes"], + row["category"], + row["language"], + row["uploader"], + str(row["id"]), + ] + if not _contains(searchable, str(filters.get("q", ""))): + continue + if not _contains([row["title"]], str(filters.get("title", ""))): + continue + if not _contains(row["authors"], str(filters.get("author", ""))): + continue + if not _contains( + [series["name"] for series in row["series"]], + str(filters.get("series", "")), + ): + continue + if not _contains(row["narrators"], str(filters.get("narrator", ""))): + continue + filetype = str(filters.get("filetype", "")).strip().casefold() + if filetype and filetype not in row["filetypes"]: + continue + category = str(filters.get("category", "")).strip().casefold() + if category and category not in row["category"].casefold(): + continue + language = str(filters.get("language", "")).strip().casefold() + if language and language != row["language"].casefold(): + continue + availability = str(filters.get("availability", "")).strip().casefold() + if availability and availability not in row["availability_keys"]: + continue + min_seeders = as_int(filters.get("min_seeders")) + if min_seeders and row["seeders"] < min_seeders: + continue + matched.append(row) + + sort_by = str(filters.get("sort", "relevance")) + sorters = { + "title": lambda row: row["title"].casefold(), + "author": lambda row: row["authors"][0].casefold() if row["authors"] else "", + "series": lambda row: ( + row["series"][0]["name"].casefold() if row["series"] else "" + ), + "filetype": lambda row: row["filetypes"][0] if row["filetypes"] else "", + "size_asc": lambda row: row["size_bytes"], + } + if sort_by in sorters: + matched.sort(key=sorters[sort_by]) + elif sort_by == "size_desc": + matched.sort(key=lambda row: row["size_bytes"], reverse=True) + elif sort_by == "seeders": + matched.sort(key=lambda row: row["seeders"], reverse=True) + elif sort_by == "newest": + matched.sort(key=lambda row: row["uploaded_at"], reverse=True) + return matched + + +def present_search_result(row: dict[str, Any]) -> dict[str, Any]: + """Create a stable HeavyMLM search-card model from a MaM API row.""" + series = decode_series_info(row.get("series_info")) + language = str(row.get("lang_code", "")).strip() + if not language: + language = LANGUAGE_BY_ID.get(as_int(row.get("language")), "").title() + filetypes = [ + value.strip().lower() + for value in str(row.get("filetype", "")).replace(",", " ").split() + if value.strip() + ] + try: + size_bytes = max(0, parse_size(row.get("size", 0))) + except ValueError: + size_bytes = 0 + return { + "id": as_int(row.get("id")), + "title": html.unescape(str(row.get("title", ""))).strip() or "Untitled release", + "authors": decode_name_mapping(row.get("author_info")), + "narrators": decode_name_mapping(row.get("narrator_info")), + "series": series, + "availability": _availability(row), + "availability_keys": _availability_keys(row), + "filetypes": filetypes, + "size": _format_bytes(row.get("size", 0)), + "size_bytes": size_bytes, + "category": str( + row.get("catname") or row.get("category_name") or "Uncategorized" + ).strip(), + "language": language or "Unknown", + "num_files": as_int(row.get("numfiles")), + "seeders": as_int(row.get("seeders")), + "leechers": as_int(row.get("leechers")), + "snatched": as_int(row.get("times_completed")), + "uploaded_at": str(row.get("added", "")).strip(), + "uploader": str(row.get("owner_name", "")).strip(), + } diff --git a/python/src/mlm/modules/heavymlm/series.py b/python/src/mlm/modules/heavymlm/series.py new file mode 100644 index 00000000..a4779113 --- /dev/null +++ b/python/src/mlm/modules/heavymlm/series.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import re +from collections import defaultdict +from typing import Any + +from ...autograbber import _preference, _preferred_types +from ...config import Config +from ...mam import MamClient +from ...repository import Repository +from ...search import ( + as_bool, + as_int, + metadata_matches, + normalize_title, + torrent_meta, +) +from .search import present_search_result + +ENTRY_NUMBER_PATTERN = re.compile(r"^#?\s*([\d.]+)", re.IGNORECASE) +TITLE_SERIES_PATTERN = re.compile(r"(?:book|vol(?:ume)?|#)\s*([\d.]+)", re.IGNORECASE) + + +def parse_entry_number(value: str) -> tuple[int, float, str]: + """Return a sortable tuple for series volume numbers (e.g. '1', '2.5', 'Book 1').""" + cleaned = value.strip().casefold() + match = ENTRY_NUMBER_PATTERN.search(cleaned) or TITLE_SERIES_PATTERN.search(cleaned) + if match: + try: + return (0, float(match.group(1)), cleaned) + except ValueError: + pass + return (1, 999999.0, cleaned) + + +def extract_series_volume(row: dict[str, Any], series_name: str) -> str: + """Find the specific volume/entry string for a series in a release.""" + normalized_target = series_name.strip().casefold() + for series in row.get("series", []): + name = str(series.get("name", "")).strip().casefold() + if name == normalized_target or normalized_target in name: + entries = series.get("entries", []) + if entries and str(entries[0]).strip(): + return str(entries[0]).strip() + + title = str(row.get("title", "")) + match = TITLE_SERIES_PATTERN.search(title) + if match: + return match.group(1).strip() + return normalize_title(title) + + +def score_series_candidate( + row: dict[str, Any], + config: Config, + preferred_media: str | None = None, + preferred_filetype: str | None = None, +) -> tuple[int, int, int, int]: + """Score a release candidate to select the best release for a given volume.""" + meta = row if "media_type" in row else torrent_meta(row) + media_type = meta.get("media_type", "") + filetypes = meta.get("filetypes", []) + + media_score = 0 + if preferred_media: + media_score = 1 if media_type == preferred_media else 0 + + preferred_formats = _preferred_types(config, media_type) + format_pref = _preference(filetypes, preferred_formats) + format_score = ( + 100 - format_pref + if format_pref is not None + else (50 if preferred_filetype and preferred_filetype in filetypes else 0) + ) + + cost_score = 0 + if as_bool(row.get("free")) or as_bool(row.get("personal_freeleech")): + cost_score = 3 + elif as_bool(row.get("vip")) or as_bool(row.get("fl_vip")): + cost_score = 2 + else: + cost_score = 1 + + seeder_score = min(as_int(row.get("seeders", 0)), 1000) + return (media_score, format_score, cost_score, seeder_score) + + +async def fetch_series_releases( + mam: MamClient, + series_name: str, + *, + max_pages: int = 5, +) -> list[dict[str, Any]]: + """Search MyAnonamouse for all torrents belonging to a series.""" + cleaned_name = series_name.strip() + if not cleaned_name: + return [] + + releases: list[dict[str, Any]] = [] + seen_ids: set[int] = set() + start = 0 + + for _ in range(max_pages): + query = { + "dlLink": True, + "mediaInfo": True, + "isbn": True, + "perpage": 100, + "tor": { + "text": cleaned_name, + "srchIn": ["series"], + "startNumber": start, + }, + } + result = await mam.search(query) + rows = result.get("data", []) + if not rows: + break + before_count = len(seen_ids) + for row in rows: + if not isinstance(row, dict): + continue + torrent_id = as_int(row.get("id")) + if torrent_id and torrent_id not in seen_ids: + seen_ids.add(torrent_id) + releases.append(row) + start += len(rows) + found = as_int(result.get("found"), len(rows)) + if len(seen_ids) == before_count or start >= found: + break + + # If searching in series returned zero matches, try a broader query fallback + if not releases: + for _ in range(2): + query = { + "dlLink": True, + "mediaInfo": True, + "isbn": True, + "perpage": 100, + "tor": { + "text": cleaned_name, + "startNumber": start, + }, + } + result = await mam.search(query) + rows = result.get("data", []) + if not rows: + break + for row in rows: + if not isinstance(row, dict): + continue + torrent_id = as_int(row.get("id")) + if torrent_id and torrent_id not in seen_ids: + seen_ids.add(torrent_id) + releases.append(row) + start += len(rows) + found = as_int(result.get("found"), len(rows)) + if start >= found: + break + + return releases + + +def group_series_books( + releases: list[dict[str, Any]], + series_name: str, + config: Config, + *, + preferred_media: str | None = None, + preferred_filetype: str | None = None, +) -> list[dict[str, Any]]: + """Group releases by series entry/book, picking the best release for each volume.""" + target_normalized = series_name.strip().casefold() + groups: dict[str, list[dict[str, Any]]] = defaultdict(list) + + for row in releases: + meta = torrent_meta(row) + matches_series = any( + target_normalized in str(s.get("name", "")).strip().casefold() + for s in meta.get("series", []) + ) or target_normalized in normalize_title(meta["title"]) + + if not matches_series: + continue + + volume = extract_series_volume(meta, series_name) + groups[volume].append(row) + + books: list[dict[str, Any]] = [] + for volume, candidates in groups.items(): + best_row = max( + candidates, + key=lambda row: score_series_candidate( + row, + config, + preferred_media=preferred_media, + preferred_filetype=preferred_filetype, + ), + ) + presented = present_search_result(best_row) + meta = torrent_meta(best_row) + books.append( + { + "volume": volume, + "sort_key": parse_entry_number(volume), + "mam_id": presented["id"], + "title": presented["title"], + "authors": presented["authors"], + "release": presented, + "raw_row": best_row, + "meta": meta, + "candidates_count": len(candidates), + } + ) + + books.sort(key=lambda item: item["sort_key"]) + return books + + +async def resolve_series_selection( + config: Config, + repository: Repository, + mam: MamClient, + series_name: str, + *, + preferred_media: str | None = None, + preferred_filetype: str | None = None, +) -> dict[str, Any]: + """Resolve all distinct books for a series, partitioning missing vs present.""" + releases = await fetch_series_releases(mam, series_name) + grouped_books = group_series_books( + releases, + series_name, + config, + preferred_media=preferred_media, + preferred_filetype=preferred_filetype, + ) + + missing_books: list[dict[str, Any]] = [] + already_present: list[dict[str, Any]] = [] + + for book in grouped_books: + torrent_id = book["mam_id"] + meta = book["meta"] + title_search = normalize_title(meta["title"]) + + is_present = repository.has_mam_id(torrent_id) + if not is_present: + for existing in repository.records_with_title(title_search): + if metadata_matches(meta, existing.get("meta", {})): + is_present = True + break + + if is_present: + book["status"] = "already_present" + already_present.append(book) + else: + book["status"] = "missing" + missing_books.append(book) + + return { + "series_name": series_name.strip(), + "total_books": len(grouped_books), + "missing_books": missing_books, + "already_present": already_present, + "all_books": grouped_books, + } + + +def detect_series_gaps(volumes: list[str]) -> list[str]: + """Detect missing volume numbers in a collection of volume entries.""" + parsed_nums: list[float] = [] + for vol in volumes: + sort_info = parse_entry_number(vol) + if sort_info[0] == 0: + parsed_nums.append(sort_info[1]) + + if not parsed_nums or len(parsed_nums) < 1: + return [] + + int_nums = {int(n) for n in parsed_nums if n.is_integer()} + if not int_nums: + return [] + + min_vol = min(int_nums) + max_vol = max(int_nums) + start_check = 1 if (1 < min_vol <= 3) else min_vol + + gaps: list[str] = [] + for expected in range(start_check, max_vol + 1): + if expected not in int_nums: + gaps.append(f"#{expected}") + if len(gaps) >= 10: + break + return gaps + + +def detect_library_series(repository: Repository) -> list[dict[str, Any]]: + """Scan library and queued records to discover series and check missing books.""" + processed_torrents = repository.table_rows("torrents", limit=20000, offset=0) + selected_torrents = repository.table_rows( + "selected_torrents", limit=10000, offset=0 + ) + abs_books = repository.abs_book_rows(limit=50000, offset=0) + pending_ids = {as_int(t.get("mam_id")) for t in repository.pending_selected()} + + grouped_series: dict[str, dict[str, Any]] = defaultdict( + lambda: {"name": "", "authors": set(), "books": [], "volumes": []} + ) + + all_records = [*processed_torrents, *selected_torrents, *abs_books] + seen_ids: set[int] = set() + seen_abs_ids: set[str] = set() + + for record in all_records: + mam_id = as_int(record.get("mam_id")) + abs_id = str(record.get("abs_id") or record.get("id") or "") + if mam_id and mam_id in seen_ids: + continue + if mam_id: + seen_ids.add(mam_id) + if record.get("source") == "audiobookshelf" and abs_id: + if abs_id in seen_abs_ids: + continue + seen_abs_ids.add(abs_id) + + meta = record.get("meta") or ( + record if "series" in record else torrent_meta(record) + ) + series_list = meta.get("series") or record.get("series", []) + title = meta.get("title") or record.get("title") or "Untitled" + authors = meta.get("authors") or record.get("authors", []) + + if not series_list: + match = TITLE_SERIES_PATTERN.search(title) + if match: + prefix = title[: match.start()].strip(" -:,") + if prefix: + series_list = [ + {"name": prefix, "entries": [match.group(1).strip()]} + ] + + for s in series_list: + s_name = str(s.get("name", "")).strip() + if not s_name: + continue + key = s_name.casefold() + group = grouped_series[key] + if not group["name"]: + group["name"] = s_name + for author in authors: + if author: + group["authors"].add(author) + + entries = s.get("entries", []) + volume = ( + str(entries[0]).strip() + if entries + else extract_series_volume(meta, s_name) + ) + group["volumes"].append(volume) + group["books"].append( + { + "title": title, + "volume": volume, + "sort_key": parse_entry_number(volume), + "mam_id": mam_id, + "media_type": meta.get("media_type", "audiobook"), + "filetypes": meta.get("filetypes", []), + "pending": mam_id in pending_ids if mam_id else False, + } + ) + + results: list[dict[str, Any]] = [] + for group in grouped_series.values(): + if not group["books"]: + continue + group["books"].sort(key=lambda b: b["sort_key"]) + gaps = detect_series_gaps(group["volumes"]) + authors_list = sorted(group["authors"]) + results.append( + { + "series_name": group["name"], + "authors": authors_list, + "volumes_count": len(group["books"]), + "volumes": [b["volume"] for b in group["books"]], + "gaps": gaps, + "books": group["books"], + } + ) + + results.sort(key=lambda s: s["series_name"].casefold()) + return results diff --git a/python/src/mlm/modules/mam_spender/README.md b/python/src/mlm/modules/mam_spender/README.md new file mode 100644 index 00000000..72fec097 --- /dev/null +++ b/python/src/mlm/modules/mam_spender/README.md @@ -0,0 +1,34 @@ +# MAM-Spender module + +This directory owns MAM-Spender behavior. It should not import HeavyMLM jobs, +templates, or downloader policy. Cross-module functionality belongs behind a +small interface in the suite layer. + +## File map + +- `models.py` contains normalized module settings and public state models. +- `service.py` owns scheduling, MaM reads, purchase decisions, verification, + logging, and the public service API used by the web layer. +- `storage.py` owns MAM-Spender-specific SQLite persistence and migrations. +- `templates/modules/mam_spender/_purchase_settings.html` is the reusable + purchase-policy editor shown on both the Dashboard and Config pages. +- `templates/mam_spender.html` composes the module pages inside the shared + MyAnonaSuite shell. +- `static/mam-spender.js` owns live module rendering and controls. +- MAM-Spender styles are grouped together under the clearly marked + `MAM-Spender` section in `static/app.css` while the suite has one stylesheet. +- `tests/test_mam_spender.py` covers module policy and MaM purchase behavior; + dedicated page routing is covered by `tests/test_web.py`. + +## Boundaries + +MAM-Spender shares only the authenticated MaM client, repository connection, +listener, and suite navigation. Its settings are persisted as module state and +apply without restarting. Host and port stay suite-wide because MyAnonaSuite is +one web process. + +When adding a setting, update `Settings`, normalization, `update_settings`, the +shared purchase-settings partial when it is a day-to-day control, and a focused +test. Authentication, migration, and uncommon controls belong on the Config +page; frequently adjusted purchase controls may also appear on the Dashboard +through the shared partial. diff --git a/python/src/mlm/modules/mam_spender/__init__.py b/python/src/mlm/modules/mam_spender/__init__.py new file mode 100644 index 00000000..22402a53 --- /dev/null +++ b/python/src/mlm/modules/mam_spender/__init__.py @@ -0,0 +1,12 @@ +"""MAM-Spender module for MyAnonaSuite.""" + +from .models import Settings, Totals +from .service import MamSpenderService, default_public_state, extract_mam_id + +__all__ = [ + "MamSpenderService", + "Settings", + "Totals", + "default_public_state", + "extract_mam_id", +] diff --git a/python/src/mlm/modules/mam_spender/models.py b/python/src/mlm/modules/mam_spender/models.py new file mode 100644 index 00000000..5332ba9b --- /dev/null +++ b/python/src/mlm/modules/mam_spender/models.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, fields +from typing import Any, TypeVar + +POINTS_PER_BLOCK = 25_000 +GB_PER_BLOCK = 50 +MAX_UPLOAD_BLOCKS_PER_RUN = 3 +MAX_UPLOAD_POINTS_PER_RUN = POINTS_PER_BLOCK * MAX_UPLOAD_BLOCKS_PER_RUN +FL_WEDGE_COST = 50_000 +VIP_RENEW_DAYS = 83 +MIN_INTERVAL_MINUTES = 2 +MAX_POINTS_BUFFER = 25_000 + + +@dataclass +class Settings: + buy_vip: bool = True + buy_upload_credit: bool = True + alternate_fl_upload: bool = False + alternate_next_purchase: str = "freeleech_wedge" + fl_only: bool = False + points_buffer: int = 10_000 + next_run_delay_minutes: int = 15 + + def normalize(self) -> None: + self.points_buffer = max(0, min(MAX_POINTS_BUFFER, int(self.points_buffer))) + self.next_run_delay_minutes = max( + MIN_INTERVAL_MINUTES, int(self.next_run_delay_minutes) + ) + if self.alternate_next_purchase not in { + "freeleech_wedge", + "upload_credit", + }: + self.alternate_next_purchase = "freeleech_wedge" + if self.fl_only: + self.buy_upload_credit = False + self.alternate_fl_upload = False + elif self.alternate_fl_upload: + self.buy_upload_credit = True + + +@dataclass +class Totals: + cumulative_upload_gb: int = 0 + cumulative_points_spent: int = 0 + cumulative_freeleech_wedges: int = 0 + cumulative_freeleech_points_spent: int = 0 + cumulative_vip_purchases: int = 0 + + +@dataclass +class UserSummary: + username: str = "N/A" + vip_expires: str = "N/A" + downloaded: str = "N/A" + uploaded: str = "N/A" + ratio: str = "N/A" + + +T = TypeVar("T") + + +def dataclass_from_dict(cls: type[T], values: dict[str, Any] | None) -> T: + defaults = asdict(cls()) # type: ignore[call-arg] + allowed = {item.name for item in fields(cls)} + defaults.update( + {key: value for key, value in (values or {}).items() if key in allowed} + ) + return cls(**defaults) diff --git a/python/src/mlm/modules/mam_spender/service.py b/python/src/mlm/modules/mam_spender/service.py new file mode 100644 index 00000000..7260f12d --- /dev/null +++ b/python/src/mlm/modules/mam_spender/service.py @@ -0,0 +1,921 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import re +from dataclasses import asdict +from datetime import UTC, datetime, timedelta +from html import unescape +from typing import Any +from urllib.parse import unquote + +from ...mam import MamClient, MamError +from ...repository import Repository +from .models import ( + FL_WEDGE_COST, + GB_PER_BLOCK, + MAX_POINTS_BUFFER, + MAX_UPLOAD_BLOCKS_PER_RUN, + MAX_UPLOAD_POINTS_PER_RUN, + MIN_INTERVAL_MINUTES, + POINTS_PER_BLOCK, + VIP_RENEW_DAYS, + Settings, + Totals, + UserSummary, + dataclass_from_dict, +) +from .storage import MamSpenderStore + +COMPONENT = "mam_spender" +BONUS_HISTORY_TYPES = ( + "giftPoints", + "giftWedge", + "wedgePF", + "wedgeGFL", + "torrentThanks", + "millionaires", +) + + +def now() -> datetime: + return datetime.now(UTC) + + +def parse_datetime(value: Any) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%b %d, %Y %I:%M %p"): + try: + return datetime.strptime(raw, fmt).replace(tzinfo=UTC) + except ValueError: + pass + try: + parsed = datetime.fromisoformat(raw) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def clean_cookie_value(value: str) -> str: + cleaned = unquote(value.strip().strip("'\"")) + if cleaned.casefold().startswith("mam_id="): + cleaned = cleaned.split("=", 1)[1] + return cleaned.split(";", 1)[0].strip() + + +def extract_mam_id(value: str) -> str: + """Extract mam_id from raw values and common browser/curl exports.""" + raw = str(value or "").strip() + if not raw: + return "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = None + + def walk(item: Any) -> str: + if isinstance(item, dict): + lowered = {str(key).casefold(): val for key, val in item.items()} + if "mam_id" in lowered: + return clean_cookie_value(str(lowered["mam_id"])) + if str(lowered.get("name", "")).casefold() == "mam_id": + return clean_cookie_value(str(lowered.get("value", ""))) + for child in item.values(): + found = walk(child) + if found: + return found + elif isinstance(item, list): + for child in item: + found = walk(child) + if found: + return found + return "" + + from_json = walk(parsed) + if from_json: + return from_json + patterns = ( + r"(?:^|[;\s,])mam_id\s*=\s*['\"]?([^;,\s'\"]+)", + r"['\"]name['\"]\s*:\s*['\"]mam_id['\"][\s\S]{0,240}?" + r"['\"]value['\"]\s*:\s*['\"]([^'\"]+)", + r"myanonamouse\.net\s+\S+\s+\S+\s+\S+\s+\S+\s+mam_id\s+([^\s]+)", + ) + for pattern in patterns: + match = re.search(pattern, raw, flags=re.IGNORECASE) + if match: + return clean_cookie_value(match.group(1)) + for line in raw.splitlines(): + parts = line.strip().split() + if len(parts) >= 7 and parts[-2].casefold() == "mam_id": + return clean_cookie_value(parts[-1]) + if "\n" not in raw and "=" not in raw and ";" not in raw and len(raw) >= 12: + return clean_cookie_value(raw) + return "" + + +class MamSpenderService: + def __init__(self, repository: Repository, mam: MamClient) -> None: + self.repository = repository + self.mam = mam + self.store = MamSpenderStore(repository) + self.settings = dataclass_from_dict(Settings, self.store.value("settings", {})) + self.settings.normalize() + self.totals = dataclass_from_dict(Totals, self.store.value("totals", {})) + self.user = dataclass_from_dict( + UserSummary, self.store.value("user_summary", {}) + ) + self.scheduler_enabled = bool(self.store.value("scheduler_enabled", False)) + self.next_run_time = parse_datetime(self.store.value("next_run_time")) + self.last_scan_points = self.store.value("last_scan_points") + self.last_scan_time = parse_datetime(self.store.value("last_scan_time")) + self.points_per_min = self.store.value("points_per_min") + self.mam_user_data = dict(self.store.value("mam_user_data", {})) + self.mam_user_error = str(self.store.value("mam_user_error", "")) + self.mam_user_fetched_at = str(self.store.value("mam_user_fetched_at", "")) + self.bonus_history = list(self.store.value("bonus_history", []))[-500:] + self.bonus_history_error = str(self.store.value("bonus_history_error", "")) + self.bonus_history_fetched_at = str( + self.store.value("bonus_history_fetched_at", "") + ) + self.automation_running = False + self._closed = False + self._scheduler_task: asyncio.Task[None] | None = None + self._automation_task: asyncio.Task[None] | None = None + + def start(self) -> None: + if self.scheduler_enabled and self.next_run_time is None: + self._schedule_next() + if not self._scheduler_task or self._scheduler_task.done(): + self._scheduler_task = asyncio.create_task( + self._scheduler_loop(), name="mam-spender-scheduler" + ) + self.log("MAM-Spender module started.") + + async def close(self) -> None: + self._closed = True + for task in (self._scheduler_task, self._automation_task): + if task and not task.done(): + task.cancel() + for task in (self._scheduler_task, self._automation_task): + if task: + with contextlib.suppress(asyncio.CancelledError): + await task + + def log( + self, + message: str, + *, + level: str = "info", + context: dict[str, Any] | None = None, + ) -> None: + safe = re.sub( + r"mam_id\s*=\s*[^;,\s]+", + "mam_id=[redacted]", + str(message), + flags=re.IGNORECASE, + ) + self.repository.log_activity( + COMPONENT, safe, level=level, context=context or {} + ) + + def _persist_runtime(self) -> None: + values = { + "settings": asdict(self.settings), + "totals": asdict(self.totals), + "user_summary": asdict(self.user), + "scheduler_enabled": self.scheduler_enabled, + "next_run_time": ( + self.next_run_time.isoformat() if self.next_run_time else None + ), + "last_scan_points": self.last_scan_points, + "last_scan_time": ( + self.last_scan_time.isoformat() if self.last_scan_time else None + ), + "points_per_min": self.points_per_min, + "mam_user_data": self.mam_user_data, + "mam_user_error": self.mam_user_error, + "mam_user_fetched_at": self.mam_user_fetched_at, + "bonus_history": self.bonus_history[-500:], + "bonus_history_error": self.bonus_history_error, + "bonus_history_fetched_at": self.bonus_history_fetched_at, + } + for key, value in values.items(): + self.store.set_value(key, value) + + def public_state(self) -> dict[str, Any]: + remaining = None + if self.next_run_time: + remaining = max(0, int((self.next_run_time - now()).total_seconds())) + return { + "settings": asdict(self.settings), + "totals": asdict(self.totals), + "user": asdict(self.user), + "scheduler_enabled": self.scheduler_enabled, + "automation_running": self.automation_running, + "next_run_time": ( + self.next_run_time.isoformat() if self.next_run_time else None + ), + "next_run_seconds": remaining, + "last_scan_points": self.last_scan_points, + "points_per_min": self.points_per_min, + "history": self.store.history(), + "spend_events": self.store.events(), + "mam_user_data": self.mam_user_data, + "mam_user_error": self.mam_user_error, + "mam_user_fetched_at": self.mam_user_fetched_at, + "bonus_history": self.bonus_history, + "bonus_history_error": self.bonus_history_error, + "bonus_history_fetched_at": self.bonus_history_fetched_at, + "logs": list( + reversed( + self.repository.recent_activity(limit=300, component=COMPONENT) + ) + ), + "session_id_saved": bool(self.repository.config_value("mam_id")), + "constants": { + "points_per_block": POINTS_PER_BLOCK, + "gb_per_block": GB_PER_BLOCK, + "max_upload_blocks_per_run": MAX_UPLOAD_BLOCKS_PER_RUN, + "max_upload_points_per_run": MAX_UPLOAD_POINTS_PER_RUN, + "fl_wedge_cost": FL_WEDGE_COST, + "vip_renew_days": VIP_RENEW_DAYS, + "min_interval_minutes": MIN_INTERVAL_MINUTES, + "max_points_buffer": MAX_POINTS_BUFFER, + }, + } + + def update_settings(self, incoming: dict[str, Any]) -> dict[str, Any]: + for key in ("buy_vip", "buy_upload_credit", "alternate_fl_upload", "fl_only"): + if key in incoming: + setattr(self.settings, key, bool(incoming[key])) + if "alternate_next_purchase" in incoming: + self.settings.alternate_next_purchase = str( + incoming["alternate_next_purchase"] + ) + if "points_buffer" in incoming: + self.settings.points_buffer = int(incoming["points_buffer"]) + if "next_run_delay_minutes" in incoming: + self.settings.next_run_delay_minutes = int( + incoming["next_run_delay_minutes"] + ) + self.settings.normalize() + if self.scheduler_enabled: + self._schedule_next() + self._persist_runtime() + self.log("MAM-Spender settings saved and applied.") + return self.public_state() + + def start_scheduler(self) -> dict[str, Any]: + self.scheduler_enabled = True + self._schedule_next() + self._persist_runtime() + self.log("Scheduled spending started.") + return self.public_state() + + def pause_scheduler(self) -> dict[str, Any]: + self.scheduler_enabled = False + self.next_run_time = None + self._persist_runtime() + self.log("Scheduled spending paused.") + return self.public_state() + + def reset_totals(self) -> dict[str, Any]: + self.totals = Totals() + self.store.clear_totals() + self._persist_runtime() + self.store.add_history({"kind": "manual", "result": "Cumulative totals reset."}) + self.log("Cumulative totals reset.") + return self.public_state() + + def save_session_id(self, value: str) -> dict[str, Any]: + mam_id = extract_mam_id(value) + if not mam_id: + raise ValueError( + "Could not find mam_id in that value, Cookie header, or export." + ) + self.mam.set_mam_id(mam_id) + self.repository.set_config_value("mam_id", mam_id) + self.log("Shared MyAnonaSuite API Session_ID updated.") + return self.public_state() + + def import_legacy(self, payload: dict[str, Any]) -> dict[str, Any]: + self.settings = dataclass_from_dict(Settings, payload.get("settings", {})) + self.settings.normalize() + self.totals = dataclass_from_dict(Totals, payload.get("totals", {})) + self.scheduler_enabled = bool(payload.get("scheduler_enabled", False)) + self.next_run_time = parse_datetime(payload.get("next_run_time")) + self.last_scan_points = payload.get("last_scan_points") + self.last_scan_time = parse_datetime(payload.get("last_scan_time")) + self.mam_user_data = dict(payload.get("mam_user_data", {})) + self.mam_user_error = str(payload.get("mam_user_error", "")) + self.mam_user_fetched_at = str(payload.get("mam_user_fetched_at", "")) + self.bonus_history = list(payload.get("bonus_history", []))[-500:] + self.bonus_history_error = str(payload.get("bonus_history_error", "")) + self.bonus_history_fetched_at = str(payload.get("bonus_history_fetched_at", "")) + for row in list(payload.get("history", []))[-300:]: + if isinstance(row, dict): + self.store.add_history(row) + for row in list(payload.get("spend_events", []))[-1000:]: + if isinstance(row, dict): + self.store.add_event(row) + plain_session = str(payload.get("settings", {}).get("plain_session_id", "")) + if plain_session: + self.save_session_id(plain_session) + self._persist_runtime() + self.log("Imported settings and history from MAM-Spender Web Edition.") + return self.public_state() + + def run_now(self, *, fl_only_override: bool = False) -> dict[str, Any]: + if self.automation_running: + return self.public_state() + self.automation_running = True + self._automation_task = asyncio.create_task( + self._run_and_reschedule(fl_only_override), + name="mam-spender-run", + ) + self.log( + "Manual spending scan requested.", + context={"freeleech_only_override": fl_only_override}, + ) + return self.public_state() + + async def _scheduler_loop(self) -> None: + while not self._closed: + await asyncio.sleep(1) + if ( + self.scheduler_enabled + and not self.automation_running + and self.next_run_time + and now() >= self.next_run_time + ): + self.automation_running = True + self._automation_task = asyncio.create_task( + self._run_and_reschedule(False), name="mam-spender-run" + ) + + async def _run_and_reschedule(self, fl_only_override: bool) -> None: + try: + await self.run_automation(fl_only_override=fl_only_override) + finally: + self.automation_running = False + if self.scheduler_enabled: + self._schedule_next() + self._persist_runtime() + + def _schedule_next(self) -> None: + self.next_run_time = now() + timedelta( + minutes=self.settings.next_run_delay_minutes + ) + + async def run_automation(self, *, fl_only_override: bool = False) -> None: + started_at = now() + result = "Completed" + points_start: int | None = None + points_end: int | None = None + purchased_gb = 0 + wedges = 0 + vip_purchased = False + self.log("Starting spending scan.") + try: + summary_data = await self._summary_data() + uid = str(summary_data.get("uid") or summary_data.get("id") or "") + if not uid: + raise MamError("MaM session did not return a user ID") + self._update_user_summary(summary_data) + points = await self._get_points(uid) + if points <= 0: + raise MamError("MaM did not return a usable bonus-point balance") + points_start = points + points_end = points + self._update_points_rate(points) + self.log( + f"Current balance: {points:,} points.", + context={"points": points, "uid": uid}, + ) + + if self.settings.buy_vip: + points, vip_purchased = await self._maybe_buy_vip( + uid, points, summary_data + ) + points_end = points + + alternate_target = ( + self.settings.alternate_next_purchase + if self.settings.alternate_fl_upload + else "" + ) + buy_wedge = ( + self.settings.fl_only + or fl_only_override + or alternate_target == "freeleech_wedge" + ) + if buy_wedge: + points, wedge_bought = await self._maybe_buy_wedge(uid, points) + wedges += int(wedge_bought) + points_end = points + if wedge_bought and self.settings.alternate_fl_upload: + self.settings.alternate_next_purchase = "upload_credit" + + stop_after_wedge = ( + self.settings.fl_only + or fl_only_override + or ( + self.settings.alternate_fl_upload + and alternate_target == "freeleech_wedge" + ) + ) + if not stop_after_wedge and self.settings.buy_upload_credit: + points, purchased_gb = await self._buy_upload_credit(uid, points) + points_end = points + if purchased_gb and self.settings.alternate_fl_upload: + self.settings.alternate_next_purchase = "freeleech_wedge" + elif stop_after_wedge: + self.log("Upload-credit purchase skipped by the active purchase mode.") + else: + self.log("Upload-credit purchases are disabled.") + + spent = max((points_start or points) - points, 0) + self.totals.cumulative_upload_gb += purchased_gb + self.totals.cumulative_points_spent += spent + self.totals.cumulative_freeleech_wedges += wedges + self.totals.cumulative_freeleech_points_spent += wedges * FL_WEDGE_COST + self.totals.cumulative_vip_purchases += int(vip_purchased) + if spent or purchased_gb or wedges or vip_purchased: + refreshed = await self._summary_data() + self._update_user_summary(refreshed) + self.log( + "Spending scan complete.", + level="success", + context={ + "points_spent": spent, + "upload_gb": purchased_gb, + "freeleech_wedges": wedges, + "vip_purchased": vip_purchased, + }, + ) + except Exception as error: # noqa: BLE001 - preserve scheduler operation + result = f"Error: {type(error).__name__}: {error}" + self.log( + "Spending scan failed.", + level="error", + context={"error": result}, + ) + finally: + spent = ( + max(points_start - points_end, 0) + if points_start is not None and points_end is not None + else 0 + ) + self.store.add_history( + { + "created_at": now().isoformat(), + "started_at": started_at.isoformat(), + "kind": "run", + "result": result, + "points_before": points_start, + "points_after": points_end, + "points_spent": spent, + "upload_gb": purchased_gb, + "freeleech_wedges": wedges, + "vip_purchased": vip_purchased, + } + ) + self._persist_runtime() + + async def _summary_data(self) -> dict[str, Any]: + data = await self.mam.user_info() + if not isinstance(data, dict): + raise MamError("MaM returned an unexpected user summary") + return data + + async def _get_points(self, uid: str) -> int: + data = await self.mam.request_json("/jsonLoad.php", params={"uid": uid}) + if not isinstance(data, dict): + return 0 + try: + return int(float(str(data.get("seedbonus") or 0).replace(",", ""))) + except (TypeError, ValueError): + return 0 + + async def _maybe_buy_vip( + self, uid: str, points: int, summary_data: dict[str, Any] + ) -> tuple[int, bool]: + expiry = parse_datetime(summary_data.get("vip_until")) + remaining_days = (expiry - now()).total_seconds() / 86_400 if expiry else 0 + self.log( + "Checked VIP renewal window.", + context={ + "vip_expires": expiry.isoformat() if expiry else None, + "days_remaining": round(remaining_days, 1), + "renew_at_days": VIP_RENEW_DAYS, + }, + ) + if expiry and remaining_days > VIP_RENEW_DAYS: + return points, False + response = await self.mam.request_json( + "/json/bonusBuy.php/", + params={ + "spendtype": "VIP", + "duration": "max", + "_": int(now().timestamp() * 1000), + }, + ) + if isinstance(response, dict) and self._response_rejected(response): + self.log( + "VIP renewal was rejected by MaM.", + level="error", + context={"response": self._safe_response(response)}, + ) + return points, False + await asyncio.sleep(0.8) + after = await self._get_points(uid) + if after <= 0 and self._response_succeeded(response): + after = points + spent = max(points - after, 0) + if spent <= 0 and not self._response_succeeded(response): + self.log( + "VIP renewal could not be confirmed because points did not decrease.", + level="error", + context={"before": points, "after": after}, + ) + return points, False + if spent > 0: + self._add_spend_event("vip", "VIP Renewal", spent, 1, "renewal", after) + self.log("VIP renewal confirmed.", level="success", context={"spent": spent}) + return after, True + + async def _maybe_buy_wedge(self, uid: str, points: int) -> tuple[int, bool]: + threshold = FL_WEDGE_COST + self.settings.points_buffer + if points < threshold: + self.log( + "Freeleech Wedge skipped: balance would cross the points buffer.", + context={ + "points": points, + "required": threshold, + "buffer": self.settings.points_buffer, + }, + ) + return points, False + self.log( + "Requesting one Freeleech Wedge from the bonus store.", + context={"cost": FL_WEDGE_COST, "buffer": self.settings.points_buffer}, + ) + response = await self.mam.request_json( + "/json/bonusBuy.php/", + params={ + "spendtype": "wedges", + "source": "points", + "_": int(now().timestamp() * 1000), + }, + ) + if isinstance(response, dict) and self._response_rejected(response): + self.log( + "Freeleech Wedge purchase was rejected by MaM.", + level="error", + context={"response": self._safe_response(response)}, + ) + return points, False + await asyncio.sleep(0.8) + after = await self._get_points(uid) + if after <= 0 and self._response_succeeded(response): + after = max(points - FL_WEDGE_COST, 0) + observed_spend = max(points - after, 0) + if observed_spend < FL_WEDGE_COST and not self._response_succeeded(response): + self.log( + "Freeleech Wedge purchase failed verification.", + level="error", + context={ + "before": points, + "after": after, + "expected_decrease": FL_WEDGE_COST, + "response": self._safe_response(response), + }, + ) + return points, False + # Points continue accruing while the confirmation request is in flight, + # so the observed delta can be a few points below the fixed store cost. + spent = FL_WEDGE_COST + self._add_spend_event( + "freeleech_wedge", "Freeleech Wedge", spent, 1, "wedge", after + ) + self.log( + "Freeleech Wedge purchase confirmed.", + level="success", + context={"before": points, "after": after, "spent": spent}, + ) + return after, True + + async def _buy_upload_credit(self, uid: str, points: int) -> tuple[int, int]: + available = max(0, points - self.settings.points_buffer) + blocks = min(MAX_UPLOAD_BLOCKS_PER_RUN, available // POINTS_PER_BLOCK) + if blocks <= 0: + self.log( + "Upload credit skipped: not enough points above the buffer.", + context={ + "points": points, + "required": POINTS_PER_BLOCK + self.settings.points_buffer, + "buffer": self.settings.points_buffer, + }, + ) + return points, 0 + upload_gb = blocks * GB_PER_BLOCK + expected_spend = blocks * POINTS_PER_BLOCK + self.log( + f"Requesting {upload_gb} GiB upload credit.", + context={"blocks": blocks, "expected_spend": expected_spend}, + ) + response = await self.mam.request_json( + "/json/bonusBuy.php/", + params={"spendtype": "upload", "amount": upload_gb}, + ) + if isinstance(response, dict) and self._response_rejected(response): + self.log( + "Upload-credit purchase was rejected by MaM.", + level="error", + context={"response": self._safe_response(response)}, + ) + return points, 0 + await asyncio.sleep(0.8) + after = await self._get_points(uid) + if after <= 0 and self._response_succeeded(response): + after = max(points - expected_spend, 0) + spent = max(points - after, 0) + if spent < expected_spend and not self._response_succeeded(response): + self.log( + "Upload-credit purchase failed verification.", + level="error", + context={ + "before": points, + "after": after, + "expected_spend": expected_spend, + "response": self._safe_response(response), + }, + ) + return points, 0 + self._add_spend_event( + "upload_credit", + "Upload Credit", + expected_spend, + upload_gb, + "GiB", + after, + ) + self.log( + "Upload-credit purchase confirmed.", + level="success", + context={"upload_gb": upload_gb, "spent": spent, "balance": after}, + ) + return after, upload_gb + + def _update_points_rate(self, current: int) -> None: + current_time = now() + if self.last_scan_points is not None and self.last_scan_time: + minutes = (current_time - self.last_scan_time).total_seconds() / 60 + earned = current - int(self.last_scan_points) + self.points_per_min = earned / minutes if earned > 0 and minutes >= 1 else 0 + else: + self.points_per_min = None + self.last_scan_points = current + self.last_scan_time = current_time + + def _update_user_summary(self, data: dict[str, Any]) -> None: + self.user = UserSummary( + username=str(data.get("username") or "N/A"), + vip_expires=self._format_vip(data.get("vip_until")), + downloaded=str(data.get("downloaded") or "N/A"), + uploaded=str(data.get("uploaded") or "N/A"), + ratio=str(data.get("ratio") or "N/A"), + ) + + def _add_spend_event( + self, + category: str, + label: str, + points_spent: int, + units: int, + unit_label: str, + balance_after: int, + ) -> None: + self.store.add_event( + { + "created_at": now().isoformat(), + "category": category, + "label": label, + "points_spent": points_spent, + "units": units, + "unit_label": unit_label, + "balance_after": balance_after, + } + ) + + async def refresh_mam_user_data(self) -> dict[str, Any]: + try: + data = await self.mam.request_json( + "/jsonLoad.php", + params={"notif": "true", "snatch_summary": "true"}, + ) + if not isinstance(data, dict): + raise MamError("MaM returned unexpected user-data JSON") + uid = str(data.get("uid") or data.get("id") or "") + if uid: + detail = await self.mam.request_json( + "/jsonLoad.php", params={"id": uid} + ) + if isinstance(detail, dict): + data = {**detail, **data} + self.mam_user_data = self._normalize_user_data(data) + self.mam_user_error = "" + self.mam_user_fetched_at = now().isoformat() + self._update_user_summary(data) + self.log("MaM account data refreshed.", level="success") + except Exception as error: # noqa: BLE001 - return cached data with error + self.mam_user_error = f"{type(error).__name__}: {error}" + self.mam_user_fetched_at = now().isoformat() + self.log( + "MaM account-data refresh failed.", + level="error", + context={"error": self.mam_user_error}, + ) + self._persist_runtime() + return self.public_state() + + async def refresh_bonus_history(self) -> dict[str, Any]: + params = [("type[]", item) for item in BONUS_HISTORY_TYPES] + try: + data = await self.mam.request_json( + "/json/userBonusHistory.php", params=params + ) + if not isinstance(data, list): + raise MamError("MaM returned unexpected bonus-history JSON") + self.bonus_history = [ + self._normalize_bonus_entry(item) + for item in data[:500] + if isinstance(item, dict) + ] + self.bonus_history_error = "" + self.bonus_history_fetched_at = now().isoformat() + self.log( + f"MaM bonus history refreshed ({len(self.bonus_history)} rows).", + level="success", + ) + except Exception as error: # noqa: BLE001 - return cached data with error + self.bonus_history_error = f"{type(error).__name__}: {error}" + self.bonus_history_fetched_at = now().isoformat() + self.log( + "MaM bonus-history refresh failed.", + level="error", + context={"error": self.bonus_history_error}, + ) + self._persist_runtime() + return self.public_state() + + @staticmethod + def _safe_response(response: Any) -> Any: + if not isinstance(response, dict): + return str(response)[:500] + return { + str(key): value + for key, value in response.items() + if str(key).casefold() not in {"mam_id", "session_id", "cookie"} + } + + @staticmethod + def _response_succeeded(response: Any) -> bool: + if not isinstance(response, dict) or "success" not in response: + return False + return str(response.get("success")).strip().casefold() in { + "1", + "true", + "yes", + } + + @classmethod + def _response_rejected(cls, response: dict[str, Any]) -> bool: + return "success" in response and not cls._response_succeeded(response) + + @staticmethod + def _first(data: dict[str, Any], *keys: str) -> str: + lowered = {str(key).casefold(): value for key, value in data.items()} + for key in keys: + value = lowered.get(key.casefold()) + if value not in (None, ""): + return str(value) + return "N/A" + + @staticmethod + def _strip_html(value: Any) -> str: + without_tags = re.sub(r"<[^>]+>", " ", unescape(str(value or ""))) + return re.sub(r"\s+", " ", without_tags).strip() + + def _normalize_user_data(self, data: dict[str, Any]) -> dict[str, Any]: + raw_notifications = data.get("notifs") or data.get("notifications") or [] + if isinstance(raw_notifications, dict): + raw_notifications = list(raw_notifications.values()) + elif not isinstance(raw_notifications, list): + raw_notifications = [raw_notifications] if raw_notifications else [] + notifications: list[str] = [] + for item in raw_notifications[:8]: + if isinstance(item, dict): + item = ( + item.get("message") + or item.get("text") + or item.get("body") + or item.get("title") + or item + ) + cleaned = self._strip_html(item) + if cleaned: + notifications.append(cleaned) + unsats = data.get("unsats") or data.get("unsat") or data.get("unsatisfied") + if isinstance(unsats, dict): + count = unsats.get("count") + limit = unsats.get("limit") + unsats = f"{count} / {limit}" if limit is not None else str(count or "N/A") + if bool(unsats) and data.get("red"): + unsats += " (flagged)" + return { + "username": self._first(data, "username"), + "uid": self._first(data, "uid", "id"), + "class": self._first(data, "classname", "class"), + "ratio": self._first(data, "ratio"), + "downloaded": self._first(data, "downloaded"), + "uploaded": self._first(data, "uploaded"), + "bonus": self._first( + data, "seedbonus", "bonus", "bonuspoints", "bonus_points" + ), + "invites": self._first(data, "invites"), + "fl_wedges": self._first( + data, "fl_wedges", "flwedge", "freeleech_wedges", "wedges" + ), + "unsats": str(unsats or "N/A"), + "notifications": notifications, + "loaded_keys": sorted(str(key) for key in data)[:60], + } + + @staticmethod + def _normalize_bonus_entry(item: dict[str, Any]) -> dict[str, Any]: + timestamp = item.get("timestamp") + try: + timestamp_value = datetime.fromtimestamp( + float(timestamp), tz=UTC + ).isoformat() + except (TypeError, ValueError, OSError): + timestamp_value = str(timestamp or "") + return { + "timestamp": timestamp_value, + "amount": item.get("amount"), + "type": str(item.get("type") or "N/A"), + "tid": item.get("tid"), + "title": str(item.get("title") or "N/A"), + "other_userid": item.get("other_userid"), + "other_name": str(item.get("other_name") or "N/A"), + } + + @staticmethod + def _format_vip(value: Any) -> str: + parsed = parse_datetime(value) + return parsed.isoformat() if parsed else str(value or "N/A") + + +def default_public_state(repository: Repository) -> dict[str, Any]: + store = MamSpenderStore(repository) + settings = dataclass_from_dict(Settings, store.value("settings", {})) + settings.normalize() + totals = dataclass_from_dict(Totals, store.value("totals", {})) + user = dataclass_from_dict(UserSummary, store.value("user_summary", {})) + return { + "settings": asdict(settings), + "totals": asdict(totals), + "user": asdict(user), + "scheduler_enabled": bool(store.value("scheduler_enabled", False)), + "automation_running": False, + "next_run_time": store.value("next_run_time"), + "next_run_seconds": None, + "last_scan_points": store.value("last_scan_points"), + "points_per_min": store.value("points_per_min"), + "history": store.history(), + "spend_events": store.events(), + "mam_user_data": store.value("mam_user_data", {}), + "mam_user_error": store.value("mam_user_error", ""), + "mam_user_fetched_at": store.value("mam_user_fetched_at", ""), + "bonus_history": store.value("bonus_history", []), + "bonus_history_error": store.value("bonus_history_error", ""), + "bonus_history_fetched_at": store.value("bonus_history_fetched_at", ""), + "logs": list( + reversed(repository.recent_activity(limit=300, component=COMPONENT)) + ), + "session_id_saved": bool(repository.config_value("mam_id")), + "constants": { + "points_per_block": POINTS_PER_BLOCK, + "gb_per_block": GB_PER_BLOCK, + "max_upload_blocks_per_run": MAX_UPLOAD_BLOCKS_PER_RUN, + "max_upload_points_per_run": MAX_UPLOAD_POINTS_PER_RUN, + "fl_wedge_cost": FL_WEDGE_COST, + "vip_renew_days": VIP_RENEW_DAYS, + "min_interval_minutes": MIN_INTERVAL_MINUTES, + "max_points_buffer": MAX_POINTS_BUFFER, + }, + } diff --git a/python/src/mlm/modules/mam_spender/storage.py b/python/src/mlm/modules/mam_spender/storage.py new file mode 100644 index 00000000..fe56fb2f --- /dev/null +++ b/python/src/mlm/modules/mam_spender/storage.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from ...database import connect +from ...migration import canonical_json +from ...repository import Repository + + +class MamSpenderStore: + """Persistent module state kept separate from HeavyMLM records.""" + + def __init__(self, repository: Repository) -> None: + self.repository = repository + + def value(self, key: str, default: Any = None) -> Any: + with connect(self.repository.path) as connection: + row = connection.execute( + "SELECT value_json FROM mam_spender_state WHERE key = ?", (key,) + ).fetchone() + return json.loads(row[0]) if row else default + + def set_value(self, key: str, value: Any) -> None: + with connect(self.repository.path) as connection: + connection.execute( + """INSERT INTO mam_spender_state(key, value_json) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json""", + (key, canonical_json(value)), + ) + + def add_history(self, payload: dict[str, Any]) -> None: + created_at = str(payload.get("created_at") or datetime.now(UTC).isoformat()) + row = {"created_at": created_at, **payload} + with connect(self.repository.path) as connection: + connection.execute( + """INSERT INTO mam_spender_history(created_at, payload_json) + VALUES (?, ?)""", + (created_at, canonical_json(row)), + ) + connection.execute( + """DELETE FROM mam_spender_history WHERE id NOT IN ( + SELECT id FROM mam_spender_history ORDER BY id DESC LIMIT 300 + )""" + ) + + def history(self, limit: int = 200) -> list[dict[str, Any]]: + with connect(self.repository.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM mam_spender_history + ORDER BY id DESC LIMIT ?""", + (limit,), + ) + return [json.loads(row[0]) for row in rows] + + def add_event(self, payload: dict[str, Any]) -> None: + created_at = str(payload.get("created_at") or datetime.now(UTC).isoformat()) + category = str(payload.get("category") or "other") + row = {"created_at": created_at, **payload} + with connect(self.repository.path) as connection: + connection.execute( + """INSERT INTO mam_spender_events + (created_at, category, payload_json) VALUES (?, ?, ?)""", + (created_at, category, canonical_json(row)), + ) + connection.execute( + """DELETE FROM mam_spender_events WHERE id NOT IN ( + SELECT id FROM mam_spender_events ORDER BY id DESC LIMIT 1000 + )""" + ) + + def events(self, limit: int = 500) -> list[dict[str, Any]]: + with connect(self.repository.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM ( + SELECT id, payload_json FROM mam_spender_events + ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC""", + (limit,), + ) + return [json.loads(row[0]) for row in rows] + + def clear_totals(self) -> None: + self.set_value("totals", {}) diff --git a/python/src/mlm/qbittorrent.py b/python/src/mlm/qbittorrent.py new file mode 100644 index 00000000..50508fcb --- /dev/null +++ b/python/src/mlm/qbittorrent.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Self + +import httpx + + +class QbitError(RuntimeError): + pass + + +class QbitClient: + def __init__( + self, + url: str, + *, + client: httpx.AsyncClient | None = None, + timeout: float = 20, + ) -> None: + self._owns_client = client is None + self.client = client or httpx.AsyncClient( + base_url=url.rstrip("/"), timeout=timeout + ) + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() + + async def close(self) -> None: + if self._owns_client: + await self.client.aclose() + + @staticmethod + def _check(response: httpx.Response) -> httpx.Response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as error: + raise QbitError(str(error)) from error + if response.text.strip() == "Fails.": + raise QbitError("qBittorrent rejected the request") + return response + + async def login(self, username: str = "", password: str = "") -> None: + response = await self.client.post( + "/api/v2/auth/login", + data={"username": username, "password": password}, + ) + self._check(response) + + async def add_torrent( + self, + torrent_file: bytes, + *, + category: str | None = None, + tags: Iterable[str] = (), + paused: bool = False, + ) -> None: + data: dict[str, str] = {"paused": str(paused).lower()} + if category: + data["category"] = category + tags_value = ",".join(tags) + if tags_value: + data["tags"] = tags_value + if category: + await self.ensure_category(category) + response = await self.client.post( + "/api/v2/torrents/add", + data=data, + files={ + "torrents": ( + "download.torrent", + torrent_file, + "application/x-bittorrent", + ) + }, + ) + self._check(response) + + async def torrents( + self, *, hashes: Iterable[str] = (), category: str | None = None + ) -> list[dict]: + hashes_value = "|".join(hashes) + params = {} + if hashes_value: + params["hashes"] = hashes_value + if category is not None: + params["category"] = category + response = self._check( + await self.client.get("/api/v2/torrents/info", params=params or None) + ) + return response.json() + + async def files(self, torrent_hash: str) -> list[dict]: + response = self._check( + await self.client.get( + "/api/v2/torrents/files", params={"hash": torrent_hash} + ) + ) + return response.json() + + async def trackers(self, torrent_hash: str) -> list[dict]: + response = self._check( + await self.client.get( + "/api/v2/torrents/trackers", params={"hash": torrent_hash} + ) + ) + return response.json() + + async def categories(self) -> dict[str, dict]: + response = self._check(await self.client.get("/api/v2/torrents/categories")) + return response.json() + + async def ensure_category(self, category: str) -> None: + if category in await self.categories(): + return + response = await self.client.post( + "/api/v2/torrents/createCategory", data={"category": category} + ) + self._check(response) + + async def set_category(self, hashes: Iterable[str], category: str) -> None: + await self.ensure_category(category) + response = await self.client.post( + "/api/v2/torrents/setCategory", + data={"hashes": "|".join(hashes), "category": category}, + ) + self._check(response) + + async def add_tags(self, hashes: Iterable[str], tags: Iterable[str]) -> None: + tags_value = ",".join(tags) + if not tags_value: + return + response = await self.client.post( + "/api/v2/torrents/addTags", + data={"hashes": "|".join(hashes), "tags": tags_value}, + ) + self._check(response) diff --git a/python/src/mlm/repository.py b/python/src/mlm/repository.py new file mode 100644 index 00000000..c450e820 --- /dev/null +++ b/python/src/mlm/repository.py @@ -0,0 +1,936 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from .database import connect +from .migration import canonical_json +from .search import normalize_title + + +class RequestLimitReached(RuntimeError): + def __init__(self, username: str, limit: int, used: int) -> None: + self.username = username + self.limit = limit + self.used = used + super().__init__( + f"request account {username!r} has used its {limit}-request weekly limit" + ) + + +class Repository: + def __init__(self, path: Path) -> None: + self.path = path + + def pending_selected(self) -> list[dict[str, Any]]: + with connect(self.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM selected_torrents + WHERE json_extract(payload_json, '$.started_at') IS NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + ORDER BY mam_id""" + ) + return [json.loads(row[0]) for row in rows] + + def has_pending_mam_id(self, mam_id: int) -> bool: + with connect(self.path) as connection: + row = connection.execute( + """SELECT 1 FROM selected_torrents + WHERE mam_id = ? + AND json_extract(payload_json, '$.started_at') IS NULL + AND json_extract(payload_json, '$.removed_at') IS NULL""", + (mam_id,), + ).fetchone() + return row is not None + + def selected_pipeline_status(self) -> dict[str, int]: + with connect(self.path) as connection: + row = connection.execute( + """SELECT + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN 1 ELSE 0 END), + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NOT NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN 1 ELSE 0 END), + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NOT NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN COALESCE( + CAST(json_extract(payload_json, '$.meta.size') AS INTEGER), 0 + ) ELSE 0 END) + FROM selected_torrents""" + ).fetchone() + return { + "awaiting": int(row[0] or 0), + "downloading": int(row[1] or 0), + "downloading_bytes": int(row[2] or 0), + } + + def config_value(self, key: str) -> str | None: + with connect(self.path) as connection: + row = connection.execute( + "SELECT value FROM config WHERE key = ?", (key,) + ).fetchone() + return str(row[0]) if row else None + + def set_config_value(self, key: str, value: str) -> None: + payload = {"key": key, "value": value} + with connect(self.path) as connection: + connection.execute( + """INSERT INTO config(key, value, payload_json) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value=excluded.value, payload_json=excluded.payload_json""", + (key, value, canonical_json(payload)), + ) + + def log_activity( + self, + component: str, + message: str, + *, + level: str = "info", + context: dict[str, Any] | None = None, + ) -> None: + with connect(self.path) as connection, connection: + connection.execute( + """INSERT INTO activity_log + (created_at, level, component, message, context_json) + VALUES (?, ?, ?, ?, ?)""", + ( + datetime.now(UTC).isoformat(), + level, + component, + message, + canonical_json(context or {}), + ), + ) + connection.execute( + """DELETE FROM activity_log WHERE id NOT IN ( + SELECT id FROM activity_log ORDER BY id DESC LIMIT 2000 + )""" + ) + + def recent_activity( + self, *, limit: int = 200, component: str | None = None + ) -> list[dict[str, Any]]: + query = """SELECT id, created_at, level, component, message, context_json + FROM activity_log""" + parameters: tuple[Any, ...] + if component: + query += " WHERE component = ?" + parameters = (component, limit) + else: + parameters = (limit,) + query += " ORDER BY id DESC LIMIT ?" + with connect(self.path) as connection: + rows = connection.execute(query, parameters) + return [ + { + "id": row["id"], + "created_at": row["created_at"], + "level": row["level"], + "component": row["component"], + "message": row["message"], + "context": json.loads(row["context_json"]), + } + for row in rows + ] + + def create_request( + self, + *, + mam_id: int, + release: dict[str, Any], + requester_name: str = "", + requester_contact: str = "", + requester_username: str = "", + requester_permissions: tuple[str, ...] = (), + requester_weekly_limit: int = 0, + note: str = "", + source: dict[str, Any] | None = None, + ) -> dict[str, Any]: + now_datetime = datetime.now(UTC) + now = now_datetime.isoformat() + row = { + "id": str(uuid4()), + "mam_id": mam_id, + "status": "pending", + "requester_name": requester_name.strip(), + "requester_contact": requester_contact.strip(), + "requester_username": requester_username.strip(), + "requester_permissions": list(requester_permissions), + "requester_weekly_limit": max(0, int(requester_weekly_limit)), + "note": note.strip(), + "source": source or {}, + "release": release, + "created_at": now, + "updated_at": now, + "decision_note": "", + "decision_by": "", + } + with connect(self.path) as connection: + if requester_username and requester_weekly_limit > 0: + connection.execute("BEGIN IMMEDIATE") + used = self._request_count_since( + connection, + requester_username, + now_datetime - timedelta(days=7), + ) + if used >= requester_weekly_limit: + raise RequestLimitReached( + requester_username, + requester_weekly_limit, + used, + ) + connection.execute( + """INSERT INTO requests + (id, mam_id, status, created_at, updated_at, payload_json) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + row["id"], + row["mam_id"], + row["status"], + row["created_at"], + row["updated_at"], + canonical_json(row), + ), + ) + return row + + @staticmethod + def _request_count_since( + connection, + username: str, + since: datetime, + ) -> int: + normalized = username.strip().casefold() + rows = connection.execute( + "SELECT payload_json FROM requests WHERE created_at >= ?", + (since.isoformat(),), + ) + return sum( + 1 + for stored in rows + if str(json.loads(stored[0]).get("requester_username", "")) + .strip() + .casefold() + == normalized + ) + + def request_count_since( + self, + username: str, + *, + since: datetime | None = None, + ) -> int: + window_start = since or (datetime.now(UTC) - timedelta(days=7)) + with connect(self.path) as connection: + return self._request_count_since(connection, username, window_start) + + def request_record(self, request_id: str) -> dict[str, Any] | None: + with connect(self.path) as connection: + row = connection.execute( + "SELECT payload_json FROM requests WHERE id = ?", (request_id,) + ).fetchone() + return json.loads(row[0]) if row else None + + def request_rows( + self, + *, + status: str | None = None, + limit: int = 200, + ) -> list[dict[str, Any]]: + query = "SELECT payload_json FROM requests" + parameters: tuple[Any, ...] + if status: + query += " WHERE status = ?" + parameters = (status, limit) + else: + parameters = (limit,) + query += " ORDER BY created_at DESC LIMIT ?" + with connect(self.path) as connection: + rows = connection.execute(query, parameters) + return [json.loads(row[0]) for row in rows] + + def update_request( + self, + request_id: str, + status: str, + *, + decision_note: str = "", + decision_by: str = "", + ) -> dict[str, Any] | None: + with connect(self.path) as connection: + stored = connection.execute( + "SELECT payload_json FROM requests WHERE id = ?", (request_id,) + ).fetchone() + if not stored: + return None + row = json.loads(stored[0]) + row.update( + status=status, + decision_note=decision_note.strip(), + decision_by=decision_by.strip(), + updated_at=datetime.now(UTC).isoformat(), + ) + connection.execute( + """UPDATE requests + SET status = ?, updated_at = ?, payload_json = ? + WHERE id = ?""", + ( + row["status"], + row["updated_at"], + canonical_json(row), + request_id, + ), + ) + return row + + def request_counts(self) -> dict[str, int]: + with connect(self.path) as connection: + rows = connection.execute( + "SELECT status, COUNT(*) FROM requests GROUP BY status" + ) + counts = {str(row[0]): int(row[1]) for row in rows} + counts["total"] = sum(counts.values()) + return counts + + def has_mam_id(self, mam_id: int) -> bool: + with connect(self.path) as connection: + selected = connection.execute( + "SELECT 1 FROM selected_torrents WHERE mam_id = ?", (mam_id,) + ).fetchone() + library = connection.execute( + "SELECT 1 FROM torrents WHERE mam_id = ?", (mam_id,) + ).fetchone() + return selected is not None or library is not None + + def has_goodreads_id(self, goodreads_id: int) -> bool: + with connect(self.path) as connection: + for table in ("selected_torrents", "torrents"): + if connection.execute( + f"""SELECT 1 FROM {table} + WHERE CAST(json_extract(payload_json, '$.goodreads_id') + AS INTEGER) = ? + LIMIT 1""", + (goodreads_id,), + ).fetchone(): + return True + return False + + def goodreads_formats(self, goodreads_id: int) -> set[str]: + formats: set[str] = set() + with connect(self.path) as connection: + for table in ("selected_torrents", "torrents"): + rows = connection.execute( + f"""SELECT json_extract(payload_json, '$.meta.media_type') + FROM {table} + WHERE CAST(json_extract(payload_json, '$.goodreads_id') + AS INTEGER) = ?""", + (goodreads_id,), + ) + for row in rows: + media_type = str(row[0] or "") + if media_type in {"audiobook", "periodical_audiobook"}: + formats.add("audio") + elif media_type in { + "ebook", + "manga", + "comic_book", + "periodical_ebook", + }: + formats.add("ebook") + return formats + + def records_with_title(self, title_search: str) -> list[dict[str, Any]]: + with connect(self.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM selected_torrents WHERE title_search = ? + UNION ALL + SELECT payload_json FROM torrents WHERE title_search = ? + UNION ALL + SELECT payload_json FROM abs_books WHERE title_search = ?""", + (title_search, title_search, title_search), + ) + return [json.loads(row[0]) for row in rows] + + def upsert_abs_books(self, items: list[dict[str, Any]]) -> int: + now = datetime.now(UTC).isoformat() + with connect(self.path) as connection, connection: + for item in items: + abs_id = str(item.get("id") or item.get("abs_id") or "") + if not abs_id: + continue + title = str(item.get("title") or "").strip() + title_search = normalize_title(title) + authors = item.get("authors") or [] + series = item.get("series") or [] + library_path = str( + item.get("library_path") + or item.get("path") + or item.get("relPath") + or "" + ).strip() + asin = str(item.get("asin") or "").strip() + isbn = str(item.get("isbn") or "").strip() + connection.execute( + """INSERT INTO abs_books + (abs_id, title, title_search, authors_json, series_json, + library_path, asin, isbn, payload_json, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(abs_id) DO UPDATE SET + title=excluded.title, + title_search=excluded.title_search, + authors_json=excluded.authors_json, + series_json=excluded.series_json, + library_path=excluded.library_path, + asin=excluded.asin, + isbn=excluded.isbn, + payload_json=excluded.payload_json, + synced_at=excluded.synced_at""", + ( + abs_id, + title, + title_search, + canonical_json(authors), + canonical_json(series), + library_path, + asin or None, + isbn or None, + canonical_json(item), + now, + ), + ) + return len(items) + + def abs_books_count(self) -> int: + with connect(self.path) as connection: + row = connection.execute("SELECT COUNT(1) FROM abs_books").fetchone() + return int(row[0] or 0) + + def abs_book_rows( + self, + *, + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + with connect(self.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM abs_books + ORDER BY title COLLATE NOCASE LIMIT ? OFFSET ?""", + (limit, offset), + ) + return [json.loads(row[0]) for row in rows] + + def add_selected(self, selected: dict[str, Any]) -> None: + with connect(self.path) as connection: + connection.execute( + """INSERT INTO selected_torrents + (mam_id, hash, title_search, created_at_json, payload_json) + VALUES (?, NULL, ?, ?, ?)""", + ( + selected["mam_id"], + selected["title_search"], + canonical_json(selected["created_at"]), + canonical_json(selected), + ), + ) + + def add_duplicate( + self, torrent: dict[str, Any], duplicate_of: str | None = None + ) -> None: + row = { + "mam_id": torrent["mam_id"], + "dl_link": torrent.get("dl_link"), + "title_search": torrent["title_search"], + "meta": torrent["meta"], + "created_at": datetime.now(UTC).isoformat(), + "duplicate_of": duplicate_of, + } + with connect(self.path) as connection: + connection.execute( + """INSERT INTO duplicate_torrents + (mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?) + ON CONFLICT(mam_id) DO UPDATE SET + payload_json=excluded.payload_json""", + ( + row["mam_id"], + row["title_search"], + canonical_json(row["created_at"]), + canonical_json(row), + ), + ) + + def torrent(self, torrent_id: str) -> dict[str, Any] | None: + with connect(self.path) as connection: + row = connection.execute( + "SELECT payload_json FROM torrents WHERE id = ?", (torrent_id,) + ).fetchone() + return json.loads(row[0]) if row else None + + def library_torrents(self) -> list[dict[str, Any]]: + with connect(self.path) as connection: + rows = connection.execute( + """SELECT payload_json FROM torrents + WHERE json_extract(payload_json, '$.library_path') IS NOT NULL + ORDER BY title_search""" + ) + return [json.loads(row[0]) for row in rows] + + def record_linked( + self, torrent: dict[str, Any], selected_mam_id: int | None + ) -> None: + event = { + "id": str(uuid4()), + "torrent_id": torrent["id"], + "mam_id": torrent["mam_id"], + "created_at": datetime.now(UTC).isoformat(), + "event": { + "Linked": { + "linker": torrent.get("linker"), + "library_path": torrent.get("library_path"), + } + }, + } + with connect(self.path) as connection, connection: + connection.execute( + "DELETE FROM torrents WHERE mam_id = ? AND id <> ?", + (torrent["mam_id"], torrent["id"]), + ) + connection.execute( + """INSERT INTO torrents + (id, mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + mam_id=excluded.mam_id, + title_search=excluded.title_search, + payload_json=excluded.payload_json""", + ( + torrent["id"], + torrent["mam_id"], + torrent["title_search"], + canonical_json(torrent["created_at"]), + canonical_json(torrent), + ), + ) + if selected_mam_id is not None: + connection.execute( + "DELETE FROM selected_torrents WHERE mam_id = ?", + (selected_mam_id,), + ) + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(event["id"]), + event["torrent_id"], + event["mam_id"], + canonical_json(event["created_at"]), + canonical_json(event), + ), + ) + connection.execute( + "DELETE FROM errored_torrents WHERE id_json = ?", + (canonical_json({"Organizer": torrent["id"]}),), + ) + + def update_torrent(self, torrent: dict[str, Any]) -> None: + with connect(self.path) as connection: + connection.execute( + """UPDATE torrents SET mam_id=?, title_search=?, payload_json=? + WHERE id=?""", + ( + torrent["mam_id"], + torrent["title_search"], + canonical_json(torrent), + torrent["id"], + ), + ) + + def mark_removed_from_mam(self, torrent: dict[str, Any]) -> None: + if torrent.get("client_status") == "RemovedFromMam": + return + torrent = dict(torrent) + torrent["client_status"] = "RemovedFromMam" + event = { + "id": str(uuid4()), + "torrent_id": torrent["id"], + "mam_id": torrent["mam_id"], + "created_at": datetime.now(UTC).isoformat(), + "event": "RemovedFromMam", + } + with connect(self.path) as connection: + connection.execute( + "UPDATE torrents SET payload_json=? WHERE id=?", + (canonical_json(torrent), torrent["id"]), + ) + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(event["id"]), + event["torrent_id"], + event["mam_id"], + canonical_json(event["created_at"]), + canonical_json(event), + ), + ) + + def upsert_list(self, row: dict[str, Any]) -> None: + with connect(self.path) as connection: + connection.execute( + """INSERT INTO lists(id, title, payload_json) VALUES (?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title=excluded.title, payload_json=excluded.payload_json""", + (row["id"], row["title"], canonical_json(row)), + ) + + def list_item(self, guid: list[str]) -> dict[str, Any] | None: + with connect(self.path) as connection: + row = connection.execute( + "SELECT payload_json FROM list_items WHERE guid_json = ?", + (canonical_json(guid),), + ).fetchone() + return json.loads(row[0]) if row else None + + def list_items_for_list(self, list_id: str) -> list[dict[str, Any]]: + with connect(self.path) as connection: + rows = connection.execute( + "SELECT payload_json FROM list_items WHERE list_id = ?", + (list_id,), + ) + return [json.loads(row[0]) for row in rows] + + def list_item_rows( + self, + *, + list_id: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[dict[str, Any]]: + query = "SELECT payload_json FROM list_items" + parameters: tuple[Any, ...] + if list_id: + query += " WHERE list_id = ?" + parameters = (list_id, limit, offset) + else: + parameters = (limit, offset) + query += " ORDER BY created_at_json DESC LIMIT ? OFFSET ?" + with connect(self.path) as connection: + rows = connection.execute(query, parameters) + return [json.loads(row[0]) for row in rows] + + def list_item_count(self, list_id: str | None = None) -> int: + query = "SELECT COUNT(*) FROM list_items" + parameters: tuple[Any, ...] = () + if list_id: + query += " WHERE list_id = ?" + parameters = (list_id,) + with connect(self.path) as connection: + return int(connection.execute(query, parameters).fetchone()[0]) + + def list_item_counts_by_list(self) -> dict[str, int]: + with connect(self.path) as connection: + rows = connection.execute( + "SELECT list_id, COUNT(*) FROM list_items GROUP BY list_id" + ) + return {str(row[0]): int(row[1]) for row in rows} + + def upsert_list_item(self, row: dict[str, Any]) -> None: + with connect(self.path) as connection: + connection.execute( + """INSERT INTO list_items + (guid_json, list_id, title, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(guid_json) DO UPDATE SET + title=excluded.title, payload_json=excluded.payload_json""", + ( + canonical_json(row["guid"]), + row["list_id"], + row["title"], + canonical_json(row["created_at"]), + canonical_json(row), + ), + ) + + def list_tracking_counts(self) -> dict[str, int]: + with connect(self.path) as connection: + rows = connection.execute( + """SELECT COALESCE(json_extract(payload_json, '$.status'), 'legacy'), + COUNT(*) + FROM list_items GROUP BY 1""" + ) + return {str(row[0]): int(row[1]) for row in rows} + + def table_rows( + self, table: str, *, limit: int = 50, offset: int = 0 + ) -> list[dict[str, Any]]: + allowed = { + "torrents", + "selected_torrents", + "duplicate_torrents", + "errored_torrents", + "events", + "lists", + "list_items", + } + if table not in allowed: + raise ValueError(f"unsupported table: {table}") + order = "created_at_json DESC" if table != "lists" else "title" + with connect(self.path) as connection: + rows = connection.execute( + f"SELECT payload_json FROM {table} ORDER BY {order} LIMIT ? OFFSET ?", + (limit, offset), + ) + return [json.loads(row[0]) for row in rows] + + def ui_snapshot(self) -> dict[str, Any]: + tables = ( + "torrents", + "selected_torrents", + "duplicate_torrents", + "errored_torrents", + "events", + "lists", + "list_items", + "requests", + ) + with connect(self.path) as connection: + counts = { + table: int( + connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + ) + for table in tables + } + pipeline_row = connection.execute( + """SELECT + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN 1 ELSE 0 END), + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NOT NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN 1 ELSE 0 END), + SUM(CASE + WHEN json_extract(payload_json, '$.started_at') IS NOT NULL + AND json_extract(payload_json, '$.removed_at') IS NULL + THEN COALESCE( + CAST(json_extract(payload_json, '$.meta.size') AS INTEGER), 0 + ) ELSE 0 END) + FROM selected_torrents""" + ).fetchone() + tracking_rows = connection.execute( + """SELECT COALESCE(json_extract(payload_json, '$.status'), 'legacy'), + COUNT(*) + FROM list_items GROUP BY 1""" + ) + tracking = {str(row[0]): int(row[1]) for row in tracking_rows} + return { + "counts": counts, + "pipeline": { + "awaiting": int(pipeline_row[0] or 0), + "downloading": int(pipeline_row[1] or 0), + "downloading_bytes": int(pipeline_row[2] or 0), + }, + "list_tracking": tracking, + "request_tracking": self.request_counts(), + } + + def counts(self) -> dict[str, int]: + return self.ui_snapshot()["counts"] + + def delete_selected(self, mam_id: int) -> None: + with connect(self.path) as connection: + connection.execute( + "DELETE FROM selected_torrents WHERE mam_id = ?", (mam_id,) + ) + + def delete_error(self, id_json: Any) -> None: + with connect(self.path) as connection: + connection.execute( + "DELETE FROM errored_torrents WHERE id_json = ?", + (canonical_json(id_json),), + ) + + def dismiss_all_errors(self) -> int: + with connect(self.path) as connection: + cursor = connection.execute("DELETE FROM errored_torrents") + return max(0, int(cursor.rowcount)) + + def add_metadata_torrent(self, meta: dict[str, Any], linker: str | None) -> str: + torrent_id = str(uuid4()) + now = datetime.now(UTC).isoformat() + row = { + "id": torrent_id, + "id_is_hash": False, + "mam_id": meta["mam_id"], + "abs_id": None, + "goodreads_id": None, + "library_path": None, + "library_files": [], + "linker": linker, + "category": None, + "selected_audio_format": None, + "selected_ebook_format": None, + "title_search": normalize_title(meta["title"]), + "meta": meta, + "created_at": now, + "replaced_with": None, + "request_matadata_update": False, + "library_mismatch": None, + "client_status": None, + } + with connect(self.path) as connection: + connection.execute( + """INSERT INTO torrents + (id, mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + row["id"], + row["mam_id"], + row["title_search"], + canonical_json(now), + canonical_json(row), + ), + ) + return torrent_id + + def record_started( + self, selected: dict[str, Any], torrent_hash: str, *, wedged: bool = False + ) -> None: + now = datetime.now(UTC).isoformat() + selected = dict(selected) + selected["hash"] = torrent_hash + selected["started_at"] = now + torrent = { + "id": torrent_hash, + "id_is_hash": True, + "mam_id": selected["mam_id"], + "abs_id": None, + "goodreads_id": selected.get("goodreads_id"), + "library_path": None, + "library_files": [], + "linker": None, + "category": selected.get("category"), + "selected_audio_format": None, + "selected_ebook_format": None, + "title_search": selected["title_search"], + "meta": selected.get("meta", {}), + "created_at": now, + "replaced_with": None, + "request_matadata_update": False, + "library_mismatch": None, + "client_status": None, + } + event = { + "id": str(uuid4()), + "torrent_id": torrent_hash, + "mam_id": selected["mam_id"], + "created_at": now, + "event": { + "Grabbed": { + "grabber": selected.get("grabber"), + "cost": selected.get("cost"), + "wedged": wedged, + } + }, + } + with connect(self.path) as connection, connection: + connection.execute( + """UPDATE selected_torrents + SET hash = ?, payload_json = ? WHERE mam_id = ?""", + (torrent_hash, canonical_json(selected), selected["mam_id"]), + ) + connection.execute( + """INSERT INTO torrents + (id, mam_id, title_search, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + payload_json=excluded.payload_json""", + ( + torrent_hash, + selected["mam_id"], + selected["title_search"], + canonical_json(now), + canonical_json(torrent), + ), + ) + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(event["id"]), + torrent_hash, + selected["mam_id"], + canonical_json(now), + canonical_json(event), + ), + ) + connection.execute( + "DELETE FROM errored_torrents WHERE id_json = ?", + (canonical_json({"Grabber": selected["mam_id"]}),), + ) + + def record_grab_error( + self, + selected: dict[str, Any], + error: Exception, + *, + context: dict[str, Any] | None = None, + ) -> None: + now = datetime.now(UTC).isoformat() + identifier = {"Grabber": selected["mam_id"]} + row = { + "id": identifier, + "title": selected.get("meta", {}).get("title", selected["title_search"]), + "error": str(error), + "meta": selected.get("meta"), + "context": context or {}, + "created_at": now, + } + with connect(self.path) as connection: + connection.execute( + """INSERT INTO errored_torrents + (id_json, created_at_json, payload_json) VALUES (?, ?, ?) + ON CONFLICT(id_json) DO UPDATE SET + created_at_json=excluded.created_at_json, + payload_json=excluded.payload_json""", + (canonical_json(identifier), canonical_json(now), canonical_json(row)), + ) + + def record_organizer_error( + self, + torrent_hash: str, + title: str, + error: str, + context: dict[str, Any], + ) -> None: + now = datetime.now(UTC).isoformat() + identifier = {"Organizer": torrent_hash} + row = { + "id": identifier, + "title": title, + "error": error, + "context": context, + "created_at": now, + } + with connect(self.path) as connection: + connection.execute( + """INSERT INTO errored_torrents + (id_json, created_at_json, payload_json) VALUES (?, ?, ?) + ON CONFLICT(id_json) DO UPDATE SET + created_at_json=excluded.created_at_json, + payload_json=excluded.payload_json""", + (canonical_json(identifier), canonical_json(now), canonical_json(row)), + ) diff --git a/python/src/mlm/request_auth.py b/python/src/mlm/request_auth.py new file mode 100644 index 00000000..39ce28a4 --- /dev/null +++ b/python/src/mlm/request_auth.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import secrets + +PASSWORD_SCHEME = "pbkdf2_sha256" +PASSWORD_ITERATIONS = 600_000 +MIN_PASSWORD_LENGTH = 8 + + +def hash_request_password(password: str) -> str: + """Return a salted password hash suitable for storage in config.toml.""" + if len(password) < MIN_PASSWORD_LENGTH: + raise ValueError( + f"requester password must contain at least {MIN_PASSWORD_LENGTH} characters" + ) + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, PASSWORD_ITERATIONS + ) + return "$".join( + ( + PASSWORD_SCHEME, + str(PASSWORD_ITERATIONS), + base64.urlsafe_b64encode(salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + ) + + +def verify_request_password(password: str, encoded: str) -> bool: + """Verify a requester password without exposing parsing failures.""" + try: + scheme, iterations_text, salt_text, expected_text = encoded.split("$", 3) + if scheme != PASSWORD_SCHEME: + return False + iterations = int(iterations_text) + if iterations < 1 or iterations > 2_000_000: + return False + salt = base64.urlsafe_b64decode(salt_text.encode("ascii")) + expected = base64.urlsafe_b64decode(expected_text.encode("ascii")) + actual = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, iterations + ) + except (binascii.Error, ValueError, TypeError): + return False + return hmac.compare_digest(actual, expected) diff --git a/python/src/mlm/request_portal.py b/python/src/mlm/request_portal.py new file mode 100644 index 00000000..2fe2c7b1 --- /dev/null +++ b/python/src/mlm/request_portal.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import html +import json +import re +from typing import Any +from urllib.parse import urljoin, urlparse + +import httpx + +from .mam import USER_AGENT + +GOODREADS_HOSTS = {"goodreads.com", "www.goodreads.com"} +GOODREADS_BOOK_PATH = re.compile(r"/(?:[a-z]{2}/)?book/show/(\d+)") +NEXT_DATA = re.compile( + r'<script[^>]+id=["\']__NEXT_DATA__["\'][^>]*>(.*?)</script>', + re.IGNORECASE | re.DOTALL, +) +META_TAG = re.compile(r"<meta\s+([^>]+)>", re.IGNORECASE) +ATTRIBUTE = re.compile( + r"([:\w-]+)\s*=\s*(?:[\"']([^\"']*)[\"']|([^\s>]+))", + re.IGNORECASE, +) +TITLE_TAG = re.compile(r"<title[^>]*>(.*?)", re.IGNORECASE | re.DOTALL) +SERIES_SUFFIX = re.compile(r"^(.*?)\s+\((.*?)(?:,\s*)?#\s*([\d.]+)\)\s*$") + + +class GoodreadsLookupError(RuntimeError): + pass + + +def validate_goodreads_url(value: str) -> str: + url = value.strip() + parsed = urlparse(url) + if ( + parsed.scheme != "https" + or (parsed.hostname or "").casefold() not in GOODREADS_HOSTS + ): + raise GoodreadsLookupError( + "enter a full https://www.goodreads.com/book/show/... URL" + ) + if not GOODREADS_BOOK_PATH.search(parsed.path): + raise GoodreadsLookupError("the Goodreads URL must point to a book page") + return url + + +def goodreads_book_id(value: str) -> int: + validated = validate_goodreads_url(value) + match = GOODREADS_BOOK_PATH.search(urlparse(validated).path) + if not match: # pragma: no cover - validation above guarantees this + raise GoodreadsLookupError("the Goodreads URL must point to a book page") + return int(match.group(1)) + + +def _reference(state: dict[str, Any], value: Any) -> dict[str, Any]: + if isinstance(value, dict) and isinstance(value.get("__ref"), str): + resolved = state.get(value["__ref"]) + return resolved if isinstance(resolved, dict) else {} + return value if isinstance(value, dict) else {} + + +def _next_data_book(document: str, book_id: int) -> dict[str, Any]: + match = NEXT_DATA.search(document) + if not match: + return {} + try: + payload = json.loads(match.group(1)) + state = payload["props"]["pageProps"]["apolloState"] + except (KeyError, TypeError, json.JSONDecodeError): + return {} + if not isinstance(state, dict): + return {} + book = next( + ( + value + for key, value in state.items() + if key.startswith("Book:") + and isinstance(value, dict) + and str(value.get("legacyId")) == str(book_id) + and value.get("title") + ), + None, + ) + if not book: + return {} + + contributor_edge = book.get("primaryContributorEdge") + contributor = _reference( + state, + contributor_edge.get("node") if isinstance(contributor_edge, dict) else None, + ) + authors = [str(contributor.get("name", "")).strip()] if contributor else [] + authors = [author for author in authors if author] + + series_name = "" + series_position = "" + book_series = book.get("bookSeries") + if isinstance(book_series, list) and book_series: + edge = book_series[0] if isinstance(book_series[0], dict) else {} + series = _reference(state, edge.get("series")) + series_name = str(series.get("title") or series.get("name") or "").strip() + series_position = str(edge.get("userPosition") or "").strip() + + details = book.get("details") if isinstance(book.get("details"), dict) else {} + language_value = details.get("language") + language = ( + str(language_value.get("name", "")).strip() + if isinstance(language_value, dict) + else "" + ) + title = str(book.get("title", "")).strip() + title_complete = str(book.get("titleComplete", "")).strip() + if not series_name and title_complete: + series_match = SERIES_SUFFIX.match(title_complete) + if series_match: + title = title or series_match.group(1).strip() + series_name = series_match.group(2).strip() + series_position = series_match.group(3).strip() + return { + "goodreads_id": book_id, + "title": title, + "authors": authors, + "series": series_name, + "series_position": series_position, + "language": language, + "isbn": str(details.get("isbn13") or details.get("isbn") or "").strip(), + "cover_url": str(book.get("imageUrl") or "").strip(), + "format": str(details.get("format") or "").strip(), + } + + +def _meta_values(document: str) -> dict[str, str]: + values: dict[str, str] = {} + for tag in META_TAG.findall(document): + attributes = { + name.casefold(): html.unescape(quoted or bare) + for name, quoted, bare in ATTRIBUTE.findall(tag) + } + key = attributes.get("property") or attributes.get("name") + if key and attributes.get("content"): + values[key.casefold()] = attributes["content"].strip() + return values + + +def parse_goodreads_book(document: str, url: str) -> dict[str, Any]: + match = GOODREADS_BOOK_PATH.search(urlparse(url).path) + if not match: + raise GoodreadsLookupError("Goodreads did not return a recognizable book URL") + book_id = int(match.group(1)) + book = _next_data_book(document, book_id) + meta = _meta_values(document) + page_title = meta.get("og:title", "") + if not page_title: + title_match = TITLE_TAG.search(document) + page_title = ( + html.unescape(re.sub(r"<[^>]+>", "", title_match.group(1))).strip() + if title_match + else "" + ) + title_author = re.match(r"^(.*?)\s+by\s+(.*?)\s*\|\s*Goodreads", page_title) + if not book.get("title") and title_author: + complete_title = title_author.group(1).strip() + series_match = SERIES_SUFFIX.match(complete_title) + if series_match: + book.update( + title=series_match.group(1).strip(), + series=series_match.group(2).strip(), + series_position=series_match.group(3).strip(), + ) + else: + book["title"] = complete_title + if not book.get("authors") and title_author: + book["authors"] = [title_author.group(2).strip()] + book.setdefault("goodreads_id", book_id) + book.setdefault("series", "") + book.setdefault("series_position", "") + book.setdefault("language", "") + book.setdefault("isbn", "") + book.setdefault("format", "") + book["cover_url"] = book.get("cover_url") or meta.get("og:image", "") + book["url"] = url + if not book.get("title"): + raise GoodreadsLookupError("Goodreads returned the page, but no book title") + return book + + +async def lookup_goodreads_book( + url: str, + *, + client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + current = validate_goodreads_url(url) + owns_client = client is None + http = client or httpx.AsyncClient( + headers={"User-Agent": USER_AGENT}, + timeout=20, + ) + try: + for _ in range(4): + response = await http.get(current, follow_redirects=False) + if response.is_redirect: + location = response.headers.get("location") + if not location: + raise GoodreadsLookupError("Goodreads returned an empty redirect") + current = validate_goodreads_url(urljoin(current, location)) + continue + response.raise_for_status() + content_type = response.headers.get("content-type", "") + if "html" not in content_type.casefold(): + raise GoodreadsLookupError( + "Goodreads returned unexpected content " + f"({content_type or 'unknown'})" + ) + return parse_goodreads_book(response.text, current) + raise GoodreadsLookupError("Goodreads redirected too many times") + except httpx.HTTPError as error: + raise GoodreadsLookupError( + f"could not read the Goodreads page: {error}" + ) from error + finally: + if owns_client: + await http.aclose() diff --git a/python/src/mlm/scheduler.py b/python/src/mlm/scheduler.py new file mode 100644 index 00000000..614b1043 --- /dev/null +++ b/python/src/mlm/scheduler.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable +from dataclasses import asdict, dataclass, field, is_dataclass +from datetime import UTC, datetime +from typing import Any + +from .audiobookshelf import AudiobookshelfClient, match_torrents_to_audiobookshelf +from .autograbber import run_autograbber +from .cleaner import clean_superseded +from .config import Config +from .downloader import grab_selected_torrents +from .library import organize_completed +from .lists import run_goodreads_import, run_notion_import +from .mam import MamClient +from .modules.mam_spender import MamSpenderService +from .qbittorrent import QbitClient +from .repository import Repository +from .snatchlist import run_snatchlist_search + + +@dataclass +class JobStatus: + last_run: str | None = None + last_error: str | None = None + last_result: object | None = None + running: bool = False + progress: list[dict[str, object]] = field(default_factory=list) + + +@dataclass +class ServiceState: + config: Config + repository: Repository + mam: MamClient + absidekick: Any | None = None + jobs: dict[str, JobStatus] = field(default_factory=dict) + mam_stats: dict[str, int] = field(default_factory=dict) + tasks: list[asyncio.Task] = field(default_factory=list) + stop_event: asyncio.Event = field(default_factory=asyncio.Event) + mam_spender: MamSpenderService = field(init=False) + + def __post_init__(self) -> None: + self.mam_spender = MamSpenderService(self.repository, self.mam) + + def _module_active(self, name: str) -> bool: + normalized = name.strip().replace("-", "_").lower() + active = { + item.strip().replace("-", "_").lower() + for item in self.config.enabled_modules + } + return normalized in active + + async def _trigger_absidekick_sync(self, linked_count: int) -> None: + if not self.absidekick: + return + try: + res = self.absidekick.auto_sync_library() + if res.get("ok"): + self.repository.log_activity( + "absidekick", + ( + f"Auto-sync triggered: {linked_count} new book(s) " + "organized, initiated ABSidekick library scan and " + "auto-matching." + ), + level="success", + ) + else: + self.repository.log_activity( + "absidekick", + f"Auto-sync skipped: {res.get('message', 'No library selected')}", + level="info", + ) + except Exception as error: # noqa: BLE001 + self.repository.log_activity( + "absidekick", + f"Auto-sync failed: {error}", + level="warning", + context={"error": str(error)}, + ) + + def report_progress( + self, + name: str, + message: str, + *, + level: str = "info", + context: dict[str, object] | None = None, + ) -> None: + status = self.jobs.setdefault(name, JobStatus()) + status.progress.append( + { + "created_at": datetime.now(UTC).isoformat(), + "level": level, + "message": message, + "context": context or {}, + } + ) + del status.progress[:-150] + + async def run_job(self, name: str, job: Callable[[], Awaitable[object]]) -> None: + status = self.jobs.setdefault(name, JobStatus()) + if status.running: + return + status.running = True + status.last_run = datetime.now(UTC).isoformat() + status.last_error = None + status.last_result = None + status.progress.clear() + self.report_progress(name, "Job started", context={"job": name}) + self.repository.log_activity( + "scheduler", f"Started job: {name}", context={"job": name} + ) + try: + result = await job() + status.last_result = asdict(result) if is_dataclass(result) else result + self.report_progress( + name, + "Job completed", + level="success", + context={"result": status.last_result}, + ) + self.repository.log_activity( + "scheduler", + f"Completed job: {name}", + level="success", + context={"job": name, "result": status.last_result}, + ) + except asyncio.CancelledError: + raise + except Exception as error: # noqa: BLE001 - jobs must not stop the scheduler + status.last_error = f"{type(error).__name__}: {error}" + self.report_progress( + name, + f"Job failed: {status.last_error}", + level="error", + ) + self.repository.log_activity( + "scheduler", + f"Job failed: {name}", + level="error", + context={"job": name, "error": status.last_error}, + ) + finally: + status.running = False + + async def _periodic( + self, + name: str, + interval_minutes: int, + job: Callable[[], Awaitable[object]], + ) -> None: + while not self.stop_event.is_set(): + await self.run_job(name, job) + with contextlib.suppress(TimeoutError): + await asyncio.wait_for( + self.stop_event.wait(), timeout=max(1, interval_minutes * 60) + ) + + async def _qbit(self, index: int) -> tuple[QbitClient, object]: + qbit_config = self.config.qbittorrent[index] + qbit = QbitClient(qbit_config.url) + await qbit.login(qbit_config.username, qbit_config.password) + return qbit, qbit_config + + async def downloader(self) -> object: + if not self.config.qbittorrent: + return {"downloaded": 0, "failed": 0, "skipped": 0} + qbits: list[QbitClient] = [] + try: + for index in range(len(self.config.qbittorrent)): + qbit, _ = await self._qbit(index) + qbits.append(qbit) + result = await grab_selected_torrents( + self.config, + self.repository, + self.mam, + qbits[0], + other_qbits=qbits[1:], + ) + self.mam_stats = { + "slots_used": result.slots_used, + "slots_total": result.slots_total, + "slot_cap": result.slot_cap, + "wedges": result.wedges_remaining, + "wedge_buffer": result.wedge_buffer, + } + return result + finally: + for qbit in qbits: + await qbit.close() + + async def organizer(self, index: int) -> object: + job_name = f"organizer:{index}" + qbit, qbit_config = await self._qbit(index) + try: + result = await organize_completed( + self.config, + self.repository, + qbit_config, + qbit, + self.mam, + progress=lambda message, level="info", context=None: ( + self.report_progress( + job_name, message, level=level, context=context + ) + ), + ) + if ( + getattr(result, "linked", 0) > 0 + and self.config.absidekick_auto_sync + and self._module_active("absidekick") + ): + await self._trigger_absidekick_sync(result.linked) + return result + finally: + await qbit.close() + + async def cleaner(self) -> None: + clients: list[tuple[dict, QbitClient]] = [] + try: + for index in range(len(self.config.qbittorrent)): + qbit, qbit_config = await self._qbit(index) + clients.append((asdict(qbit_config), qbit)) + await clean_superseded(self.config, self.repository, clients) + finally: + for _, qbit in clients: + await qbit.close() + + async def audiobookshelf(self) -> None: + definition = self.config.audiobookshelf + if not definition: + return + client = AudiobookshelfClient(definition["url"], definition["token"]) + try: + await match_torrents_to_audiobookshelf(self.repository, client) + finally: + await client.close() + + async def refresh_lists(self) -> dict[str, int]: + refreshed = selected = failed = 0 + for definition in self.config.goodreads_lists: + try: + result = await run_goodreads_import( + self.config, self.repository, self.mam, definition + ) + refreshed += result.refreshed + selected += result.selected + except Exception as error: # noqa: BLE001 - continue with other lists + failed += 1 + self.repository.log_activity( + "lists", + "Goodreads refresh failed", + level="error", + context={"error": f"{type(error).__name__}: {error}"}, + ) + for definition in self.config.notion_lists: + try: + selected += await run_notion_import( + self.config, self.repository, self.mam, definition + ) + refreshed += 1 + except Exception as error: # noqa: BLE001 - continue with other lists + failed += 1 + self.repository.log_activity( + "lists", + "Notion refresh failed", + level="error", + context={"error": f"{type(error).__name__}: {error}"}, + ) + return {"refreshed": refreshed, "selected": selected, "failed": failed} + + def start(self) -> None: + if self._module_active("mam_spender"): + self.mam_spender.start() + if not self._module_active("heavymlm"): + return + for index, rule in enumerate(self.config.autograbs): + interval = int(rule.get("search_interval") or self.config.search_interval) + name = f"autograb:{index}" + self.tasks.append( + asyncio.create_task( + self._periodic( + name, + interval, + lambda rule=rule, index=index: run_autograbber( + self.config, + self.repository, + self.mam, + rule, + index=index, + ), + ), + name=name, + ) + ) + for index, rule in enumerate(self.config.goodreads_lists): + interval = int(rule.get("search_interval") or self.config.import_interval) + name = f"goodreads:{index}" + self.tasks.append( + asyncio.create_task( + self._periodic( + name, + interval, + lambda rule=rule: run_goodreads_import( + self.config, self.repository, self.mam, rule + ), + ), + name=name, + ) + ) + for index, rule in enumerate(self.config.notion_lists): + interval = int(rule.get("search_interval") or self.config.import_interval) + name = f"notion:{index}" + self.tasks.append( + asyncio.create_task( + self._periodic( + name, + interval, + lambda rule=rule: run_notion_import( + self.config, self.repository, self.mam, rule + ), + ), + name=name, + ) + ) + for index, rule in enumerate(self.config.snatchlist): + interval = int(rule.get("search_interval") or self.config.search_interval) + name = f"snatchlist:{index}" + self.tasks.append( + asyncio.create_task( + self._periodic( + name, + interval, + lambda rule=rule: run_snatchlist_search( + self.config, self.repository, self.mam, rule + ), + ), + name=name, + ) + ) + self.tasks.append( + asyncio.create_task( + self._periodic("downloader", 1, self.downloader), name="downloader" + ) + ) + if self.config.audiobookshelf: + self.tasks.append( + asyncio.create_task( + self._periodic( + "audiobookshelf", + int(self.config.audiobookshelf.get("interval", 10)), + self.audiobookshelf, + ), + name="audiobookshelf", + ) + ) + for index in range(len(self.config.qbittorrent)): + self.tasks.append( + asyncio.create_task( + self._periodic( + f"organizer:{index}", + self.config.link_interval, + lambda index=index: self.organizer(index), + ), + name=f"organizer:{index}", + ) + ) + self.tasks.append( + asyncio.create_task( + self._periodic("cleaner", self.config.link_interval, self.cleaner), + name="cleaner", + ) + ) + + async def reconfigure(self, config: Config) -> None: + for task in self.tasks: + task.cancel() + for task in self.tasks: + with contextlib.suppress(asyncio.CancelledError): + await task + self.tasks.clear() + self.stop_event = asyncio.Event() + if config.mam_id != self.config.mam_id: + self.mam.set_mam_id(config.mam_id) + self.config = config + self.repository.log_activity( + "configuration", + "Applied configuration without restarting MyAnonaSuite", + level="success", + context={ + "min_ratio": config.min_ratio, + "max_unsat_slots": config.max_unsat_slots, + "unsat_buffer": config.unsat_buffer, + "prefer_wedges": config.prefer_wedges, + "download_on_wedge_failure": config.download_on_wedge_failure, + "wedge_buffer": config.wedge_buffer, + "grab_both_formats": config.grab_both_formats, + "qbittorrent_clients": len(config.qbittorrent), + "autograbs": len(config.autograbs), + "goodreads_lists": len(config.goodreads_lists), + "notion_lists": len(config.notion_lists), + "libraries": len(config.libraries), + "request_portal_enabled": config.request_portal_enabled, + "request_portal_require_account_login": ( + config.request_portal_require_account_login + ), + "request_portal_domains": list(config.request_portal_domains), + "request_portal_accounts": len(config.request_portal_users), + }, + ) + self.start() + + async def close(self) -> None: + self.stop_event.set() + for task in self.tasks: + task.cancel() + for task in self.tasks: + with contextlib.suppress(asyncio.CancelledError): + await task + await self.mam_spender.close() + await self.mam.close() + + async def trigger(self, name: str) -> None: + if name == "downloader": + await self.run_job(name, self.downloader) + elif name == "cleaner": + await self.run_job(name, self.cleaner) + elif name == "organizer": + for index in range(len(self.config.qbittorrent)): + await self.run_job( + f"organizer:{index}", lambda index=index: self.organizer(index) + ) + elif name == "autograb": + for index, rule in enumerate(self.config.autograbs): + await self.run_job( + f"autograb:{index}", + lambda rule=rule, index=index: run_autograbber( + self.config, + self.repository, + self.mam, + rule, + index=index, + ), + ) + elif name == "lists": + await self.run_job("lists:manual", self.refresh_lists) + else: + raise ValueError(f"unknown job: {name}") diff --git a/python/src/mlm/search.py b/python/src/mlm/search.py new file mode 100644 index 00000000..0510951f --- /dev/null +++ b/python/src/mlm/search.py @@ -0,0 +1,447 @@ +from __future__ import annotations + +import html +import json +import re +import unicodedata +from collections.abc import AsyncIterator +from datetime import date +from typing import Any + +from .mam import MamClient + +FLAG_BITS = { + "crude_language": 1 << 1, + "crude": 1 << 1, + "language": 1 << 1, + "violence": 1 << 2, + "some_explicit": 1 << 3, + "explicit": 1 << 4, + "abridged": 1 << 5, + "lgbt": 1 << 6, +} + +MEDIA_TYPE_BY_ID = { + 1: "audiobook", + 2: "ebook", + 3: "musicology", + 4: "radio", + 5: "manga", + 6: "comic_book", + 7: "periodical_ebook", + 8: "periodical_audiobook", +} + +MAIN_CATEGORY_BY_ID = {13: "audiobook", 14: "ebook", 15: "musicology", 16: "radio"} + +LANGUAGE_BY_ID = { + 1: "english", + 2: "chinese", + 3: "gujarati", + 4: "spanish", + 5: "kannada", + 6: "burmese", + 7: "thai", + 8: "hindi", + 9: "marathi", + 10: "telugu", + 11: "tamil", + 12: "javanese", + 13: "vietnamese", + 14: "punjabi", + 15: "urdu", + 16: "russian", + 17: "afrikaans", + 18: "bulgarian", + 19: "catalan", + 20: "czech", + 21: "danish", + 22: "dutch", + 23: "finnish", + 24: "scottish", + 25: "ukrainian", + 26: "greek", + 27: "hebrew", + 28: "hungarian", + 29: "tagalog", + 30: "romanian", + 31: "serbian", + 32: "arabic", + 33: "malay", + 34: "portuguese", + 35: "bengali", + 36: "french", + 37: "german", + 38: "japanese", + 39: "farsi", + 40: "swedish", + 41: "korean", + 42: "turkish", + 43: "italian", + 44: "cantonese", + 45: "polish", + 46: "latin", + 47: "other", + 48: "norwegian", + 49: "croatian", + 50: "lithuanian", + 51: "bosnian", + 52: "brazilian", + 53: "indonesian", + 54: "slovenian", + 55: "castilian", + 56: "irish", + 57: "manx", + 58: "malayalam", + 59: "ancient greek", + 60: "sanskrit", + 61: "estonian", + 62: "latvian", + 63: "icelandic", +} + +SIZE_UNITS = { + "b": 1, + "kb": 1_000, + "kib": 1 << 10, + "mb": 1_000_000, + "mib": 1 << 20, + "gb": 1_000_000_000, + "gib": 1 << 30, + "tb": 1_000_000_000_000, + "tib": 1 << 40, +} + + +def as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def as_bool(value: Any) -> bool: + if isinstance(value, str): + return value.strip().lower() not in {"", "0", "false", "no", "null"} + return bool(value) + + +def parse_size(value: Any) -> int: + if isinstance(value, (int, float)): + return int(value) + normalized = str(value).replace(",", "") + match = re.fullmatch(r"\s*([\d.]+)\s*([kmgt]?i?b)?\s*", normalized, re.IGNORECASE) + if not match: + raise ValueError(f"invalid size: {value!r}") + return int( + float(match.group(1)) * SIZE_UNITS.get((match.group(2) or "b").lower(), 1) + ) + + +def normalize_title(value: str) -> str: + ascii_title = ( + unicodedata.normalize("NFKD", html.unescape(value)) + .encode("ascii", "ignore") + .decode() + .lower() + .replace(" & ", " and ") + ) + ascii_title = re.sub(r"^(the|a|an)\s+|[^\w ]", "", ascii_title) + return re.sub(r"(?i)(volume|vol\.)", "", ascii_title) + + +def _nested_json(value: Any) -> Any: + if not isinstance(value, str): + return value + stripped = value.strip() + if not stripped: + return None + try: + return json.loads(stripped) + except (TypeError, ValueError): + return value + + +def decode_name_mapping(value: Any) -> list[str]: + value = _nested_json(value) + if isinstance(value, dict): + items = sorted( + value.items(), + key=lambda item: ( + ( + 0, + int(item[0]), + ) + if str(item[0]).isdigit() + else (1, str(item[0])) + ), + ) + return [ + html.unescape(str(item)).strip() + for _, item in items + if item is not None and str(item).strip() + ] + if isinstance(value, list): + names = [] + for item in value: + if isinstance(item, dict): + item = item.get("name") or item.get("author") or item.get("narrator") + if item is not None and str(item).strip(): + names.append(html.unescape(str(item)).strip()) + return names + return [] + + +def decode_series_info(value: Any) -> list[dict[str, Any]]: + series = [] + raw_series = _nested_json(value) + if not isinstance(raw_series, dict): + return series + for entry in raw_series.values(): + if isinstance(entry, list) and entry: + series.append( + { + "name": html.unescape(str(entry[0])).strip(), + "entries": [ + html.unescape(str(item)).strip() + for item in entry[1:2] + if item is not None and str(item).strip() + ], + } + ) + return series + + +def torrent_meta(row: dict[str, Any]) -> dict[str, Any]: + media_id = as_int(row.get("mediatype")) + main_id = as_int(row.get("main_cat")) + media_type = MEDIA_TYPE_BY_ID.get(media_id) or MAIN_CATEGORY_BY_ID.get( + main_id, "unknown" + ) + series = decode_series_info(row.get("series_info")) + filetypes = [part.lower() for part in str(row.get("filetype", "")).split() if part] + raw_categories = _nested_json(row.get("categories", [])) + if not isinstance(raw_categories, list): + raw_categories = [] + return { + "mam_id": as_int(row.get("id")), + "vip_status": "Permanent" if as_bool(row.get("vip")) else "NotVip", + "cat": {"id": as_int(row.get("category")), "name": str(row.get("catname", ""))}, + "media_type": media_type, + "main_cat": as_int(row.get("maincat")) or None, + "categories": [as_int(value) for value in raw_categories], + "language": LANGUAGE_BY_ID.get(as_int(row.get("language")), "").title() or None, + "flags": as_int(row.get("browseflags")), + "filetypes": filetypes, + "num_files": as_int(row.get("numfiles")), + "size": parse_size(row.get("size", 0)), + "title": html.unescape(str(row.get("title", ""))), + "edition": None, + "authors": decode_name_mapping(row.get("author_info")), + "narrators": decode_name_mapping(row.get("narrator_info")), + "series": series, + "source": "Mam", + "uploaded_at": str(row.get("added", "")), + } + + +def _date(value: Any) -> date: + return date.fromisoformat(str(value)[:10]) + + +def _series_map(meta: dict[str, Any]) -> dict[str, set[str]]: + mapping: dict[str, set[str]] = {} + for item in meta.get("series", []): + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip().casefold() + if not name: + continue + entries = { + str(entry).strip().casefold() + for entry in item.get("entries", []) + if str(entry).strip() + } + mapping.setdefault(name, set()).update(entries) + return mapping + + +def metadata_matches(left: dict[str, Any], right: dict[str, Any]) -> bool: + left_media = str(left.get("media_type", "")).lower() + right_media = str(right.get("media_type", "")).lower() + media_matches = ( + left_media == right_media + or { + left_media, + right_media, + } + == {"ebook", "comicbook"} + or {left_media, right_media} + == { + "ebook", + "comic_book", + } + ) + if not media_matches or left.get("language") != right.get("language"): + return False + + left_edition = left.get("edition") + right_edition = right.get("edition") + if (left_edition is None) != (right_edition is None): + return False + if left_edition is not None: + if left_edition[1] != right_edition[1]: + return False + if left_edition[1] == 0 and left_edition[0] != right_edition[0]: + return False + + left_authors = {str(value).casefold() for value in left.get("authors", [])} + right_authors = {str(value).casefold() for value in right.get("authors", [])} + if not left_authors.intersection(right_authors): + return False + left_narrators = {str(value).casefold() for value in left.get("narrators", [])} + right_narrators = {str(value).casefold() for value in right.get("narrators", [])} + if (left_narrators or right_narrators) and not left_narrators.intersection( + right_narrators + ): + return False + + left_series = _series_map(left) + right_series = _series_map(right) + if left_series and right_series: + common_series = set(left_series.keys()).intersection(right_series.keys()) + if not common_series: + return False + for name in common_series: + left_entries = left_series[name] + right_entries = right_series[name] + if left_entries and right_entries: + if not left_entries.intersection(right_entries): + return False + elif bool(left_entries) != bool(right_entries): + return False + elif bool(left_series) != bool(right_series): + return False + + return True + + +def matches_filter(row: dict[str, Any], rule: dict[str, Any]) -> bool: + media_types = {str(value).lower() for value in rule.get("media_type", [])} + actual_media = MEDIA_TYPE_BY_ID.get(as_int(row.get("mediatype"))) + if media_types and actual_media not in media_types: + return False + + categories = rule.get("categories", {}) + if categories: + main = MAIN_CATEGORY_BY_ID.get(as_int(row.get("main_cat"))) + category_rule = categories.get({"audiobook": "audio"}.get(main, main), False) + if category_rule is False: + return False + if isinstance(category_rule, list): + names = {str(value).lower().replace(" ", "_") for value in category_rule} + actual = ( + str(row.get("catname", row.get("cat", ""))).lower().replace(" ", "_") + ) + if not any(actual == name or actual.endswith(f"_{name}") for name in names): + return False + + languages = {str(value).lower() for value in rule.get("languages", [])} + if languages: + actual_languages = { + str(row.get("lang_code", "")).lower(), + LANGUAGE_BY_ID.get(as_int(row.get("language")), ""), + } + if languages.isdisjoint(actual_languages): + return False + + bitfield = as_int(row.get("browseflags")) + for name, required in rule.get("flags", {}).items(): + bit = FLAG_BITS.get(name.lower().replace(" ", "_")) + if bit is None or bool(bitfield & bit) != bool(required): + return False + + size = parse_size(row.get("size", 0)) + if rule.get("min_size") and size < parse_size(rule["min_size"]): + return False + if rule.get("max_size") and size > parse_size(rule["max_size"]): + return False + if str(row.get("owner_name", "")) in rule.get("exclude_uploader", []): + return False + if rule.get("uploaded_after") and _date(row.get("added")) < _date( + rule["uploaded_after"] + ): + return False + if rule.get("uploaded_before") and _date(row.get("added")) > _date( + rule["uploaded_before"] + ): + return False + + comparisons = { + "seeders": "seeders", + "leechers": "leechers", + "snatched": "times_completed", + } + for label, field in comparisons.items(): + value = as_int(row.get(field)) + minimum = rule.get(f"min_{label}") + maximum = rule.get(f"max_{label}") + if minimum is not None and value < int(minimum): + return False + if maximum is not None and value > int(maximum): + return False + return True + + +def build_search_query(rule: dict[str, Any], start: int = 0) -> dict[str, Any]: + kind = rule.get("type", "new") + target = None + search_type = None + if kind == "bookmarks": + target = "bookmarks" + elif kind == "mine": + target = "mine" + elif isinstance(kind, dict) and "uploader" in kind: + target = f"u{kind['uploader']}" + if kind == "freeleech": + search_type = "fl" + elif rule.get("cost", "free") == "free": + search_type = "fl-VIP" + sort_types = { + "low_seeders": "seedersAsc", + "low_snatches": "snatchedAsc", + "oldest_first": "dateAsc", + "random": "random", + } + tor = { + "text": rule.get("query", ""), + "srchIn": rule.get("search_in", []), + "sortType": sort_types.get( + rule.get("sort_by"), "dateDesc" if kind == "new" else "" + ), + "startNumber": start, + } + if target: + tor["searchIn"] = target + if search_type: + tor["searchType"] = search_type + return {"dlLink": True, "perpage": 100, "tor": {k: v for k, v in tor.items() if v}} + + +async def search_pages( + mam: MamClient, rule: dict[str, Any] +) -> AsyncIterator[dict[str, Any]]: + kind = rule.get("type", "new") + default_pages = 50 if kind in {"bookmarks", "freeleech", "mine"} else 1 + max_pages = int(rule.get("max_pages") or default_pages) + start = 0 + for _ in range(max_pages): + result = await mam.search(build_search_query(rule, start)) + rows = result.get("data", []) + for row in rows: + if isinstance(row, dict): + yield row + start += len(rows) + found = as_int(result.get("found", len(rows))) + if not rows or start >= found: + break diff --git a/python/src/mlm/snatchlist.py b/python/src/mlm/snatchlist.py new file mode 100644 index 00000000..0de47531 --- /dev/null +++ b/python/src/mlm/snatchlist.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import time +from typing import Any + +from .config import Config +from .mam import MamClient +from .repository import Repository +from .search import as_bool, as_int, matches_filter, parse_size + + +def user_torrent_meta(row: dict[str, Any]) -> dict[str, Any]: + authors = [ + str(item.get("name", "")) + for item in sorted( + row.get("author", []), key=lambda item: as_int(item.get("id")) + ) + ] + narrators = [ + str(item.get("name", "")) + for item in sorted( + row.get("narrator", []), key=lambda item: as_int(item.get("id")) + ) + ] + series = [ + {"name": item.get("name", ""), "entries": [item.get("number", "")]} + for item in sorted( + row.get("series", []), key=lambda item: as_int(item.get("id")) + ) + ] + category = as_int(row.get("category")) + return { + "mam_id": as_int(row.get("id")), + "vip_status": "Permanent" if as_bool(row.get("vip")) else "NotVip", + "cat": {"id": category, "name": row.get("catname", "")}, + "media_type": "unknown", + "main_cat": None, + "categories": [as_int(item.get("id")) for item in row.get("categories", [])], + "language": None, + "flags": as_int(row.get("browseFlags")), + "filetypes": [ + str(item.get("name", "")).lower() for item in row.get("fileTypes", []) + ], + "num_files": 0, + "size": parse_size(row.get("size", 0)), + "title": str(row.get("title", "")), + "edition": None, + "authors": authors, + "narrators": narrators, + "series": series, + "source": "Mam", + "uploaded_at": "1970-01-01T00:00:00Z", + } + + +def _search_compatible_row(row: dict[str, Any]) -> dict[str, Any]: + return { + **row, + "browseflags": row.get("browseFlags"), + "owner_name": row.get("uploaderName"), + "personal_freeleech": row.get("personalFree"), + } + + +async def run_snatchlist_search( + config: Config, + repository: Repository, + mam: MamClient, + definition: dict[str, Any], +) -> int: + if definition.get("languages"): + raise ValueError("language filtering is not supported for snatchlists") + if definition.get("uploaded_after") or definition.get("uploaded_before"): + raise ValueError("upload date filtering is not supported for snatchlists") + cost = definition.get("cost", "metadata_only") + if cost not in {"metadata_only", "metadata_only_add"}: + raise ValueError("snatchlists only support metadata_only costs") + + added = 0 + timestamp = int(time.time()) + max_pages = int(definition.get("max_pages", 100)) + for page in range(max_pages): + result = await mam.snatchlist(definition["type"], page, timestamp) + rows = result.get("rows", []) + for row in rows: + torrent_id = as_int(row.get("id")) + if ( + not torrent_id + or torrent_id in config.ignore_torrents + or repository.has_mam_id(torrent_id) + or not matches_filter(_search_compatible_row(row), definition) + ): + continue + if cost == "metadata_only_add" and not definition.get("dry_run", False): + repository.add_metadata_torrent( + user_torrent_meta(row), row.get("uploaderName") or None + ) + added += 1 + if len(rows) != 250: + break + return added diff --git a/python/src/mlm/static/absidekick.css b/python/src/mlm/static/absidekick.css new file mode 100644 index 00000000..931b6bbe --- /dev/null +++ b/python/src/mlm/static/absidekick.css @@ -0,0 +1,1269 @@ +.absidekick-module { + --panel-2: color-mix(in srgb, var(--panel-strong) 82%, var(--surface)); + --accent-2: var(--line); + --border: var(--line-soft); + --mono: "Cascadia Mono", "SFMono-Regular", Consolas, monospace; + font-family: var(--mono); + + .abs-subtitle { + color: var(--muted); + margin: 0.55rem 0 0; + } + +button, +input, +select { + font-family: var(--mono); + font-size: 0.95rem; +} + +button { + border: 1px solid var(--accent-2); + border-radius: var(--radius); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 92%, #fff), var(--accent-2)); + color: #031008; + cursor: pointer; + font-weight: 800; + padding: 0.75rem 1rem; + transition: transform 120ms ease, box-shadow 120ms ease, opacity 120ms ease; +} + +button:hover { + box-shadow: 0 0 0 3px var(--shadow); + transform: translateY(-1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +button.ghost { + background: transparent; + color: var(--accent); +} + +button.danger { + background: transparent; + border-color: var(--danger); + color: var(--danger); +} + +code { + color: var(--accent); +} + +.topbar { + align-items: center; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem clamp(1rem, 3vw, 2rem); +} + +.topbar h1 { + font-size: clamp(1.6rem, 3vw, 2.35rem); + letter-spacing: 0; + line-height: 1; + margin: 0; + text-shadow: 0 0 12px var(--shadow), 0 0 2px var(--accent); +} + +.topbar-actions, +.button-row, +.run-buttons { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.shell { + display: grid; + gap: 1rem; + padding: 1rem clamp(1rem, 3vw, 2rem) 2rem; +} + +.eyebrow { + color: var(--accent); + font-size: 0.75rem; + font-weight: 900; + letter-spacing: 0.12em; + margin: 0 0 0.35rem; + text-transform: uppercase; +} + +.status-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.metric, +.panel { + background: color-mix(in srgb, var(--panel) 92%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025), 0 16px 48px rgba(0, 0, 0, 0.22); +} + +.metric { + min-width: 0; + padding: 1rem; +} + +.metric span { + color: var(--muted); + display: block; + font-size: 0.8rem; + margin-bottom: 0.35rem; +} + +.metric strong { + color: var(--accent); + display: block; + font-size: 1.35rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-panel { + align-items: center; + background: color-mix(in srgb, var(--panel) 94%, transparent); + border: 1px solid var(--border); + border-left: 3px solid var(--muted); + border-radius: var(--radius); + display: grid; + gap: 0.85rem; + grid-template-columns: auto minmax(0, 1fr) auto; + min-width: 0; + padding: 0.85rem 1rem; + transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease; +} + +.activity-panel.running { + background: linear-gradient(90deg, rgba(21, 255, 138, 0.08), color-mix(in srgb, var(--panel) 94%, transparent)); + border-color: var(--accent); + box-shadow: 0 0 22px rgba(21, 255, 138, 0.08); +} + +.activity-panel.success { + border-left-color: var(--accent); +} + +.activity-panel.error { + border-color: var(--danger); +} + +.activity-signal { + align-items: center; + border: 1px solid var(--border); + border-radius: 50%; + display: flex; + height: 2.25rem; + justify-content: center; + width: 2.25rem; +} + +.activity-signal span { + background: var(--muted); + border-radius: 50%; + height: 0.55rem; + width: 0.55rem; +} + +.activity-panel.running .activity-signal span { + animation: activity-pulse 1.15s ease-in-out infinite; + background: var(--accent); + box-shadow: 0 0 14px var(--accent); +} + +.activity-panel.success .activity-signal span { + background: var(--accent); +} + +.activity-panel.error .activity-signal span { + background: var(--danger); +} + +.activity-copy { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.activity-copy .eyebrow { + margin: 0; +} + +.activity-copy > strong { + color: var(--text); + font-size: 0.95rem; +} + +.activity-copy > span { + color: var(--muted); + font-size: 0.8rem; + min-height: 1.1rem; + overflow-wrap: anywhere; +} + +.activity-copy progress { + accent-color: var(--accent); + height: 0.35rem; + margin-top: 0.25rem; + max-width: 42rem; + width: 100%; +} + +.activity-numbers { + display: grid; + gap: 0.2rem; + min-width: 7rem; + text-align: right; +} + +.activity-numbers strong { + color: var(--accent); + font-size: 0.88rem; +} + +.activity-numbers span { + color: var(--muted); + font-size: 0.75rem; +} + +button.is-busy { + cursor: wait; + position: relative; +} + +button.is-busy::before { + animation: activity-spin 0.8s linear infinite; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + content: ""; + display: inline-block; + height: 0.7rem; + margin-right: 0.5rem; + vertical-align: -0.05rem; + width: 0.7rem; +} + +@keyframes activity-pulse { + 0%, + 100% { + opacity: 0.45; + transform: scale(0.75); + } + 50% { + opacity: 1; + transform: scale(1.15); + } +} + +@keyframes activity-spin { + to { + transform: rotate(360deg); + } +} + +.layout { + display: grid; + gap: 1rem; + grid-template-columns: 220px minmax(0, 1fr); +} + +.nav-panel { + align-self: start; + display: grid; + gap: 0.4rem; + padding: 0.55rem; +} + +.tab { + background: transparent; + border-color: transparent; + color: var(--muted); + justify-content: flex-start; + text-align: left; +} + +.tab.active { + background: var(--panel-2); + border-color: var(--border); + color: var(--accent); +} + +.content-panel, +.log-panel { + min-width: 0; + padding: clamp(1rem, 2vw, 1.35rem); +} + +.section-head { + align-items: center; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 1rem; +} + +h2, +h3 { + letter-spacing: 0; + margin: 0; +} + +h3 { + color: var(--accent); + font-size: 1rem; + margin-top: 1.1rem; +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: grid; + gap: 1rem; +} + +.grid { + display: grid; + gap: 0.85rem; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.grid.four { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.grid.six { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.field { + display: grid; + gap: 0.35rem; + min-width: 0; +} + +.field span, +.check span { + color: var(--muted); + font-size: 0.82rem; + font-weight: 700; +} + +.field.compact { + min-width: 150px; +} + +input, +select { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + min-height: 2.65rem; + min-width: 0; + outline: none; + padding: 0.65rem 0.75rem; + width: 100%; +} + +input::placeholder { + color: color-mix(in srgb, var(--muted) 68%, transparent); +} + +select[multiple] { + min-height: 12rem; +} + +input:focus, +select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--shadow); +} + +.checks { + display: grid; + gap: 0.6rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.check { + align-items: center; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + gap: 0.65rem; + min-height: 3rem; + padding: 0.7rem; +} + +.check input { + accent-color: var(--accent); + min-height: auto; + width: auto; +} + +.note, +.preview { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--muted); + padding: 0.9rem; +} + +.match-policy-card { + align-items: stretch; + background: + linear-gradient(120deg, color-mix(in srgb, var(--accent) 12%, transparent), transparent 58%), + var(--panel-2); + border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--border)); + border-radius: var(--radius); + display: grid; + gap: 1rem; + grid-template-columns: minmax(14rem, 0.7fr) minmax(20rem, 2fr); + padding: 1rem; +} + +.match-policy-select { + align-content: start; +} + +.policy-summary { + display: grid; + gap: 0.35rem; +} + +.policy-summary strong { + color: var(--accent); + font-size: 0.95rem; +} + +.policy-summary span { + color: var(--text); + font-size: 0.84rem; + line-height: 1.45; +} + +.policy-summary .policy-google { + color: var(--muted); +} + +.preview { + color: var(--text); + max-height: 30rem; + overflow: auto; +} + +.preview-row { + border-bottom: 1px solid var(--border); + display: grid; + gap: 0.4rem; + padding: 0.75rem 0; +} + +.preview-row:last-child { + border-bottom: 0; +} + +.score { + color: var(--accent); + font-weight: 900; +} + +.hidden { + display: none !important; +} + +.latest { + color: var(--muted); + font-size: 0.9rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.log { + display: grid; + gap: 0.5rem; + max-height: 26rem; + overflow: auto; +} + +.log-tools { + display: grid; + gap: 0.65rem; + grid-template-columns: minmax(12rem, 1.8fr) repeat(4, minmax(8rem, 1fr)); + margin-bottom: 0.75rem; +} + +.log-entry { + border: 1px solid var(--border); + border-radius: var(--radius); + display: grid; + gap: 0.35rem; + padding: 0.7rem; +} + +.log-entry strong { + color: var(--accent); +} + +.log-entry .meta { + color: var(--muted); + font-size: 0.8rem; +} + +.log-entry.error strong { + color: var(--danger); +} + +.log-entry.warning strong { + color: var(--warning); +} + +.pager { + align-items: center; + color: var(--muted); + display: flex; + gap: 0.8rem; + justify-content: flex-end; + margin-top: 0.75rem; +} + +.review-desk { + display: grid; + gap: 1rem; +} + +.review-item { + background: color-mix(in srgb, var(--panel-2) 78%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem; +} + +.review-item-head { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin-bottom: 0.85rem; +} + +.search-trace { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.2rem; +} + +.trace-label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.08em; + margin-right: 0.2rem; + text-transform: uppercase; +} + +.trace-arrow { + color: var(--muted); +} + +.search-attempt { + background: color-mix(in srgb, var(--accent) 10%, var(--panel)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + border-radius: 999px; + color: var(--text); + font-size: 0.76rem; + padding: 0.25rem 0.5rem; +} + +.search-attempt.no_results, +.search-attempt.skipped { + border-color: color-mix(in srgb, var(--warning) 45%, var(--border)); + color: var(--warning); +} + +.search-attempt.error, +.search-attempt.disabled { + border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); + color: var(--danger); +} + +.log-decision { + background: color-mix(in srgb, var(--panel-2) 82%, transparent); + border-left: 3px solid var(--accent); + display: grid; + font-size: 0.8rem; + gap: 0.25rem; + margin-top: 0.2rem; + padding: 0.55rem 0.65rem; +} + +.log-decision.review { + border-left-color: var(--warning); +} + +.log-decision.unmatched { + border-left-color: var(--muted); +} + +.log-decision strong { + color: var(--text); +} + +.decision-reasons { + color: var(--warning); +} + +.review-search { + background: rgba(4, 31, 18, 0.72); + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: var(--radius); + margin-bottom: 0.85rem; + padding: 0.7rem 0.8rem; +} + +.review-search > summary { + color: var(--accent); + cursor: pointer; + font-weight: 900; + letter-spacing: 0.025em; +} + +.review-search > .meta { + margin: 0.6rem 0; +} + +.review-search-form { + align-items: end; + display: grid; + gap: 0.65rem; + grid-template-columns: minmax(12rem, 2fr) minmax(10rem, 1.4fr) minmax(8rem, 0.8fr) 6rem auto; +} + +.review-search-form .field, +.review-search-form .field.compact { + margin: 0; +} + +.review-search-form .field span em { + color: var(--muted); + font-size: 0.72rem; + font-style: normal; + font-weight: 600; +} + +.review-search-feedback { + border-left: 3px solid var(--accent); + color: var(--text); + margin-top: 0.7rem; + padding: 0.45rem 0.65rem; +} + +.review-search-feedback.error { + border-left-color: var(--danger); + color: var(--danger); +} + +.review-search-feedback.success { + color: var(--accent); +} + +.manual-search-outcome { + align-items: center; + background: rgba(2, 15, 8, 0.72); + border: 1px solid var(--border); + border-left: 4px solid var(--muted); + border-radius: var(--radius); + display: flex; + gap: 1rem; + justify-content: space-between; + margin-top: 0.75rem; + padding: 0.75rem 0.85rem; +} + +.manual-search-outcome > div:first-child { + display: grid; + gap: 0.25rem; +} + +.manual-search-outcome strong { + color: var(--text); + letter-spacing: 0.045em; +} + +.manual-search-outcome span { + color: var(--muted); + display: block; + font-size: 0.84rem; +} + +.manual-search-outcome.match { + background: color-mix(in srgb, var(--accent) 10%, rgba(2, 15, 8, 0.9)); + border-left-color: var(--accent); +} + +.manual-search-outcome.match strong, +.manual-search-outcome.match .manual-best b { + color: var(--accent); +} + +.manual-search-outcome.review, +.manual-search-outcome.unmatched { + border-left-color: var(--warning); +} + +.manual-search-outcome.review strong, +.manual-search-outcome.unmatched strong { + color: var(--warning); +} + +.manual-search-outcome.error { + border-left-color: var(--danger); +} + +.manual-search-outcome.error strong { + color: var(--danger); +} + +.manual-search-outcome.searching { + border-left-color: #54c7e6; +} + +.manual-search-outcome.searching strong { + color: #9eeeff; +} + +.manual-best { + color: var(--text) !important; + margin-top: 0.2rem; +} + +.manual-reasons, +.manual-result-meta { + font-size: 0.76rem !important; +} + +.manual-search-actions { + align-items: center; + display: flex; + flex: 0 0 auto; + gap: 0.5rem; +} + +.search-pulse { + animation: search-pulse 1s ease-in-out infinite; + background: #9eeeff; + border-radius: 999px; + display: inline-block !important; + height: 0.55rem; + margin-right: 0.5rem; + width: 0.55rem; +} + +@keyframes search-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(158, 238, 255, 0.42); opacity: 0.7; } + 50% { box-shadow: 0 0 0 0.35rem rgba(158, 238, 255, 0); opacity: 1; } +} + +.compare-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.compare-card { + background: linear-gradient(90deg, rgba(3, 16, 8, 0.95), rgba(3, 16, 8, 0.78)); + background-position: center; + background-size: cover; + border: 1px solid var(--border); + border-radius: var(--radius); + display: grid; + gap: 0.45rem; + min-height: 11rem; + overflow: hidden; + padding: 0.75rem; + position: relative; +} + +.compare-card::before { + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.04), + rgba(255, 255, 255, 0.04) 1px, + transparent 1px, + transparent 4px + ); + content: ""; + inset: 0; + opacity: 0.35; + pointer-events: none; + position: absolute; +} + +.compare-card > * { + position: relative; +} + +.source-card { + background: + linear-gradient(135deg, rgba(30, 88, 112, 0.92), rgba(6, 24, 36, 0.96)), + var(--panel); + border-color: #54c7e6; + border-style: solid; + box-shadow: inset 0 0 0 1px rgba(160, 235, 255, 0.22), 0 0 0 3px rgba(84, 199, 230, 0.08); +} + +.source-card .card-label, +.source-card .candidate-title, +.source-card .source-score { + color: #9eeeff; +} + +.source-card .meta, +.source-card summary, +.source-card .detail-grid span { + color: #b7dce5; +} + +.candidate-card:has(input:checked), +.candidate-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--shadow); +} + +.candidate-card.author-match-card { + border-color: color-mix(in srgb, var(--warning) 72%, var(--border)); +} + +.candidate-card.author-match-card:has(input:checked) { + box-shadow: 0 0 0 3px rgba(255, 209, 102, 0.22); +} + +.candidate-pick { + align-items: center; + cursor: pointer; + display: inline-flex; + gap: 0.45rem; + width: fit-content; +} + +.candidate-pick input { + accent-color: var(--accent); + min-height: auto; + width: auto; +} + +.candidate-origin { + color: #9eeeff; + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.055em; + text-transform: uppercase; +} + +.evidence-line, +.conflict-line, +.confidence-line { + font-size: 0.76rem; + font-weight: 800; +} + +.evidence-line, +.confidence-line.high { + color: var(--accent); +} + +.conflict-line, +.confidence-line.review, +.confidence-line.low { + color: var(--warning); +} + +.match-decision { + align-items: center; + background: rgba(3, 16, 8, 0.72); + border-left: 3px solid var(--muted); + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.8rem; + margin-bottom: 0.85rem; + padding: 0.55rem 0.7rem; +} + +.match-decision.high { + border-left-color: var(--accent); +} + +.match-decision.review, +.match-decision.low { + border-left-color: var(--warning); +} + +.match-decision span { + color: var(--muted); + font-size: 0.78rem; +} + +.provider-warning { + background: rgba(255, 188, 66, 0.08); + border: 1px solid rgba(255, 188, 66, 0.28); + color: var(--warning); + font-size: 0.78rem; + font-weight: 800; + margin: -0.35rem 0 0.85rem; + padding: 0.55rem 0.7rem; +} + +.card-label, +.candidate-pick span, +.source-score, +.match-score { + color: var(--accent); + font-size: 0.78rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.source-score, +.match-score { + letter-spacing: 0; + text-transform: none; +} + +.candidate-card > input { + position: absolute; + opacity: 0; +} + +.candidate-title { + color: var(--accent); + font-weight: 900; + line-height: 1.2; +} + +.candidate-author { + align-items: center; + color: var(--muted); + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.candidate-author strong, +.candidate-author em { + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.72rem; + font-style: normal; + font-weight: 900; + padding: 0.17rem 0.45rem; +} + +.candidate-author em { + color: var(--muted); +} + +.candidate-author.author-match span, +.candidate-author.author-match strong { + color: #ffe08a; +} + +.candidate-author.author-match strong, +.score-parts .author-chip-match { + background: rgba(255, 209, 102, 0.15); + border-color: rgba(255, 209, 102, 0.72); + box-shadow: 0 0 16px rgba(255, 209, 102, 0.12); +} + +.compare-card details { + margin-top: 0.25rem; +} + +.compare-card summary { + color: var(--muted); + cursor: pointer; + font-size: 0.82rem; + font-weight: 800; +} + +.compare-card p { + color: var(--muted); + font-size: 0.85rem; + line-height: 1.35; + margin: 0.25rem 0 0; +} + +.detail-grid { + display: grid; + gap: 0.3rem 0.6rem; + grid-template-columns: max-content minmax(0, 1fr); + margin-top: 0.45rem; +} + +.detail-grid span { + color: var(--muted); +} + +.detail-grid strong { + color: var(--text); + font-weight: 700; + min-width: 0; + overflow-wrap: anywhere; +} + +.score-parts { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.score-parts span { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 0.72rem; + padding: 0.2rem 0.45rem; +} + +.toast { + background: var(--panel); + border: 1px solid var(--accent); + border-radius: var(--radius); + bottom: 1rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28); + color: var(--text); + left: 50%; + max-width: min(32rem, calc(100vw - 2rem)); + padding: 0.8rem 1rem; + position: fixed; + transform: translateX(-50%); + z-index: 10; +} + +.provider-config { + background: linear-gradient(145deg, rgba(21, 255, 138, 0.045), rgba(3, 16, 8, 0.76)); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + margin-top: 1rem; + padding: 1rem; +} + +.compact-head { + margin-bottom: 0.45rem; +} + +.compact-head h3 { + margin: 0; +} + +.provider-state { + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 900; + letter-spacing: 0.04em; + padding: 0.28rem 0.58rem; + text-transform: uppercase; +} + +.provider-state.ready { + background: rgba(21, 255, 138, 0.12); + border-color: var(--accent); + color: var(--accent); +} + +.provider-state.untested { + background: rgba(255, 188, 66, 0.08); + border-color: var(--warning); + color: var(--warning); +} + +.google-key-grid { + align-items: end; + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto; + margin-top: 0.8rem; +} + +.google-key-actions { + flex-wrap: nowrap; +} + +.provider-test-result { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.4; + min-height: 1.2rem; + padding-top: 0.65rem; +} + +.provider-test-result.success { + color: var(--accent); +} + +.provider-test-result.warning, +.provider-test-result.testing { + color: var(--warning); +} + +.provider-test-result.error { + color: #ff8d8d; +} + +.setup-guide { + border-top: 1px solid var(--border); + margin-top: 0.8rem; + padding-top: 0.75rem; +} + +.setup-guide summary { + color: var(--accent); + cursor: pointer; + font-weight: 900; +} + +.setup-guide ol { + color: var(--muted); + line-height: 1.5; + margin: 0.75rem 0; + padding-left: 1.35rem; +} + +.setup-guide li + li { + margin-top: 0.5rem; +} + +.setup-guide a { + color: var(--accent); +} + +@media (max-width: 1400px) { + .status-grid, + .grid.four, + .grid.six { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .checks, + .grid.three, + .log-tools { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .layout { + grid-template-columns: 1fr; + } + + .nav-panel { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + + .tab { + text-align: center; + } + + .review-search-form { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .topbar, + .section-head { + align-items: stretch; + flex-direction: column; + } + + .status-grid, + .grid.two, + .grid.three, + .grid.four, + .grid.six, + .checks, + .nav-panel, + .log-tools { + grid-template-columns: 1fr; + } + + .review-item-head { + flex-direction: column; + } + + .candidate-card { + grid-template-columns: 1fr; + } + + .review-search-form { + grid-template-columns: 1fr; + } + + .manual-search-outcome { + align-items: stretch; + flex-direction: column; + } + + .manual-search-actions { + flex-wrap: wrap; + } + + .activity-panel { + align-items: start; + grid-template-columns: auto minmax(0, 1fr); + } + + .activity-numbers { + grid-column: 2; + min-width: 0; + text-align: left; + } + + .google-key-grid { + grid-template-columns: 1fr; + } + + .match-policy-card { + grid-template-columns: 1fr; + } + + .google-key-actions { + flex-wrap: wrap; + } +} + +@media (prefers-reduced-motion: reduce) { + .activity-panel.running .activity-signal span, + button.is-busy::before { + animation: none; + } +} + +} diff --git a/python/src/mlm/static/absidekick.js b/python/src/mlm/static/absidekick.js new file mode 100644 index 00000000..96335ba1 --- /dev/null +++ b/python/src/mlm/static/absidekick.js @@ -0,0 +1,1740 @@ +const $ = (id) => document.getElementById(id); +const API_ROOT = "/api/absidekick"; +const REVIEW_PROVIDERS = [ + "audible", + "google", + "openlibrary", + "itunes", + "audible.ca", + "audible.uk", + "audible.au", + "audible.fr", + "audible.de", + "audible.jp", + "audible.it", + "audible.in", + "audible.es", + "fantlab", +]; +const MATCH_POLICY_PRESETS = { + balanced: { + threshold: 80, + reviewFloor: 65, + minimumTitleScore: 86, + minimumAuthorScore: 78, + minimumWinnerMargin: 6, + minimumStrongSignals: 2, + strictAutoMatch: true, + }, + cautious: { + threshold: 90, + reviewFloor: 70, + minimumTitleScore: 92, + minimumAuthorScore: 85, + minimumWinnerMargin: 8, + minimumStrongSignals: 2, + strictAutoMatch: true, + }, + flexible: { + threshold: 80, + reviewFloor: 60, + minimumTitleScore: 84, + minimumAuthorScore: 72, + minimumWinnerMargin: 3, + minimumStrongSignals: 1, + strictAutoMatch: true, + }, +}; +const MATCH_POLICY_FIELDS = [ + "threshold", + "reviewFloor", + "minimumTitleScore", + "minimumAuthorScore", + "minimumWinnerMargin", + "minimumStrongSignals", +]; + +let appState = { + settings: null, + libraries: [], + filterData: null, + job: null, + poll: null, + reviewRows: [], + logs: [], + logPage: 1, + activity: { + sequence: 0, + activeId: 0, + startedAt: 0, + timer: null, + poll: null, + status: "idle", + }, +}; + +const ACTION_CANCELLED = Symbol("action-cancelled"); + +const checkboxIds = [ + "rememberConnection", + "missingMetadataOnly", + "missingCoverOnly", + "noAsinOnly", + "noIsbnOnly", + "skipMissingItems", + "skipInvalidItems", + "overwriteMetadata", + "repairSeries", + "adaptiveSearch", + "automaticFallbackProviders", + "strictAutoMatch", + "quickMatchFirstResultOnly", + "requireAuthor", + "requireTitleToken", + "useEmbeddedFileMetadata", + "sortDesc", + "tagMatched", + "tagUnmatched", + "tagReview", + "clearUnmatchedOnMatch", + "clearReviewOnMatch", + "clearMatchedOnUnmatched", + "dryRun", + "stopOnError", + "rejectAddsUnmatchedTag", + "rejectClearsReviewTag", +]; + +function showToast(message, timeout = 3200) { + const toast = $("toast"); + toast.textContent = message; + toast.classList.remove("hidden"); + window.clearTimeout(showToast.timer); + showToast.timer = window.setTimeout(() => toast.classList.add("hidden"), timeout); +} + +function elapsedLabel(startedAt) { + if (!startedAt) return "Idle"; + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = String(elapsedSeconds % 60).padStart(2, "0"); + return minutes ? `${minutes}m ${seconds}s elapsed` : `${elapsedSeconds}s elapsed`; +} + +function renderActivity(activity) { + const panel = $("activityPanel"); + const progress = $("activityProgress"); + const status = activity.status || "idle"; + const current = Number(activity.current || 0); + const total = Number(activity.total || 0); + const startedAt = activity.startedAt + ? typeof activity.startedAt === "number" + ? activity.startedAt + : Date.parse(activity.startedAt) + : appState.activity.startedAt; + + panel.className = `activity-panel ${status}`; + panel.setAttribute("aria-busy", status === "running" ? "true" : "false"); + $("activityTitle").textContent = activity.title || "Ready"; + $("activityDetail").textContent = activity.detail || "No ABSidekick operation is currently running."; + $("activityCount").textContent = total > 0 ? `${Math.min(current, total)} / ${total}` : status === "running" ? "Working…" : "—"; + $("activityElapsed").textContent = startedAt + ? status === "running" + ? elapsedLabel(startedAt) + : `${elapsedLabel(startedAt).replace(" elapsed", "")} total` + : status === "success" + ? "Completed" + : status === "error" + ? "Stopped with error" + : "Idle"; + + if (status === "running" && total <= 0) { + progress.removeAttribute("value"); + progress.max = 1; + } else { + progress.max = Math.max(1, total); + progress.value = total > 0 ? Math.min(current, total) : status === "success" ? 1 : 0; + } +} + +function setButtonBusy(button, busy, busyText = "Working…") { + if (!button) return; + if (busy) { + button.dataset.activityLabel = button.textContent; + button.dataset.activityWasDisabled = button.disabled ? "true" : "false"; + button.disabled = true; + button.classList.add("is-busy"); + button.setAttribute("aria-busy", "true"); + button.textContent = busyText; + return; + } + button.disabled = button.dataset.activityWasDisabled === "true"; + button.classList.remove("is-busy"); + button.removeAttribute("aria-busy"); + if (button.dataset.activityLabel) button.textContent = button.dataset.activityLabel; + delete button.dataset.activityLabel; + delete button.dataset.activityWasDisabled; +} + +function beginVisibleActivity(button, options) { + const activity = appState.activity; + activity.sequence += 1; + activity.activeId = activity.sequence; + activity.startedAt = Date.now(); + activity.status = "running"; + activity.source = "action"; + window.clearInterval(activity.timer); + window.clearInterval(activity.poll); + setButtonBusy(button, true, options.busyText); + renderActivity({ + status: "running", + title: options.title, + detail: options.detail, + startedAt: activity.startedAt, + }); + activity.timer = window.setInterval(() => { + if (activity.status === "running") { + $("activityElapsed").textContent = elapsedLabel(activity.startedAt); + } + }, 500); + return activity.activeId; +} + +async function refreshBackendActivity(activityId) { + if (appState.activity.activeId !== activityId) return; + try { + const payload = await api("/api/activity"); + const activity = payload.activity || {}; + if (activity.status !== "running") { + if (appState.activity.source === "server") { + appState.activity.status = activity.status || "idle"; + window.clearInterval(appState.activity.timer); + window.clearInterval(appState.activity.poll); + renderActivity(activity); + } + return; + } + appState.activity.status = "running"; + renderActivity(activity); + } catch (_error) { + // The initiating request still owns final success/error handling. + } +} + +function resumeBackendActivity(activity) { + if (!activity || !activity.status || activity.status === "idle") return; + if (appState.activity.status === "running" && appState.activity.source !== "server") return; + appState.activity.sequence += 1; + appState.activity.activeId = appState.activity.sequence; + appState.activity.source = "server"; + appState.activity.status = activity.status; + appState.activity.startedAt = Date.parse(activity.startedAt || "") || Date.now(); + renderActivity(activity); + if (activity.status !== "running") return; + const activityId = appState.activity.activeId; + window.clearInterval(appState.activity.timer); + window.clearInterval(appState.activity.poll); + appState.activity.timer = window.setInterval(() => { + $("activityElapsed").textContent = elapsedLabel(appState.activity.startedAt); + }, 500); + appState.activity.poll = window.setInterval(() => refreshBackendActivity(activityId), 450); +} + +function finishVisibleActivity(activityId, status, title, detail) { + if (appState.activity.activeId !== activityId) return; + appState.activity.status = status; + window.clearInterval(appState.activity.timer); + window.clearInterval(appState.activity.poll); + renderActivity({ + status, + title, + detail, + startedAt: appState.activity.startedAt, + current: 0, + total: 0, + }); +} + +async function runVisibleAction(button, options, action) { + const activityId = beginVisibleActivity(button, options); + if (options.pollBackend) { + appState.activity.poll = window.setInterval(() => refreshBackendActivity(activityId), 450); + window.setTimeout(() => refreshBackendActivity(activityId), 80); + } + try { + const result = await action(); + if (result === ACTION_CANCELLED) { + finishVisibleActivity(activityId, "idle", "Action cancelled", "Nothing was changed."); + return result; + } + const successDetail = typeof options.success === "function" ? options.success(result) : options.success; + finishVisibleActivity(activityId, "success", options.successTitle || `${options.title} complete`, successDetail || "Operation completed successfully."); + return result; + } catch (error) { + finishVisibleActivity(activityId, "error", `${options.title} failed`, error.message); + showToast(error.message, 5200); + return null; + } finally { + setButtonBusy(button, false); + } +} + +function reportInstantActivity(title, detail) { + if (appState.activity.status === "running") return; + appState.activity.sequence += 1; + appState.activity.activeId = appState.activity.sequence; + appState.activity.startedAt = Date.now(); + appState.activity.source = "instant"; + finishVisibleActivity(appState.activity.activeId, "success", title, detail); +} + +async function api(path, options = {}) { + const resolvedPath = path.startsWith("/api/") ? `${API_ROOT}${path.slice(4)}` : path; + const response = await fetch(resolvedPath, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + const payload = await response.json(); + if (!response.ok || payload.ok === false) { + throw new Error(payload.error || `Request failed: ${response.status}`); + } + return payload; +} + +function csvToArray(value) { + return String(value || "") + .split(",") + .map((part) => part.trim()) + .filter(Boolean); +} + +function arrayToCsv(values) { + return (values || []).join(", "); +} + +function selectedValues(select) { + return Array.from(select.selectedOptions).map((option) => option.value); +} + +function setSelectedValues(select, values) { + const selected = new Set(values || []); + Array.from(select.options).forEach((option) => { + option.selected = selected.has(option.value); + }); +} + +function matchingPolicyValues() { + return { + threshold: Number($("threshold").value || 80), + reviewFloor: Number($("reviewFloor").value || 65), + minimumTitleScore: Number($("minimumTitleScore").value || 86), + minimumAuthorScore: Number($("minimumAuthorScore").value || 78), + minimumWinnerMargin: Number($("minimumWinnerMargin").value || 6), + minimumStrongSignals: Number($("minimumStrongSignals").value || 2), + strictAutoMatch: $("strictAutoMatch").checked, + }; +} + +function detectMatchingPolicyPreset() { + const current = matchingPolicyValues(); + return Object.entries(MATCH_POLICY_PRESETS).find(([, preset]) => + Object.entries(preset).every(([key, value]) => current[key] === value), + )?.[0] || "custom"; +} + +function renderPolicySummary() { + const values = matchingPolicyValues(); + const preset = detectMatchingPolicyPreset(); + $("matchPolicyPreset").value = preset; + const scoreRange = `Similarity ${values.threshold}+ can auto-match; scores from ${values.reviewFloor} up to just under ${values.threshold} go to Review.`; + $("policySummary").innerHTML = values.strictAutoMatch + ? `${preset === "custom" ? "Custom safety policy" : `${$("matchPolicyPreset").selectedOptions[0].textContent} policy`}${scoreRange} Automatic approval also requires title ${values.minimumTitleScore}+, author ${values.minimumAuthorScore}+ when both sides provide one, ${values.minimumStrongSignals} independent signal${values.minimumStrongSignals === 1 ? "" : "s"}, a ${values.minimumWinnerMargin}-point lead over a meaningfully different result, and no work-identity conflicts. With an exact ASIN or ISBN, a different secondary identifier or stored duration is a non-blocking edition note. Duplicate listings of the same work do not count as competitors.If ABS does not pass all of that, a tested Google key is searched second and Open Library is searched third. The full provider path appears in the log.` + : `Similarity-only approval${scoreRange} The title, author, signal, margin, and conflict safety checks are displayed but do not block automatic approval. This is less accurate.If ABS does not reach the score, Google is searched second and Open Library third.`; +} + +function applyMatchingPolicyPreset(name) { + const preset = MATCH_POLICY_PRESETS[name]; + if (!preset) { + renderPolicySummary(); + return; + } + MATCH_POLICY_FIELDS.forEach((id) => { + $(id).value = preset[id]; + }); + $("strictAutoMatch").checked = preset.strictAutoMatch; + renderPolicySummary(); +} + +function normalizeLibraryList(payload) { + const raw = payload?.libraries; + if (Array.isArray(raw)) return raw; + if (Array.isArray(raw?.libraries)) return raw.libraries; + if (Array.isArray(raw?.items)) return raw.items; + if (Array.isArray(raw?.results)) return raw.results; + return []; +} + +function populateLibraries(libraries) { + appState.libraries = libraries || []; + const select = $("libraryId"); + const current = select.value || appState.settings?.connection?.libraryId || ""; + select.innerHTML = ''; + appState.libraries.forEach((library) => { + const option = document.createElement("option"); + option.value = library.id; + option.textContent = `${library.name || library.id}${library.mediaType ? ` (${library.mediaType})` : ""}`; + select.appendChild(option); + }); + if (current) select.value = current; +} + +function populateFilterData(filterData) { + appState.filterData = filterData || {}; + const authors = filterData?.authors || []; + ["includeAuthors", "excludeAuthors"].forEach((id) => { + const select = $(id); + const current = selectedValues(select); + select.innerHTML = ""; + authors.forEach((author) => { + const option = document.createElement("option"); + option.value = author.name || author.id; + option.textContent = author.name || author.id; + select.appendChild(option); + }); + setSelectedValues(select, current); + }); + if (filterData?.tags?.length) { + $("connectionNote").textContent = `Loaded ${authors.length} author(s), ${filterData.tags.length} tag(s), ${filterData.series?.length || 0} series.`; + } else { + $("connectionNote").textContent = `Loaded ${authors.length} author(s). No tags returned for this library yet.`; + } +} + +function setForm(settings) { + appState.settings = settings; + const connection = settings.connection || {}; + const providers = settings.providers || {}; + const run = settings.run || {}; + const targeting = settings.targeting || {}; + const matching = settings.matching || {}; + const weights = settings.weights || {}; + const tags = settings.tags || {}; + const review = settings.review || {}; + + $("baseUrl").value = connection.baseUrl || ""; + $("provider").value = connection.provider || "audible"; + $("rememberConnection").checked = connection.rememberConnection !== false; + $("libraryId").value = connection.libraryId || ""; + const hasToken = Boolean(connection.hasToken); + const tokenSaved = hasToken && connection.rememberConnection !== false; + const tokenStatus = $("absTokenStatus"); + tokenStatus.className = `provider-state ${tokenSaved ? "ready" : hasToken ? "untested" : "missing"}`; + tokenStatus.textContent = tokenSaved + ? "API token saved" + : hasToken + ? "Token available this run only" + : "Token not saved"; + $("token").value = ""; + $("token").placeholder = hasToken + ? "Stored token is hidden — paste only to replace it" + : "Paste token once; it will be saved privately"; + $("connectionNote").textContent = tokenSaved + ? "The saved API token is loaded on the server. The blank token field is intentional; you do not need to enter it again." + : hasToken + ? "This token is available only until MyAnonaSuite restarts. Enable saving to keep it." + : "Paste the Audiobookshelf API token and connect. Saving is enabled by default and the stored token is never returned to the browser."; + $("googleBooksApiKey").value = ""; + renderGoogleBooksStatus(providers); + $("openLibraryEnabled").checked = providers.openLibraryEnabled !== false; + $("openLibraryContactEmail").value = providers.openLibraryContactEmail || ""; + renderOpenLibraryStatus(); + + $("targetMode").value = targeting.mode || "unprocessed"; + $("titleContains").value = targeting.titleContains || ""; + $("pathContains").value = targeting.pathContains || ""; + $("includeTags").value = arrayToCsv(targeting.includeTags); + $("excludeTags").value = arrayToCsv(targeting.excludeTags); + $("includeTagMode").value = targeting.includeTagMode || "any"; + setSelectedValues($("includeAuthors"), targeting.includeAuthors || []); + setSelectedValues($("excludeAuthors"), targeting.excludeAuthors || []); + + $("threshold").value = matching.threshold ?? 80; + $("reviewFloor").value = matching.reviewFloor ?? 65; + $("candidateLimit").value = matching.candidateLimit ?? 8; + $("durationToleranceMinutes").value = matching.durationToleranceMinutes ?? 7; + $("minimumTitleScore").value = matching.minimumTitleScore ?? 86; + $("minimumAuthorScore").value = matching.minimumAuthorScore ?? 78; + $("minimumWinnerMargin").value = matching.minimumWinnerMargin ?? 6; + $("minimumStrongSignals").value = matching.minimumStrongSignals ?? 2; + $("fallbackProviders").value = arrayToCsv( + (matching.fallbackProviders || []).filter( + (provider) => !["google", "openlibrary"].includes(provider), + ), + ); + $("applyMode").value = matching.applyMode || "metadata_patch"; + $("coverMode").value = matching.coverMode || "if_missing"; + + $("sort").value = run.sort || "media.metadata.title"; + $("limit").value = run.limit ?? 0; + $("pageSize").value = run.pageSize ?? 100; + $("requestDelayMs").value = run.requestDelayMs ?? 150; + $("maxRetries").value = run.maxRetries ?? 2; + $("timeoutSeconds").value = run.timeoutSeconds ?? 30; + $("searchTimeoutSeconds").value = run.searchTimeoutSeconds ?? 12; + + $("weightTitle").value = weights.title ?? 50; + $("weightAuthor").value = weights.author ?? 25; + $("weightSeries").value = weights.series ?? 8; + $("weightNarrator").value = weights.narrator ?? 6; + $("weightYear").value = weights.year ?? 6; + $("weightDuration").value = weights.duration ?? 5; + + $("matchedTag").value = tags.matchedTag || "ABSidekick: AutoMatched"; + $("unmatchedTag").value = tags.unmatchedTag || "ABSidekick: AutoMatch Unmatched"; + $("reviewTag").value = tags.reviewTag || "ABSidekick: Needs Review"; + $("reviewScanLimit").value = review.scanLimit ?? 25; + $("reviewCandidateLimit").value = review.candidateLimit ?? 6; + + checkboxIds.forEach((id) => { + if ($(id) && id in targeting) $(id).checked = Boolean(targeting[id]); + if ($(id) && id in matching) $(id).checked = Boolean(matching[id]); + if ($(id) && id in run) $(id).checked = Boolean(run[id]); + if ($(id) && id in tags) $(id).checked = Boolean(tags[id]); + if ($(id) && id in review) $(id).checked = Boolean(review[id]); + }); + renderPolicySummary(); +} + +function getSettingsFromForm() { + return { + connection: { + baseUrl: $("baseUrl").value.trim(), + libraryId: $("libraryId").value, + provider: $("provider").value, + rememberConnection: $("rememberConnection").checked, + }, + providers: { + ...openLibraryPayload(), + ...googleKeyPayload(), + }, + run: { + dryRun: $("dryRun").checked, + limit: Number($("limit").value || 0), + pageSize: Number($("pageSize").value || 100), + sort: $("sort").value, + sortDesc: $("sortDesc").checked, + requestDelayMs: Number($("requestDelayMs").value || 0), + timeoutSeconds: Number($("timeoutSeconds").value || 30), + searchTimeoutSeconds: Number($("searchTimeoutSeconds").value || 12), + maxRetries: Number($("maxRetries").value || 0), + stopOnError: $("stopOnError").checked, + }, + targeting: { + mode: $("targetMode").value, + includeAuthors: selectedValues($("includeAuthors")), + excludeAuthors: selectedValues($("excludeAuthors")), + includeTags: csvToArray($("includeTags").value), + excludeTags: csvToArray($("excludeTags").value), + includeTagMode: $("includeTagMode").value, + titleContains: $("titleContains").value.trim(), + pathContains: $("pathContains").value.trim(), + missingMetadataOnly: $("missingMetadataOnly").checked, + missingCoverOnly: $("missingCoverOnly").checked, + noAsinOnly: $("noAsinOnly").checked, + noIsbnOnly: $("noIsbnOnly").checked, + skipMissingItems: $("skipMissingItems").checked, + skipInvalidItems: $("skipInvalidItems").checked, + }, + matching: { + threshold: Number($("threshold").value || 80), + reviewFloor: Number($("reviewFloor").value || 65), + candidateLimit: Number($("candidateLimit").value || 8), + adaptiveSearch: $("adaptiveSearch").checked, + automaticFallbackProviders: $("automaticFallbackProviders").checked, + fallbackProviders: csvToArray($("fallbackProviders").value), + strictAutoMatch: $("strictAutoMatch").checked, + minimumTitleScore: Number($("minimumTitleScore").value || 86), + minimumAuthorScore: Number($("minimumAuthorScore").value || 78), + minimumWinnerMargin: Number($("minimumWinnerMargin").value || 6), + minimumStrongSignals: Number($("minimumStrongSignals").value || 2), + applyMode: $("applyMode").value, + overwriteMetadata: $("overwriteMetadata").checked, + repairSeries: $("repairSeries").checked, + coverMode: $("coverMode").value, + quickMatchFirstResultOnly: $("quickMatchFirstResultOnly").checked, + requireAuthor: $("requireAuthor").checked, + requireTitleToken: $("requireTitleToken").checked, + durationToleranceMinutes: Number($("durationToleranceMinutes").value || 7), + useEmbeddedFileMetadata: $("useEmbeddedFileMetadata").checked, + }, + weights: { + title: Number($("weightTitle").value || 0), + author: Number($("weightAuthor").value || 0), + series: Number($("weightSeries").value || 0), + narrator: Number($("weightNarrator").value || 0), + year: Number($("weightYear").value || 0), + duration: Number($("weightDuration").value || 0), + }, + tags: { + matchedTag: $("matchedTag").value.trim(), + unmatchedTag: $("unmatchedTag").value.trim(), + reviewTag: $("reviewTag").value.trim(), + tagMatched: $("tagMatched").checked, + tagUnmatched: $("tagUnmatched").checked, + tagReview: $("tagReview").checked, + clearUnmatchedOnMatch: $("clearUnmatchedOnMatch").checked, + clearReviewOnMatch: $("clearReviewOnMatch").checked, + clearMatchedOnUnmatched: $("clearMatchedOnUnmatched").checked, + }, + review: { + scanLimit: Number($("reviewScanLimit").value || 25), + candidateLimit: Number($("reviewCandidateLimit").value || 6), + rejectAddsUnmatchedTag: $("rejectAddsUnmatchedTag").checked, + rejectClearsReviewTag: $("rejectClearsReviewTag").checked, + }, + }; +} + +function tokenPayload() { + const token = $("token").value.trim(); + return token ? { token } : {}; +} + +function renderJobActivity(job) { + if (!job) return; + if (appState.activity.status === "running" && appState.activity.source !== "job") return; + const stats = job.stats || {}; + const status = job.status || "idle"; + const running = ["queued", "running", "paused"].includes(status); + const failed = status === "failed"; + const cancelled = status === "cancelled"; + const latest = job.latest?.title ? `Current item: ${job.latest.title}` : "Preparing the Audiobookshelf match queue…"; + appState.activity.source = "job"; + appState.activity.status = running ? "running" : failed ? "error" : cancelled ? "idle" : "success"; + appState.activity.startedAt = Date.parse(job.startedAt || "") || appState.activity.startedAt || Date.now(); + renderActivity({ + status: appState.activity.status, + title: running ? `Matching job ${status}` : failed ? "Matching job failed" : cancelled ? "Matching job cancelled" : "Matching job complete", + detail: running ? latest : `Matched ${stats.matched || 0}; review ${stats.review || 0}; errors ${stats.errors || 0}.`, + current: stats.processed || 0, + total: stats.total || 0, + startedAt: appState.activity.startedAt, + }); +} + +function renderJob(job) { + appState.job = job; + const stats = job?.stats || {}; + $("jobStatus").textContent = job?.status || "Idle"; + $("processedMetric").textContent = `${stats.processed || 0} / ${stats.total || 0}`; + $("matchedMetric").textContent = stats.matched || 0; + $("unmatchedMetric").textContent = stats.unmatched || 0; + $("reviewMetric").textContent = stats.review || 0; + $("latestItem").textContent = job?.latest?.title ? `Latest: ${job.latest.title}` : ""; + appState.logs = job?.logs || []; + renderLogs(); + renderJobActivity(job); + if (!appState.reviewRows.length && job?.reviewQueue?.length) { + appState.reviewRows = job.reviewQueue; + renderReviewDesk(); + } +} + +function logComparable(entry, sortKey) { + if (sortKey === "score") return Number(entry.score ?? -1); + if (sortKey === "level") return String(entry.level || ""); + if (sortKey === "title") return String(entry.title || entry.message || "").toLowerCase(); + return Date.parse(entry.time || "") || 0; +} + +function filteredLogs() { + const level = $("logLevel")?.value || "all"; + const search = String($("logSearch")?.value || "").toLowerCase().trim(); + const sortKey = $("logSort")?.value || "time"; + const direction = $("logDirection")?.value || "desc"; + const rows = appState.logs.filter((entry) => { + if (level !== "all" && entry.level !== level) return false; + if (!search) return true; + const haystack = [ + entry.message, + entry.title, + entry.author, + entry.candidate, + entry.level, + entry.score, + JSON.stringify(entry.reasons || []), + JSON.stringify(entry.searchAttempts || []), + JSON.stringify(entry.decision || {}), + ] + .filter((value) => value !== undefined && value !== null) + .join(" ") + .toLowerCase(); + return haystack.includes(search); + }); + rows.sort((a, b) => { + const left = logComparable(a, sortKey); + const right = logComparable(b, sortKey); + if (left < right) return direction === "asc" ? -1 : 1; + if (left > right) return direction === "asc" ? 1 : -1; + return 0; + }); + return rows; +} + +function providerLabel(provider) { + if (provider === "embedded") return "File metadata"; + if (provider === "google") return "Google Books"; + if (provider === "openlibrary") return "Open Library"; + return `ABS ${provider || "provider"}`; +} + +function renderSearchAttempts(attempts) { + if (!attempts?.length) return ""; + const steps = attempts.map((attempt) => { + const count = Number(attempt.resultCount || 0); + const status = attempt.status === "results" + ? `${count} result${count === 1 ? "" : "s"}` + : attempt.status === "evidence" + ? `${count} tagged file${count === 1 ? "" : "s"} used` + : attempt.status === "no_metadata" + ? "no usable tags" + : attempt.status === "no_results" + ? "no results" + : attempt.status === "skipped" + ? "skipped" + : attempt.status === "disabled" + ? "disabled" + : "error"; + const query = attempt.provider === "embedded" || !attempt.queryTitle + ? "" + : ` · “${escapeHtml(attempt.queryTitle)}”${attempt.queryAuthor ? ` by ${escapeHtml(attempt.queryAuthor)}` : " · title only"}`; + const detail = [ + attempt.strategy, + attempt.queryTitle ? `Title: ${attempt.queryTitle}` : "", + attempt.queryAuthor ? `Author: ${attempt.queryAuthor}` : "", + attempt.message, + attempt.error, + ].filter(Boolean).join(" | "); + return `${escapeHtml(providerLabel(attempt.provider))} ${escapeHtml(status)}${query}`; + }); + return `
Search path${steps.join('')}
`; +} + +function renderLogDecision(decision) { + if (!decision) return ""; + const policy = decision.policy || {}; + const reasons = decision.reasons || []; + const advisories = decision.advisories || []; + const scoreGate = decision.scorePassed + ? `Similarity ${decision.score ?? "-"} passed the ${policy.autoThreshold ?? "-"} auto threshold.` + : `Similarity ${decision.score ?? "-"} did not pass the ${policy.autoThreshold ?? "-"} auto threshold.`; + const duplicateNote = Number(decision.equivalentCandidateCount || 0) + ? ` ${decision.equivalentCandidateCount} duplicate-equivalent result${decision.equivalentCandidateCount === 1 ? " was" : "s were"} ignored for the margin check.` + : ""; + const evidence = policy.strict + ? `${decision.strongSignalCount ?? 0}/${policy.minimumStrongSignals ?? "-"} strong signals; different-result lead ${decision.margin ?? 0}/${policy.minimumWinnerMargin ?? "-"}.${duplicateNote}` + : "Safety gates are not required by the current policy."; + return ` +
+ ${escapeHtml(decision.action === "auto" ? "Automatically approved" : decision.action === "review" ? "Why it needs review" : "Why it was not matched")} + ${escapeHtml(scoreGate)} ${escapeHtml(evidence)} + ${reasons.length ? `${escapeHtml(reasons.join("; "))}` : ""} + ${advisories.length ? `Match note: ${escapeHtml(advisories.join("; "))}. This did not block approval.` : ""} +
+ `; +} + +function renderLogs() { + const root = $("log"); + const logs = filteredLogs(); + const pageSize = Number($("logPageSize")?.value || 10); + const pageCount = Math.max(1, Math.ceil(logs.length / pageSize)); + appState.logPage = Math.min(Math.max(1, appState.logPage), pageCount); + const start = (appState.logPage - 1) * pageSize; + const pageRows = logs.slice(start, start + pageSize); + $("logPageInfo").textContent = logs.length ? `Page ${appState.logPage} / ${pageCount} (${logs.length} log lines)` : "Page 0 / 0"; + $("logPrevBtn").disabled = appState.logPage <= 1; + $("logNextBtn").disabled = appState.logPage >= pageCount || !logs.length; + root.innerHTML = ""; + if (!pageRows.length) { + root.innerHTML = '
No run logs yet.
'; + return; + } + pageRows.forEach((entry) => { + const row = document.createElement("div"); + row.className = `log-entry ${entry.level || ""}`; + const score = entry.score !== undefined && entry.score !== null ? ` Similarity ${entry.score}` : ""; + const selectedProvider = entry.search?.provider + ? `
Selected from ${escapeHtml(providerLabel(entry.search.provider))}${entry.search.strategy ? ` via ${escapeHtml(entry.search.strategy)}` : ""}
` + : ""; + row.innerHTML = ` + ${entry.level || "info"}${score} +
${escapeHtml(entry.message || "")}
+
${escapeHtml(entry.time || "")}${entry.candidate ? ` | Candidate: ${escapeHtml(entry.candidate)}` : ""}
+ ${renderSearchAttempts(entry.searchAttempts)} + ${selectedProvider} + ${renderLogDecision(entry.decision)} + `; + root.appendChild(row); + }); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderPreview(preview) { + const panel = $("previewPanel"); + panel.classList.remove("hidden"); + const rows = preview.rows || []; + if (!rows.length) { + panel.innerHTML = "No eligible items found for these filters."; + return; + } + panel.innerHTML = ` + ${preview.totalEligible} eligible item(s), previewing ${rows.length} + ${rows + .map((row) => { + const best = row.best; + const candidate = best?.candidate || {}; + const decision = row.decision || {}; + return ` +
+
${escapeHtml(row.title)} ${escapeHtml(row.author || "")}
+ ${ + best + ? `
${best.score} | ${escapeHtml(candidate.title || "Untitled")} | ${escapeHtml(candidate.author || "")}
` + : "
No candidates returned
" + } +
${escapeHtml(decision.action || "unmatched")} · margin ${escapeHtml(decision.margin ?? 0)}${decision.reasons?.length ? ` · ${escapeHtml(decision.reasons.join("; "))}` : ""}
+ ${decision.advisories?.length ? `
Match note: ${escapeHtml(decision.advisories.join("; "))}. This did not block approval.
` : ""} + ${renderEmbeddedMetadata(row.embeddedMetadata)} + ${renderSearchAttempts(row.searchAttempts)} + ${row.searchDiagnostics?.length ? `
Provider warning: ${escapeHtml(row.searchDiagnostics.map((entry) => entry.message).join("; "))}
` : ""} + ${best?.parts ? `
Title ${best.parts.title} | Author ${best.parts.author} | Year ${best.parts.year} | Duration ${best.parts.duration}
` : ""} +
+ `; + }) + .join("")} + `; +} + +function candidateAuthor(candidate) { + const author = candidate.author || candidate.authorName || candidate.authors || ""; + if (Array.isArray(author)) { + return author.map((entry) => entry.name || entry).join(", "); + } + return String(author || ""); +} + +function candidateSeries(candidate) { + const series = candidate.series; + const label = (entry) => { + if (!entry || typeof entry !== "object") return String(entry || ""); + const name = entry.name || entry.series || entry.title || ""; + const sequence = entry.sequence || entry.seq || entry.number || ""; + return sequence ? `${name} #${sequence}` : name; + }; + if (Array.isArray(series)) return series.map(label).filter(Boolean).join(", "); + if (series && typeof series === "object") return label(series); + return series || ""; +} + +function candidateNarrator(candidate) { + const narrator = candidate.narrator || candidate.narrators || ""; + return Array.isArray(narrator) ? narrator.join(", ") : String(narrator || ""); +} + +function cssCoverStyle(url, tone = "candidate") { + if (!url) return ""; + const clean = String(url).replace(/['"\\\n\r]/g, ""); + const overlay = + tone === "source" + ? "linear-gradient(90deg, rgba(5, 32, 48, 0.94), rgba(8, 54, 70, 0.76))" + : "linear-gradient(90deg, rgba(3, 16, 8, 0.92), rgba(3, 16, 8, 0.7))"; + return `style="background-image: ${overlay}, url('${escapeHtml(clean)}')"`; +} + +function renderEmbeddedMetadata(metadata) { + if (!metadata || !metadata.status) return ""; + if (metadata.status === "error") { + return `
Embedded file metadata failed: ${escapeHtml(metadata.error || "unknown error")}
`; + } + const fileCount = Number(metadata.fileCount || 0); + const taggedCount = Number(metadata.taggedFileCount || 0); + if (metadata.status !== "found") { + return `
Embedded file metadata: no usable tags or filename titles in ${fileCount} associated audio file${fileCount === 1 ? "" : "s"}.
`; + } + const filenameTitles = (metadata.fileTitleCandidates || []) + .map((candidate) => candidate?.title || candidate) + .filter(Boolean); + const evidence = [ + metadata.title ? `Title: ${metadata.title}` : "", + filenameTitles.length ? `Filename title: ${filenameTitles.join(", ")}` : "", + metadata.author ? `Author: ${metadata.author}` : "", + metadata.series ? `Series: ${metadata.series}${metadata.seriesSequence ? ` #${metadata.seriesSequence}` : ""}` : "", + metadata.narrator ? `Narrator: ${metadata.narrator}` : "", + metadata.publishedYear ? `Year: ${metadata.publishedYear}` : "", + metadata.asin ? `ASIN: ${metadata.asin}` : "", + metadata.isbn ? `ISBN: ${metadata.isbn}` : "", + ].filter(Boolean); + return `
File evidence (${taggedCount}/${fileCount} tagged): ${escapeHtml(evidence.join(" | ") || "usable filename evidence found")}
`; +} + +function renderCurrentBookCard(item) { + return ` +
+
ABS ITEM
+
${escapeHtml(item.title || "Untitled")}
+
${escapeHtml(item.author || "Unknown author")}
+
Current Metadata
+ ${renderEmbeddedMetadata(item.embeddedMetadata)} +
+ More current info +
+ Series${escapeHtml(item.series || "-")} + Narrator${escapeHtml(item.narrator || "-")} + Year${escapeHtml(item.year || "-")} + ASIN/ISBN${escapeHtml([item.asin, item.isbn].filter(Boolean).join(" / ") || "-")} + Tags${escapeHtml((item.tags || []).join(", ") || "-")} + Path${escapeHtml(item.path || "-")} +
+ ${item.description ? `

${escapeHtml(String(item.description).slice(0, 320))}${String(item.description).length > 320 ? "..." : ""}

` : ""} +
+
+ `; +} + +function authorMatchInfo(scored) { + const authorScore = Number(scored.parts?.author ?? 0); + return { + score: authorScore, + matches: authorScore >= 75, + }; +} + +function renderCandidateCard(scored, rowIndex, candidateIndex, selectedIndex = 0) { + const candidate = scored.candidate || {}; + const checked = candidateIndex === selectedIndex ? "checked" : ""; + const series = candidateSeries(candidate); + const meta = [series, candidateNarrator(candidate), candidate.publishedYear].filter(Boolean).join(" | "); + const author = candidateAuthor(candidate) || "Unknown author"; + const authorInfo = authorMatchInfo(scored); + const searchOrigin = scored.searchSource === "manual" + ? `Manual search · ${scored.searchProvider || "provider"}` + : scored.search?.strategy + ? `Automated search · ${scored.search.strategy}` + : ""; + return ` +
+ + ${searchOrigin ? `
${escapeHtml(searchOrigin)}
` : ""} +
${escapeHtml(candidate.title || "Untitled")}
+
+ ${escapeHtml(author)} + ${authorInfo.matches ? `Author match ${Math.round(authorInfo.score)}` : `Author ${Math.round(authorInfo.score)}`} +
+
${scored.score} match score
+ ${series ? `
Series to write: ${escapeHtml(series)}${candidate._absidekickSeriesSource ? ` · ${escapeHtml(candidate._absidekickSeriesSource)}` : ""}
` : ""} + ${scored.strongSignals?.length ? `
Strong: ${escapeHtml(scored.strongSignals.join(", "))}
` : ""} + ${scored.conflicts?.length ? `
Check: ${escapeHtml(scored.conflicts.join("; "))}
` : ""} + ${scored.advisories?.length ? `
Note: ${escapeHtml(scored.advisories.join("; "))}
` : ""} +
+ More match info + ${meta ? `
${escapeHtml(meta)}
` : ""} + ${renderScoreParts(scored.parts)} +
+ Publisher${escapeHtml(candidate.publisher || "-")} + ASIN/ISBN${escapeHtml([candidate.asin, candidate.isbn].filter(Boolean).join(" / ") || "-")} + Provider Rank${Number(scored.index ?? 0) + 1} +
+ ${candidate.description ? `

${escapeHtml(String(candidate.description).slice(0, 320))}${String(candidate.description).length > 320 ? "..." : ""}

` : ""} +
+
+ `; +} + +function renderScoreParts(parts) { + if (!parts) return ""; + const authorScore = Number(parts.author ?? 0); + return ` +
+ Title ${parts.title ?? "-"} + Author ${parts.author ?? "-"} + Series ${parts.series ?? "-"} + Narrator ${parts.narrator ?? "-"} + Year ${parts.year ?? "-"} + Duration ${parts.duration ?? "-"} +
+ `; +} + +function renderMatchDecision(decision) { + if (!decision) return ""; + const reasons = decision.reasons || []; + const advisories = decision.advisories || []; + const policy = decision.policy || {}; + const scoreStatus = decision.scorePassed + ? `Similarity ${decision.score ?? "-"} passed ${policy.autoThreshold ?? "-"}` + : `Similarity ${decision.score ?? "-"} is below ${policy.autoThreshold ?? "-"}`; + const duplicateNote = Number(decision.equivalentCandidateCount || 0) + ? `; ${decision.equivalentCandidateCount} duplicate-equivalent result${decision.equivalentCandidateCount === 1 ? " ignored" : "s ignored"}` + : ""; + const evidenceStatus = policy.strict + ? `${decision.strongSignalCount ?? 0}/${policy.minimumStrongSignals ?? "-"} strong signals; different-result lead ${decision.margin ?? 0}/${policy.minimumWinnerMargin ?? "-"}${duplicateNote}` + : "Safety checks are not blocking in this policy"; + return ` +
+ ${decision.action === "auto" ? "High-confidence match" : decision.action === "review" ? "Human review required" : "Insufficient evidence"} + ${escapeHtml(scoreStatus)} | ${escapeHtml(evidenceStatus)} + ${reasons.length ? `${escapeHtml(reasons.join(" · "))}` : ""} + ${advisories.length ? `Match note: ${escapeHtml(advisories.join(" · "))}. This did not block approval.` : ""} +
+ `; +} + +function reviewSearchState(row) { + if (!row.manualSearch) { + row.manualSearch = { + title: row.item?.title || "", + author: row.item?.author || "", + provider: $("provider")?.value || appState.settings?.connection?.provider || "audible", + limit: 20, + open: false, + loading: false, + error: "", + result: null, + }; + } + return row.manualSearch; +} + +function renderManualSearchOutcome(row, rowIndex) { + const search = reviewSearchState(row); + if (search.loading) { + return ` +
+
+ SEARCHING NOW + Checking ${escapeHtml(search.provider)} for “${escapeHtml(search.title || "all titles")}”. This review stays open while results load. +
+
+ `; + } + if (search.error) { + return ` + + `; + } + const result = search.result; + if (!result) { + return ` +
+
READY TO RESEARCHThe search runs here. This row will remain open and report whether it found a confident match.
+
+ `; + } + + const resultCount = Number(result.resultCount || 0); + const confident = Boolean(result.isConfidentMatch); + const hasCandidates = resultCount > 0; + const heading = confident ? "MATCH FOUND" : hasCandidates ? "NO CONFIDENT MATCH — REVIEW RESULTS" : "NO MATCH FOUND"; + const className = confident ? "match" : hasCandidates ? "review" : "unmatched"; + const best = result.bestCandidate || null; + const candidate = best?.candidate || {}; + const bestLine = best + ? `Best result: ${escapeHtml(candidate.title || "Untitled")}${candidateAuthor(candidate) ? ` by ${escapeHtml(candidateAuthor(candidate))}` : ""} · score ${escapeHtml(best.score ?? "-")}` + : ""; + const reasons = result.decision?.reasons || []; + return ` +
+
+ ${heading} + ${escapeHtml(result.message || "Search completed.")} + ${bestLine} + ${reasons.length ? `Policy: ${escapeHtml(reasons.join(" · "))}` : ""} + ${resultCount} result${resultCount === 1 ? "" : "s"} returned by ${escapeHtml(search.provider)} +
+
+ ${hasCandidates ? `` : ""} + +
+
+ `; +} + +function renderReviewSearch(row, rowIndex) { + const search = reviewSearchState(row); + const options = REVIEW_PROVIDERS.map( + (provider) => ``, + ).join(""); + const keepOpen = search.open || search.loading || search.result || search.error; + return ` + + `; +} + +function candidateIdentity(scored) { + const candidate = scored?.candidate || {}; + const identifier = candidate.asin || candidate.isbn || candidate.id || candidate.bookId; + if (identifier) return `id:${String(identifier).toLowerCase()}`; + return [candidate.title, candidateAuthor(candidate), candidate.publishedYear] + .map((value) => String(value || "").toLowerCase().trim()) + .join("|"); +} + +function mergeReviewCandidates(manualCandidates, existingCandidates) { + const seen = new Set(); + return [...manualCandidates, ...existingCandidates].filter((scored) => { + const identity = candidateIdentity(scored); + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} + +function renderGoogleBooksStatus(providers = {}) { + const status = $("googleBooksStatus"); + const result = $("googleBooksResult"); + const input = $("googleBooksApiKey"); + const clearButton = $("clearGoogleBooksBtn"); + const hasKey = Boolean(providers.hasGoogleBooksApiKey); + const ready = Boolean(providers.googleBooksReady); + const lastError = String(providers.googleBooksLastError || "").trim(); + + status.className = `provider-state ${ready ? "ready" : hasKey ? "untested" : "missing"}`; + status.textContent = ready + ? lastError ? "Saved · last test interrupted" : "API key saved & enabled" + : hasKey ? "Saved · test required" : "Not configured"; + input.placeholder = hasKey + ? "Stored key is hidden — paste only to replace it" + : "Paste a new key, then test it"; + clearButton.disabled = !hasKey; + + result.className = "provider-test-result"; + if (ready) { + const when = providers.googleBooksApiKeyValidatedAt + ? new Date(providers.googleBooksApiKeyValidatedAt).toLocaleString() + : "recently"; + result.classList.add(lastError ? "warning" : "success"); + result.textContent = lastError + ? `The key remains enabled because it passed validation ${when}. The latest test was interrupted by a temporary provider error: ${lastError}` + : `Saved privately on this server; you do not need to enter the key again. Live Google Books test passed ${when}. Automatic runs try ABS first, native Google second, and Open Library third.`; + } else if (hasKey) { + result.classList.add("warning"); + result.textContent = providers.googleBooksLastError || "The saved key must pass a live test before Google searches are enabled."; + } else { + result.textContent = "Google second pass is disabled. MyAnonaSuite will skip Google and continue to Open Library until a key passes the live test."; + } +} + +function renderOpenLibraryStatus() { + const enabled = $("openLibraryEnabled").checked; + const contact = $("openLibraryContactEmail").value.trim(); + const status = $("openLibraryStatus"); + const result = $("openLibraryResult"); + status.className = `provider-state ${enabled ? "ready" : "missing"}`; + status.textContent = enabled ? (contact ? "Identified & enabled" : "Enabled") : "Disabled"; + result.className = `provider-test-result ${enabled ? "success" : "warning"}`; + result.textContent = enabled + ? contact + ? "Third-stage and manual searches are enabled at the identified-client limit of 3 requests/second. Results are cached during each run." + : "Third-stage and manual searches are enabled at the anonymous limit of 1 request/second. Add a contact email for Open Library's identified-client limit." + : "Open Library automatic and Review Desk searches are disabled."; +} + +function googleKeyPayload() { + const key = $("googleBooksApiKey").value.trim(); + return key ? { googleBooksApiKey: key } : {}; +} + +function openLibraryPayload() { + return { + openLibraryEnabled: $("openLibraryEnabled").checked, + openLibraryContactEmail: $("openLibraryContactEmail").value.trim(), + }; +} + +function ensureProviderReady(provider) { + if (provider === "google" && !appState.settings?.providers?.googleBooksReady) { + throw new Error( + "Google Books is disabled. Open ABSidekick Config, add an API key, and select Test & Enable first. No Google request was sent.", + ); + } + if (provider === "openlibrary" && !$("openLibraryEnabled").checked) { + throw new Error("Open Library is disabled. Enable it in ABSidekick Config before selecting it as the primary or manual provider."); + } +} + +function selectedCandidateIndex(row) { + const candidates = row.candidates || []; + if (!row.selectedCandidateIdentity) return 0; + const index = candidates.findIndex((candidate) => candidateIdentity(candidate) === row.selectedCandidateIdentity); + return index >= 0 ? index : 0; +} + +function renderReviewDesk({ preserveRow = null } = {}) { + const root = $("reviewDesk"); + const oldRow = preserveRow === null ? null : root.querySelector(`[data-review-row="${preserveRow}"]`); + const oldTop = oldRow?.getBoundingClientRect().top; + const rows = appState.reviewRows || []; + if (!rows.length) { + root.innerHTML = '
No review items loaded. Scan review tags or run automatch to build this queue.
'; + return; + } + root.innerHTML = rows + .map((row, rowIndex) => { + const item = row.item || {}; + const candidates = row.candidates || []; + const selectedIndex = selectedCandidateIndex(row); + return ` +
+
+
+

Review #${rowIndex + 1}

+

${escapeHtml(item.title || "Untitled")}

+
${escapeHtml(item.author || "")}${item.path ? ` | ${escapeHtml(item.path)}` : ""}
+
+
+ + +
+
+ ${renderMatchDecision(row.decision)} + ${renderSearchAttempts(row.searchAttempts)} + ${row.searchDiagnostics?.length ? `
${escapeHtml(row.searchDiagnostics.map((entry) => `${entry.provider}: ${entry.error}`).join(" · "))}
` : ""} + ${renderReviewSearch(row, rowIndex)} +
+ ${renderCurrentBookCard(item)} + ${ + candidates.length + ? candidates.map((scored, candidateIndex) => renderCandidateCard(scored, rowIndex, candidateIndex, selectedIndex)).join("") + : '
No candidates are available yet. Use the manual research fields above now, or reject this item.
' + } +
+
+ `; + }) + .join(""); + if (oldTop !== undefined) { + const newRow = root.querySelector(`[data-review-row="${preserveRow}"]`); + if (newRow) window.scrollBy(0, newRow.getBoundingClientRect().top - oldTop); + } +} + +async function scanReview() { + const settings = getSettingsFromForm(); + ensureProviderReady(settings.connection.provider); + const payload = await api("/api/review/scan", { + method: "POST", + body: JSON.stringify({ settings, limit: settings.review.scanLimit, ...tokenPayload() }), + }); + appState.reviewRows = payload.review.rows || []; + renderReviewDesk(); + showToast(`Loaded ${appState.reviewRows.length} review item(s)`); + return payload; +} + +function loadJobReviewQueue() { + const rows = appState.job?.reviewQueue || []; + appState.reviewRows = rows; + renderReviewDesk(); + showToast(`Loaded ${rows.length} item(s) from the current job queue`); + reportInstantActivity("Job review queue loaded", `${rows.length} review item(s) are now displayed.`); +} + +async function approveReview(rowIndex) { + const row = appState.reviewRows[rowIndex]; + if (!row) return ACTION_CANCELLED; + const selected = document.querySelector(`input[name="review-${rowIndex}"]:checked`); + const candidateIndex = Number(selected?.value || 0); + const candidate = row.candidates?.[candidateIndex]; + if (!candidate) { + showToast("No candidate selected"); + return ACTION_CANCELLED; + } + const confirmed = window.confirm(`Approve this match for "${row.item.title}"? This writes to Audiobookshelf.`); + if (!confirmed) return ACTION_CANCELLED; + appState.reviewRows.splice(rowIndex, 1); + renderReviewDesk(); + try { + const payload = await api("/api/review/approve", { + method: "POST", + body: JSON.stringify({ settings: getSettingsFromForm(), itemId: row.item.id, candidate, row, ...tokenPayload() }), + }); + showToast("Review match approved"); + return payload; + } catch (error) { + appState.reviewRows.splice(rowIndex, 0, row); + renderReviewDesk(); + throw error; + } +} + +async function rejectReview(rowIndex) { + const row = appState.reviewRows[rowIndex]; + if (!row) return ACTION_CANCELLED; + appState.reviewRows.splice(rowIndex, 1); + renderReviewDesk(); + try { + const payload = await api("/api/review/reject", { + method: "POST", + body: JSON.stringify({ settings: getSettingsFromForm(), itemId: row.item.id, row, ...tokenPayload() }), + }); + showToast("Review item rejected"); + return payload; + } catch (error) { + appState.reviewRows.splice(rowIndex, 0, row); + renderReviewDesk(); + throw error; + } +} + +async function loadState() { + const payload = await api("/api/state"); + setForm(payload.settings); + renderJob(payload.job); + resumeBackendActivity(payload.activity); + renderReviewDesk(); +} + +async function searchReview(rowIndex, form) { + const row = appState.reviewRows[rowIndex]; + if (!row) return; + const formData = new FormData(form); + const search = reviewSearchState(row); + search.title = String(formData.get("title") || "").trim(); + search.author = String(formData.get("author") || "").trim(); + search.provider = String(formData.get("provider") || "audible"); + ensureProviderReady(search.provider); + search.limit = Number(formData.get("limit") || 20); + search.open = true; + search.loading = true; + search.error = ""; + search.result = null; + const searchPanel = form.closest(".review-search"); + if (searchPanel) { + searchPanel.open = true; + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + submitButton.disabled = true; + submitButton.textContent = "Searching…"; + } + const outcome = searchPanel.querySelector("[data-manual-search-outcome]"); + if (outcome) outcome.outerHTML = renderManualSearchOutcome(row, rowIndex); + } + try { + const payload = await api("/api/review/search", { + method: "POST", + body: JSON.stringify({ + settings: getSettingsFromForm(), + itemId: row.item.id, + query: { + title: search.title, + author: search.author, + provider: search.provider, + limit: search.limit, + }, + ...tokenPayload(), + }), + }); + const candidates = payload.candidates || []; + row.item = payload.item || row.item; + row.candidates = mergeReviewCandidates(candidates, row.candidates || []); + row.decision = payload.decision || row.decision; + if (candidates.length) { + row.selectedCandidateIdentity = candidateIdentity(candidates[0]); + } + search.result = { + ...(payload.manualMatch || {}), + decision: payload.decision || null, + resultCount: Number(payload.resultCount ?? candidates.length), + bestCandidate: payload.manualMatch?.bestCandidate || candidates[0] || null, + }; + const verdict = search.result.isConfidentMatch + ? "Confident match found" + : candidates.length + ? "Results need your review" + : "No match found"; + showToast(verdict); + return payload; + } catch (error) { + search.error = `${error.message}. Try broader terms or another provider.`; + search.result = null; + throw error; + } finally { + search.loading = false; + renderReviewDesk({ preserveRow: rowIndex }); + } +} + +async function connect() { + const payload = await api("/api/connect", { + method: "POST", + body: JSON.stringify({ + settings: getSettingsFromForm(), + ...tokenPayload(), + ...googleKeyPayload(), + ...openLibraryPayload(), + }), + }); + setForm(payload.settings); + populateLibraries(normalizeLibraryList(payload)); + showToast(payload.message || "Connected"); + return payload; +} + +async function saveSettings() { + const payload = await api("/api/settings", { + method: "POST", + body: JSON.stringify({ + settings: getSettingsFromForm(), + ...tokenPayload(), + ...googleKeyPayload(), + ...openLibraryPayload(), + }), + }); + setForm(payload.settings); + showToast("Settings saved"); + return payload; +} + +async function testGoogleBooks() { + const button = $("testGoogleBooksBtn"); + const result = $("googleBooksResult"); + button.disabled = true; + button.textContent = "Testing live API…"; + result.className = "provider-test-result testing"; + result.textContent = "Sending one test query to the official Google Books API…"; + try { + const payload = await api("/api/provider/google/test", { + method: "POST", + body: JSON.stringify({ + settings: getSettingsFromForm(), + ...tokenPayload(), + ...googleKeyPayload(), + }), + }); + setForm(payload.settings); + showToast(payload.message || "Google Books API key tested and enabled"); + return payload; + } catch (error) { + try { + await loadState(); + } catch (_stateError) { + // Keep the provider's actionable test error visible even if state reload fails. + } + result.className = "provider-test-result error"; + result.textContent = `${error.message} Google searches remain disabled.`; + throw error; + } finally { + button.disabled = false; + button.textContent = "Test & Enable"; + } +} + +async function clearGoogleBooks() { + if (!window.confirm("Remove the saved Google Books API key and disable all Google searches?")) return ACTION_CANCELLED; + const payload = await api("/api/provider/google/clear", { + method: "POST", + body: "{}", + }); + setForm(payload.settings); + showToast(payload.message || "Google Books API key removed"); + return payload; +} + +async function loadFilterData() { + const libraryId = $("libraryId").value; + if (!libraryId) { + throw new Error("Select a library first"); + } + const payload = await api(`/api/filter-data?libraryId=${encodeURIComponent(libraryId)}`); + populateFilterData(payload.filterData); + return payload; +} + +async function preview() { + ensureProviderReady($("provider").value); + const payload = await api("/api/preview", { + method: "POST", + body: JSON.stringify({ settings: getSettingsFromForm(), limit: 10, ...tokenPayload() }), + }); + renderPreview(payload.preview); + showToast("Preview loaded"); + return payload; +} + +async function startJob() { + const settings = getSettingsFromForm(); + ensureProviderReady(settings.connection.provider); + if (!settings.run.dryRun) { + const confirmed = window.confirm("This run will write metadata/tags to Audiobookshelf. Start anyway?"); + if (!confirmed) return ACTION_CANCELLED; + } + const payload = await api("/api/job/start", { + method: "POST", + body: JSON.stringify({ settings, ...tokenPayload() }), + }); + renderJob(payload.job); + startPolling(); + showToast("Job started"); + return payload; +} + +async function jobAction(action) { + const payload = await api(`/api/job/${action}`, { method: "POST", body: "{}" }); + renderJob(payload.job); + return payload; +} + +async function pollJob() { + try { + const payload = await api("/api/job"); + renderJob(payload.job); + const status = payload.job?.status; + if (!["queued", "running", "paused"].includes(status)) { + stopPolling(); + } + } catch (error) { + console.error(error); + } +} + +function startPolling() { + stopPolling(); + appState.poll = window.setInterval(pollJob, 1500); +} + +function stopPolling() { + if (appState.poll) window.clearInterval(appState.poll); + appState.poll = null; +} + +function exportLog() { + const job = appState.job; + if (!job) { + showToast("No job log to export"); + reportInstantActivity("Nothing to export", "Run a matching job before exporting its log."); + return; + } + const blob = new Blob([JSON.stringify(job, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `absidekick-job-${job.id}.json`; + link.click(); + URL.revokeObjectURL(url); + reportInstantActivity("Job log exported", "The JSON log download was created."); +} + +function wireEvents() { + document.querySelectorAll(".tab").forEach((button) => { + button.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((tab) => tab.classList.remove("active")); + document.querySelectorAll(".tab-panel").forEach((panel) => panel.classList.remove("active")); + button.classList.add("active"); + $(`tab-${button.dataset.tab}`).classList.add("active"); + }); + }); + $("matchPolicyPreset").addEventListener("change", (event) => { + applyMatchingPolicyPreset(event.target.value); + }); + [...MATCH_POLICY_FIELDS, "strictAutoMatch"].forEach((id) => { + $(id).addEventListener("input", renderPolicySummary); + $(id).addEventListener("change", renderPolicySummary); + }); + ["openLibraryEnabled", "openLibraryContactEmail"].forEach((id) => { + $(id).addEventListener("input", renderOpenLibraryStatus); + $(id).addEventListener("change", renderOpenLibraryStatus); + }); + $("token").addEventListener("input", () => { + if ($("token").value.trim()) { + $("rememberConnection").checked = true; + $("absTokenStatus").className = "provider-state untested"; + $("absTokenStatus").textContent = "Will save on connect"; + $("connectionNote").textContent = "This new API token will be stored privately when you select Connect or Save Settings."; + } + }); + $("connectBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Connecting to Audiobookshelf", + detail: "Checking the server URL and token, then loading available libraries…", + busyText: "Connecting…", + success: (payload) => payload?.message || "Audiobookshelf connection is ready.", + }, connect)); + $("saveSettingsBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Saving ABSidekick settings", + detail: "Validating and writing module settings to the private data folder…", + busyText: "Saving…", + success: "Settings were saved and will apply to the next action.", + }, saveSettings)); + $("testGoogleBooksBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Testing Google Books", + detail: "Sending one validation query to the official Google Books API…", + busyText: "Testing…", + success: "The API key passed its live test; native Google searches are enabled.", + }, testGoogleBooks)); + $("clearGoogleBooksBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Removing Google Books key", + detail: "Waiting for confirmation, then removing the stored private key…", + busyText: "Removing…", + success: "The key was removed and Google searches are disabled.", + }, clearGoogleBooks)); + $("loadFiltersBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Loading library filters", + detail: "Requesting authors, tags, and series from the selected ABS library…", + busyText: "Loading…", + success: "Library filter choices are ready.", + }, loadFilterData)); + $("previewBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Building match preview", + detail: "Loading eligible ABS items and searching the selected metadata provider…", + busyText: "Previewing…", + success: "The dry-run match preview is ready.", + }, preview)); + $("startBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Starting matching job", + detail: "Validating run policy and creating the background matching queue…", + busyText: "Starting…", + success: "The background job started; live item counts will continue here.", + }, startJob)); + $("pauseBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Pausing matching job", + detail: "Waiting for the current safe checkpoint…", + busyText: "Pausing…", + success: "The matching job is paused.", + }, () => jobAction("pause"))); + $("resumeBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Resuming matching job", + detail: "Releasing the paused background worker…", + busyText: "Resuming…", + success: "The matching job resumed.", + }, () => jobAction("resume"))); + $("cancelBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Cancelling matching job", + detail: "Requesting a stop at the next safe checkpoint…", + busyText: "Cancelling…", + success: "Cancellation was requested.", + }, () => jobAction("cancel"))); + $("exportBtn").addEventListener("click", exportLog); + $("scanReviewBtn").addEventListener("click", (event) => runVisibleAction(event.currentTarget, { + title: "Scanning Review Tags", + successTitle: "Review scan complete", + detail: "Loading review-tagged ABS items; live item and provider progress will appear here…", + busyText: "Scanning…", + pollBackend: true, + success: () => `Review scan finished with ${appState.reviewRows.length} item(s) ready for review.`, + }, scanReview)); + $("loadJobReviewBtn").addEventListener("click", loadJobReviewQueue); + $("reviewDesk").addEventListener("click", (event) => { + const button = event.target.closest("[data-review-action]"); + if (!button) return; + const row = Number(button.dataset.row); + const title = appState.reviewRows[row]?.item?.title || `review item ${row + 1}`; + if (button.dataset.reviewAction === "approve") { + runVisibleAction(button, { + title: `Approving ${title}`, + detail: "Writing the selected metadata to Audiobookshelf and clearing review state…", + busyText: "Approving…", + success: "The selected match was written to Audiobookshelf.", + }, () => approveReview(row)); + } + if (button.dataset.reviewAction === "reject") { + runVisibleAction(button, { + title: `Rejecting ${title}`, + detail: "Updating ABS tags and saving the review decision…", + busyText: "Rejecting…", + success: "The item was rejected and its review state was saved.", + }, () => rejectReview(row)); + } + }); + $("reviewDesk").addEventListener("submit", (event) => { + const form = event.target.closest("[data-review-search-form]"); + if (!form) return; + event.preventDefault(); + const row = Number(form.dataset.row); + const button = form.querySelector('button[type="submit"]'); + const title = appState.reviewRows[row]?.item?.title || `review item ${row + 1}`; + runVisibleAction(button, { + title: `Researching ${title}`, + detail: "Searching the selected provider and rescoring every returned candidate…", + busyText: "Searching…", + success: (payload) => `${payload?.resultCount || 0} candidate(s) returned; the review row has been updated.`, + }, () => searchReview(row, form)); + }); + $("reviewDesk").addEventListener("change", (event) => { + const radio = event.target.closest('input[type="radio"][name^="review-"]'); + if (!radio) return; + const rowIndex = Number(radio.name.slice("review-".length)); + const row = appState.reviewRows[rowIndex]; + const candidate = row?.candidates?.[Number(radio.value)]; + if (row && candidate) row.selectedCandidateIdentity = candidateIdentity(candidate); + }); + ["logSearch", "logLevel", "logSort", "logDirection", "logPageSize"].forEach((id) => { + $(id).addEventListener("input", () => { + appState.logPage = 1; + renderLogs(); + }); + $(id).addEventListener("change", () => { + appState.logPage = 1; + renderLogs(); + }); + }); + $("logPrevBtn").addEventListener("click", () => { + appState.logPage -= 1; + renderLogs(); + }); + $("logNextBtn").addEventListener("click", () => { + appState.logPage += 1; + renderLogs(); + }); +} + +wireEvents(); +const initialView = $("absidekickModule")?.dataset.initialView || "run"; +document.querySelectorAll(".absidekick-module .tab").forEach((tab) => { + tab.classList.toggle("active", tab.dataset.tab === initialView); +}); +document.querySelectorAll(".absidekick-module .tab-panel").forEach((panel) => { + panel.classList.toggle("active", panel.id === `tab-${initialView}`); +}); +loadState() + .then(() => { + if (appState.job?.status && ["queued", "running", "paused"].includes(appState.job.status)) { + startPolling(); + } + }) + .catch((error) => showToast(error.message)); diff --git a/python/src/mlm/static/app.css b/python/src/mlm/static/app.css new file mode 100644 index 00000000..41538177 --- /dev/null +++ b/python/src/mlm/static/app.css @@ -0,0 +1,3902 @@ +:root { + color-scheme: dark; + --bg: #020604; + --bg-raised: #051009; + --panel: rgba(5, 18, 10, 0.92); + --panel-strong: rgba(8, 29, 15, 0.96); + --surface: #020a05; + --surface-hover: #08170d; + --text: #b7f7bf; + --text-strong: #e4ffe7; + --muted: #73ad7b; + --dim: #376a40; + --line: #2bdc58; + --accent-rgb: 43, 220, 88; + --line-soft: rgba(43, 220, 88, 0.22); + --accent: #7cff91; + --accent-strong: #c8ffd1; + --danger: #ff6878; + --warning: #e6ee70; + --info: #75d7ff; + --shadow: rgba(43, 220, 88, 0.15); + --radius: 6px; + --sidebar: 256px; + font-family: "Cascadia Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--bg); +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at 75% -10%, rgba(var(--accent-rgb), 0.08), transparent 32rem), + linear-gradient(180deg, var(--bg-raised) 0%, var(--bg) 44%, #010302 100%); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} + +body::before { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + content: ""; + background: repeating-linear-gradient( + 180deg, + rgba(var(--accent-rgb), 0.022) 0, + rgba(var(--accent-rgb), 0.022) 1px, + transparent 1px, + transparent 4px + ); +} + +body[data-suite="absidekick"] { + --bg: #02070b; + --bg-raised: #06131a; + --panel: rgba(5, 20, 29, 0.92); + --panel-strong: rgba(8, 31, 44, 0.96); + --surface: #020b10; + --surface-hover: #071a24; + --text: #b8eaff; + --text-strong: #e4f8ff; + --muted: #76abc0; + --dim: #3b7186; + --line: #38c9ff; + --accent-rgb: 56, 201, 255; + --line-soft: rgba(56, 201, 255, 0.22); + --accent: #7edcff; + --accent-strong: #d1f4ff; + --shadow: rgba(56, 201, 255, 0.15); +} + +body[data-suite="mam-spender"] { + --bg: #090601; + --bg-raised: #171006; + --panel: rgba(27, 18, 5, 0.92); + --panel-strong: rgba(43, 28, 7, 0.96); + --surface: #0e0902; + --surface-hover: #201506; + --text: #f4dba6; + --text-strong: #fff2cf; + --muted: #b99a5f; + --dim: #755a2a; + --line: #f2ad32; + --accent-rgb: 242, 173, 50; + --line-soft: rgba(242, 173, 50, 0.23); + --accent: #ffc65d; + --accent-strong: #ffe8b4; + --shadow: rgba(242, 173, 50, 0.16); +} + +/* MAM-Spender keeps its own controls while sharing the suite shell. */ +.spender-hero { + align-items: flex-end; +} + +.spender-view-tabs { + display: flex; + gap: 7px; + flex-wrap: wrap; + margin: -4px 0 14px; + padding: 8px; + border: 1px solid var(--line-soft); + background: var(--surface); +} + +.spender-view-tabs a { + padding: 8px 12px; + border: 1px solid transparent; + color: var(--muted); + font: 700 11px/1.2 var(--mono); + letter-spacing: 0.07em; + text-decoration: none; + text-transform: uppercase; +} + +.spender-view-tabs a:hover { + border-color: var(--line-soft); + color: var(--text-strong); +} + +.spender-view-tabs a.active { + border-color: var(--line); + background: rgba(var(--accent-rgb), 0.13); + color: var(--accent-strong); + box-shadow: inset 0 -2px 0 var(--accent); +} + +.spender-run-controls, +.spender-setting-actions, +.spender-chart-controls, +.spender-pagination, +.panel-heading-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.panel-heading-row { + justify-content: space-between; +} + +.spender-marquee { + margin: -6px 0 22px; + overflow: hidden; + border: 1px solid rgba(172, 255, 71, 0.2); + background: rgba(6, 18, 10, 0.82); + color: var(--accent-strong); + font: 700 11px/1.2 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.spender-marquee > div { + padding: 10px 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.spender-notice { + position: sticky; + z-index: 8; + top: 14px; + margin: 0 0 18px; + padding: 12px 15px; + border: 1px solid rgba(95, 255, 143, 0.48); + background: rgba(6, 26, 14, 0.96); + color: var(--accent-strong); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +.spender-notice.error { + border-color: rgba(255, 92, 112, 0.58); + color: #ffb4bd; +} + +.spender-metrics { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.metric-value.compact { + font-size: clamp(1.15rem, 2vw, 1.7rem); +} + +.spender-dashboard-grid { + align-items: stretch; + margin-top: 18px; +} + +.spender-dashboard-settings { + align-items: start; + margin-top: 18px; +} + +.spender-dashboard-config-help { + min-height: 100%; +} + +.spender-console { + min-height: 420px; +} + +.spender-log { + height: 370px; + overflow: auto; + padding: 13px 15px; + font: 12px/1.55 var(--mono); + color: var(--text); +} + +.spender-log p { + margin: 0 0 7px; + padding-bottom: 7px; + border-bottom: 1px solid rgba(103, 255, 145, 0.08); +} + +.spender-log p.error { + color: #ff9fac; +} + +.spender-log p.success { + color: var(--accent-strong); +} + +.spender-log time { + color: var(--muted); +} + +.spender-log details { + margin: 5px 0 0 14px; +} + +.spender-log pre { + white-space: pre-wrap; +} + +.spender-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + margin: 18px 0; + background: rgba(105, 255, 143, 0.12); +} + +.spender-facts div { + min-width: 0; + padding: 13px; + background: rgba(3, 12, 7, 0.96); +} + +.spender-facts dt, +.spender-account-grid span { + margin-bottom: 6px; + color: var(--muted); + font: 700 10px/1.2 var(--mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.spender-facts dd { + margin: 0; + overflow-wrap: anywhere; + color: var(--accent-strong); + font-family: var(--mono); +} + +.spender-mode-summary { + margin: 0 0 16px; + padding: 11px 12px; + border-left: 2px solid var(--accent); + background: rgba(68, 255, 117, 0.06); + color: var(--muted); + font-family: var(--mono); +} + +.spender-safety-panel { + margin-top: 18px; +} + +.spender-guardrail-grid, +.spender-account-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 16px 0; +} + +.spender-guardrail-grid > div, +.spender-account-grid > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 5px; + padding: 14px; + border: 1px solid rgba(117, 255, 152, 0.13); + background: rgba(7, 19, 11, 0.66); +} + +.spender-guardrail-grid strong, +.spender-account-grid strong { + overflow-wrap: anywhere; + color: var(--accent-strong); + font: 700 18px/1.2 var(--mono); +} + +.spender-guardrail-grid span { + color: var(--muted); + font-size: 12px; +} + +.spender-settings-grid { + align-items: start; +} + +.spender-config-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin: 0 0 18px; +} + +.spender-config-heading h2 { + margin: 0; +} + +.spender-settings h2, +.spender-settings-side h2 { + margin-top: 0; +} + +.toggle-setting { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 0 -4px; + padding: 13px 4px; + border-bottom: 1px solid rgba(105, 255, 143, 0.1); + cursor: pointer; +} + +.toggle-setting input { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--accent); +} + +.toggle-setting span { + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; +} + +.toggle-setting small, +.spender-settings label small { + color: var(--muted); +} + +.spender-settings .form-grid { + margin: 22px 0; +} + +.spender-settings label:not(.toggle-setting) { + display: flex; + flex-direction: column; + gap: 7px; +} + +.spender-settings-side { + display: grid; + gap: 18px; +} + +.spender-file-field { + display: grid; + gap: 7px; + margin-top: 14px; + color: var(--text-strong); +} + +.spender-file-field input[type="file"] { + width: 100%; + border: 1px dashed var(--dim); + background: var(--surface); + color: var(--muted); + padding: 10px; +} + +.spender-file-field small { + color: var(--muted); +} + +.spender-number-setting { + display: grid; + align-content: start; + gap: 10px; +} + +.spender-setting-presets { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.spender-setting-presets .chip { + min-height: 31px; + padding: 5px 9px; +} + +.spender-settings textarea, +.spender-settings-side textarea { + width: 100%; + margin: 12px 0; + resize: vertical; +} + +.spender-instructions { + margin-top: 18px; + border-top: 1px solid rgba(105, 255, 143, 0.14); + padding-top: 14px; +} + +.spender-instructions summary { + cursor: pointer; + color: var(--accent-strong); + font-weight: 700; +} + +.spender-instructions > div { + max-width: 850px; + padding: 10px 0; + color: var(--muted); +} + +.spender-runtime-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 18px 0; +} + +.spender-runtime-grid > div { + display: flex; + min-width: 0; + flex-direction: column; + gap: 6px; + padding: 13px; + border: 1px solid var(--line-soft); + background: var(--surface); +} + +.spender-runtime-grid span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.spender-runtime-grid strong { + overflow-wrap: anywhere; + color: var(--accent-strong); +} + +.spender-table td:first-child { + white-space: nowrap; +} + +.spender-pagination { + justify-content: flex-end; + padding: 14px 18px; + color: var(--muted); +} + +.spender-chart-panel canvas { + display: block; + width: 100%; + max-height: 420px; + margin-top: 16px; + border: 1px solid rgba(105, 255, 143, 0.14); + background: rgba(2, 10, 5, 0.78); +} + +.spender-chart-legend { + display: flex; + gap: 18px; + flex-wrap: wrap; + padding-top: 14px; + color: var(--muted); + font-family: var(--mono); +} + +.spender-chart-legend span::before { + display: inline-block; + width: 9px; + height: 9px; + margin-right: 7px; + border-radius: 50%; + background: var(--legend); + content: ""; +} + +.spender-account-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.spender-notifications { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.spender-notifications p { + margin: 0; + padding: 11px 13px; + border-left: 2px solid var(--accent); + background: rgba(105, 255, 143, 0.05); +} + +.spender-link-stack { + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 9px; +} + +@media (max-width: 1050px) { + .spender-metrics, + .spender-account-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .spender-guardrail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .spender-runtime-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 680px) { + .spender-hero { + align-items: flex-start; + } + + .spender-run-controls, + .spender-setting-actions { + align-items: stretch; + flex-direction: column; + width: 100%; + } + + .spender-run-controls .button, + .spender-setting-actions .button { + justify-content: center; + } + + .spender-metrics, + .spender-account-grid, + .spender-guardrail-grid, + .spender-facts, + .spender-runtime-grid { + grid-template-columns: 1fr; + } + + .spender-view-tabs a { + flex: 1 1 calc(50% - 7px); + text-align: center; + } +} + +a { + color: inherit; +} + +button, +input { + font: inherit; +} + +button, +.button { + display: inline-flex; + min-height: 38px; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px dashed var(--line); + border-radius: 4px; + background: var(--panel-strong); + box-shadow: inset 0 0 14px rgba(var(--accent-rgb), 0.05); + color: var(--text); + cursor: pointer; + padding: 8px 14px; + text-decoration: none; + text-transform: uppercase; + transition: 140ms ease; +} + +button:hover, +.button:hover { + border-color: var(--accent-strong); + box-shadow: 0 0 18px var(--shadow), inset 0 0 16px rgba(var(--accent-rgb), 0.08); + color: var(--text-strong); + transform: translateY(-1px); +} + +button:focus-visible, +.button:focus-visible, +input:focus-visible, +summary:focus-visible, +a:focus-visible { + outline: 2px solid var(--warning); + outline-offset: 3px; +} + +.button.primary, +button.primary { + border-style: solid; + background: rgba(var(--accent-rgb), 0.13); + color: var(--accent-strong); + font-weight: 700; +} + +.button.danger, +button.danger { + border-color: var(--danger); + background: rgba(255, 104, 120, 0.08); + color: #ffb7bf; +} + +.app-shell { + display: grid; + min-height: 100vh; + grid-template-columns: var(--sidebar) minmax(0, 1fr); +} + +.sidebar { + position: sticky; + top: 0; + display: flex; + height: 100vh; + flex-direction: column; + overflow-y: auto; + border-right: 1px dashed var(--line); + background: color-mix(in srgb, var(--bg) 94%, transparent); + box-shadow: 14px 0 40px rgba(0, 0, 0, 0.2); + padding: 22px 16px 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 11px; + color: var(--text-strong); + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border: 1px solid var(--line); + border-radius: 4px; + background: rgba(var(--accent-rgb), 0.1); + box-shadow: 0 0 20px var(--shadow), inset 0 0 14px rgba(var(--accent-rgb), 0.08); + color: var(--accent-strong); + font-size: 18px; + font-weight: 700; +} + +.brand-copy { + display: grid; + line-height: 1.15; +} + +.brand-copy strong { + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.brand-copy small { + margin-top: 5px; + color: var(--muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.system-pill { + display: flex; + align-items: center; + gap: 8px; + margin: 22px 4px 16px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--line); + box-shadow: 0 0 11px var(--line); +} + +.nav-group { + display: grid; + gap: 3px; + margin-top: 16px; +} + +.nav-label { + margin: 0 10px 5px; + color: var(--dim); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.nav-link { + position: relative; + display: flex; + min-height: 38px; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid transparent; + border-radius: 4px; + color: var(--muted); + padding: 8px 10px; + text-decoration: none; + transition: 120ms ease; +} + +.nav-link::before { + color: var(--dim); + content: ">"; + opacity: 0; +} + +.nav-link span:first-of-type { + flex: 1; +} + +.nav-link:hover { + background: var(--surface-hover); + color: var(--text-strong); +} + +.nav-link:hover::before, +.nav-link.active::before { + opacity: 1; +} + +.nav-link.active { + border-color: var(--line-soft); + background: rgba(var(--accent-rgb), 0.1); + color: var(--accent-strong); +} + +.nav-link-child { + min-height: 32px; + margin-left: 18px; + border-left: 1px dashed var(--line-soft); + border-radius: 0 4px 4px 0; + padding-block: 5px; + font-size: 12px; +} + +.nav-count { + min-width: 24px; + border: 1px solid var(--line-soft); + border-radius: 999px; + color: var(--muted); + padding: 1px 6px; + font-size: 10px; + text-align: center; +} + +.nav-count.count-alert { + border-color: rgba(255, 104, 120, 0.45); + background: rgba(255, 104, 120, 0.09); + color: #ff9eaa; +} + +.suite-switcher { + margin-top: auto; + border-top: 1px dashed var(--line-soft); + padding-top: 15px; +} + +.suite-switcher .nav-label { + margin-left: 2px; +} + +.suite-switcher-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} + +.suite-module { + display: grid; + min-height: 54px; + place-items: center; + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); + color: var(--muted); + padding: 6px 3px; + text-align: center; + text-decoration: none; + transition: 130ms ease; +} + +.suite-module span { + font-size: 15px; + font-weight: 700; + line-height: 1; +} + +.suite-module small { + font-size: 8px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.suite-module:hover, +.suite-module.active { + border-color: var(--line); + background: rgba(var(--accent-rgb), 0.1); + box-shadow: 0 0 16px var(--shadow); + color: var(--accent-strong); + transform: translateY(-1px); +} + +.suite-module.absidekick:not(.active) { + border-color: rgba(56, 201, 255, 0.3); + color: #76cce9; +} + +.suite-module.mam-spender:not(.active) { + border-color: rgba(242, 173, 50, 0.3); + color: #d5a64c; +} + +.sidebar-footer { + margin-top: 13px; + border-top: 1px dashed var(--line-soft); + padding: 16px 8px 0; + color: var(--dim); + font-size: 10px; + line-height: 1.7; + text-transform: uppercase; +} + +.content-shell { + min-width: 0; +} + +.mobile-bar { + display: none; +} + +.page { + width: min(1440px, 100%); + margin: 0 auto; + padding: 30px clamp(20px, 4vw, 56px) 48px; +} + +.page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 7px; + color: var(--line); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.eyebrow::before { + content: "// "; +} + +h1, +h2, +h3, +p { + margin-top: 0; +} + +h1 { + margin-bottom: 6px; + color: var(--text-strong); + font-size: clamp(26px, 4vw, 40px); + line-height: 1.1; + letter-spacing: -0.04em; +} + +h2 { + margin-bottom: 5px; + color: var(--text-strong); + font-size: 16px; + letter-spacing: 0.02em; +} + +h3 { + color: var(--text-strong); + font-size: 14px; +} + +.subtitle { + max-width: 720px; + margin-bottom: 0; + color: var(--muted); +} + +.page-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.chip, +.status { + display: inline-flex; + min-height: 25px; + align-items: center; + gap: 6px; + border: 1px solid var(--line-soft); + border-radius: 999px; + background: rgba(var(--accent-rgb), 0.05); + color: var(--muted); + padding: 3px 9px; + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status.ok { + color: var(--accent); +} + +.status.running { + border-color: rgba(230, 238, 112, 0.4); + color: var(--warning); +} + +.status.waiting { + border-color: var(--line-strong); + color: var(--muted); +} + +.status.error { + border-color: rgba(255, 104, 120, 0.4); + color: var(--danger); +} + +.job-result { + min-width: 15rem; +} + +.result-reason { + margin-top: 0.35rem; + color: var(--warning); + font-size: 0.78rem; + line-height: 1.35; +} + +.flash { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + border: 1px dashed var(--line); + border-radius: var(--radius); + background: rgba(var(--accent-rgb), 0.08); + padding: 11px 14px; + color: var(--accent-strong); +} + +.flash code { + color: var(--warning); +} + +.job-monitor { + margin-bottom: 18px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(4, 20, 10, 0.97); + box-shadow: 0 0 28px rgba(43, 220, 88, 0.1); +} + +.job-monitor[hidden] { + display: none; +} + +.job-monitor-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 15px 17px 12px; +} + +.job-monitor-header .eyebrow { + margin-bottom: 4px; +} + +.job-monitor-header h2, +.job-monitor-header p { + margin-bottom: 0; +} + +.job-monitor-header p:last-child { + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} + +.job-progress-track { + height: 3px; + overflow: hidden; + background: rgba(43, 220, 88, 0.12); +} + +.job-progress-track span { + display: block; + width: 8%; + height: 100%; + background: var(--line); + box-shadow: 0 0 10px var(--line); + transition: width 240ms ease; +} + +.job-trace { + border-top: 1px dashed var(--line-soft); +} + +.job-trace > summary { + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--muted); + padding: 10px 17px; + cursor: pointer; + font-size: 11px; + text-transform: uppercase; +} + +.job-trace ol { + max-height: 360px; + margin: 0; + overflow-y: auto; + border-top: 1px dashed var(--line-soft); + padding: 8px 17px 12px 40px; +} + +.job-trace li { + border-bottom: 1px dashed rgba(43, 220, 88, 0.1); + padding: 7px 0; + color: var(--text); + font-size: 11px; +} + +.job-trace li.warning { + color: var(--warning); +} + +.job-trace li.error { + color: var(--danger); +} + +.job-trace li.success { + color: var(--accent); +} + +.job-trace li > div { + display: grid; + grid-template-columns: minmax(9rem, auto) minmax(7rem, auto) 1fr; + gap: 10px; +} + +.job-trace time, +.job-trace code { + color: var(--dim); +} + +.job-trace details { + margin: 5px 0 0; +} + +.job-trace pre { + max-height: 180px; + margin: 5px 0 0; + overflow: auto; + color: var(--muted); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 16px; +} + +.button.disabled { + cursor: not-allowed; + opacity: 0.35; + pointer-events: none; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.metric { + position: relative; + min-height: 124px; + overflow: hidden; + border: 1px dashed var(--line-soft); + border-radius: var(--radius); + background: var(--panel); + box-shadow: inset 0 0 26px rgba(43, 220, 88, 0.025); + padding: 17px; + text-decoration: none; + transition: 140ms ease; +} + +.metric::after { + position: absolute; + right: -18px; + bottom: -34px; + width: 90px; + height: 90px; + border: 1px solid var(--line-soft); + border-radius: 50%; + content: ""; +} + +.metric:hover { + border-color: var(--line); + background: var(--panel-strong); + box-shadow: 0 0 24px rgba(43, 220, 88, 0.08); + transform: translateY(-2px); +} + +.metric-label { + display: block; + color: var(--muted); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.metric-value { + display: block; + margin-top: 13px; + color: var(--text-strong); + font-size: clamp(28px, 4vw, 38px); + font-weight: 700; + line-height: 1; +} + +.metric-help { + display: block; + margin-top: 10px; + color: var(--dim); + font-size: 11px; +} + +.grid { + display: grid; + gap: 16px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel { + border: 1px dashed var(--line-soft); + border-radius: var(--radius); + background: var(--panel); + box-shadow: 0 0 34px rgba(0, 0, 0, 0.12), inset 0 0 24px rgba(43, 220, 88, 0.025); +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + border-bottom: 1px dashed var(--line-soft); + padding: 16px 18px; +} + +.panel-header p { + margin-bottom: 0; + color: var(--muted); + font-size: 12px; +} + +.panel-header > .chip { + flex: 0 0 auto; + white-space: nowrap; +} + +.panel-body { + padding: 18px; +} + +.quick-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.action-card { + display: grid; + gap: 9px; + min-height: 134px; + align-content: space-between; + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); + padding: 14px; +} + +.action-card p { + margin-bottom: 0; + color: var(--muted); + font-size: 11px; +} + +.action-card button { + width: 100%; +} + +.table-wrap { + width: 100%; + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +th, +td { + border-bottom: 1px dashed var(--line-soft); + padding: 12px 14px; + text-align: left; + vertical-align: top; +} + +th { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +tbody tr { + transition: background 100ms ease; +} + +tbody tr:hover { + background: rgba(43, 220, 88, 0.035); +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.job-name, +.record-title { + color: var(--text-strong); + font-weight: 700; +} + +.job-name::before { + margin-right: 7px; + color: var(--line); + content: "$"; +} + +.error-text { + color: var(--danger); +} + +.muted { + color: var(--muted); +} + +.empty-state { + display: grid; + min-height: 220px; + place-items: center; + padding: 32px; + text-align: center; +} + +.empty-state strong { + display: block; + margin-bottom: 8px; + color: var(--text-strong); +} + +.empty-state p { + margin-bottom: 0; + color: var(--muted); +} + +.search-panel { + margin-bottom: 18px; +} + +.hm-search-results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 400px), 1fr)); + gap: 14px; +} + +.hm-release-card { + display: flex; + min-width: 0; + min-height: 300px; + flex-direction: column; + overflow: hidden; + border: 1px dashed var(--line-soft); + border-radius: var(--radius); + background: var(--panel); + box-shadow: inset 0 0 26px rgba(var(--accent-rgb), 0.025); + transition: 140ms ease; +} + +.hm-release-card:hover { + border-color: var(--line); + background: var(--panel-strong); + box-shadow: 0 0 24px var(--shadow); + transform: translateY(-2px); +} + +.hm-release-header, +.hm-release-main, +.hm-release-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.hm-release-header { + min-height: 47px; + align-items: center; + border-bottom: 1px dashed var(--line-soft); + background: rgba(var(--accent-rgb), 0.035); + padding: 10px 14px; +} + +.hm-availability { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.availability-badge { + display: inline-flex; + min-height: 23px; + align-items: center; + border: 1px solid var(--line-soft); + border-radius: 999px; + padding: 2px 8px; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.availability-badge.free { + border-color: rgba(var(--accent-rgb), 0.5); + background: rgba(var(--accent-rgb), 0.1); + color: var(--accent-strong); +} + +.availability-badge.vip { + border-color: rgba(230, 238, 112, 0.5); + background: rgba(230, 238, 112, 0.08); + color: var(--warning); +} + +.availability-badge.ratio { + color: var(--muted); +} + +.hm-release-id { + flex: 0 0 auto; + color: var(--dim); + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.hm-release-main { + flex: 1; + padding: 17px 16px 15px; +} + +.hm-release-copy { + min-width: 0; +} + +.hm-release-copy h2 { + margin-bottom: 7px; + font-size: 18px; + line-height: 1.3; +} + +.hm-authors { + margin-bottom: 11px; + color: var(--accent); + font-size: 12px; +} + +.hm-authors span { + color: var(--dim); +} + +.hm-secondary { + margin-bottom: 5px; + color: var(--muted); + font-size: 10px; +} + +.hm-secondary strong { + color: var(--text); + font-weight: 400; + text-transform: uppercase; +} + +.hm-card-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex: 0 0 auto; +} + +.hm-add-form { + margin: 0; +} + +.hm-add-form button { + width: 100%; + min-height: 35px; + padding: 6px 10px; + font-size: 10px; +} + +.series-link { + color: var(--accent); + text-decoration: underline; + text-decoration-style: dotted; + transition: color 120ms ease; +} + +.series-link:hover { + color: var(--accent-strong); + text-decoration-style: solid; +} + +.series-banner-panel { + border-color: var(--accent); + background: rgba(var(--accent-rgb), 0.04); +} + +.series-search-form { + display: flex; + gap: 10px; + align-items: center; + margin-top: 12px; +} + +.series-search-form input[type="search"] { + flex: 1; + min-width: 260px; + padding: 8px 12px; + border: 1px solid var(--line); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + font-size: 13px; +} + +.series-search-form input[type="search"]:focus { + border-color: var(--accent); + outline: none; +} + +.status-gap-note { + color: var(--alert, #ff6b6b); + font-weight: 500; +} + +.chip-success { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + background: rgba(var(--accent-rgb), 0.15); + color: var(--accent-strong); +} + +.hm-release-facts { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + margin: 0; + border-top: 1px dashed var(--line-soft); + border-bottom: 1px dashed var(--line-soft); + background: var(--surface); +} + +.hm-release-facts > div { + min-width: 0; + border-right: 1px dashed var(--line-soft); + padding: 10px; +} + +.hm-release-facts > div:last-child { + border-right: 0; +} + +.hm-release-facts dt { + color: var(--dim); + font-size: 8px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hm-release-facts dd { + overflow: hidden; + margin: 3px 0 0; + color: var(--text-strong); + font-size: 10px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.hm-release-footer { + align-items: center; + padding: 10px 14px; +} + +.hm-peer-stats { + display: flex; + flex-wrap: wrap; + gap: 12px; + color: var(--dim); + font-size: 9px; + text-transform: uppercase; +} + +.hm-peer-stats strong { + color: var(--text); +} + +.hm-upload-meta { + color: var(--dim); + font-size: 9px; + text-align: right; +} + +.search-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.hm-filter-form { + display: grid; + gap: 15px; +} + +.hm-filter-lead { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: end; + gap: 10px; +} + +.hm-filter-grid { + display: grid; + grid-template-columns: repeat(5, minmax(140px, 1fr)); + gap: 10px; + border-top: 1px dashed var(--line-soft); + padding-top: 14px; +} + +.hm-filter-form label { + display: grid; + min-width: 0; + gap: 6px; +} + +.hm-filter-form label > span { + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.hm-filter-form select { + width: 100%; + min-height: 44px; + border: 1px dashed var(--dim); + border-radius: 4px; + background: var(--surface); + color: var(--text-strong); + padding: 9px 12px; +} + +.hm-filter-hint { + margin: 0; + color: var(--muted); + font-size: 10px; +} + +.hm-filter-hint strong { + color: var(--accent); +} + +input[type="text"], +input[type="search"], +input[type="number"] { + width: 100%; + min-height: 44px; + border: 1px dashed var(--dim); + border-radius: 4px; + background: var(--surface); + box-shadow: inset 0 0 18px rgba(43, 220, 88, 0.04); + color: var(--text-strong); + padding: 9px 12px; +} + +input::placeholder { + color: var(--dim); +} + +.capacity-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.capacity-card { + border: 1px solid var(--line-soft); + border-radius: var(--radius); + background: var(--panel); + padding: 15px 17px; +} + +.capacity-card header, +.capacity-split { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.capacity-card header span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.capacity-card header strong { + color: var(--text-strong); +} + +.capacity-card p { + margin: 9px 0 0; + color: var(--muted); + font-size: 11px; +} + +.capacity-track { + height: 8px; + overflow: hidden; + margin-top: 12px; + border: 1px solid var(--line-soft); + background: var(--surface); +} + +.capacity-track span { + display: block; + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--warning)); + box-shadow: 0 0 12px var(--shadow); +} + +.capacity-split { + margin-top: 12px; + border-top: 1px dashed var(--line-soft); + padding-top: 10px; + color: var(--accent); + font-size: 11px; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.35; + pointer-events: none; +} + +.home-header { + margin-bottom: 20px; +} + +.home-overview { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 0.8fr); + gap: 16px; + margin-bottom: 22px; +} + +.home-attention, +.home-guardrails { + overflow: hidden; +} + +.home-section-header, +.home-workflow-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.home-section-header { + border-bottom: 1px dashed var(--line-soft); + padding: 19px 20px 17px; +} + +.home-section-header.compact { + align-items: center; +} + +.home-section-header h2, +.home-workflow-header h2 { + margin-bottom: 6px; + font-size: 18px; +} + +.home-section-header p, +.home-workflow-header p { + margin-bottom: 0; + color: var(--muted); + font-size: 12px; +} + +.home-section-header.compact > a { + flex: 0 0 auto; + color: var(--accent); + font-size: 11px; + text-decoration: none; +} + +.home-section-header.compact > a:hover { + color: var(--text-strong); + text-decoration: underline; +} + +.home-search-button { + flex: 0 0 auto; + min-width: 130px; +} + +.home-task-list { + display: grid; + padding: 6px 14px 14px; +} + +.home-task { + display: grid; + grid-template-columns: 34px minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + border-bottom: 1px dashed rgba(var(--accent-rgb), 0.12); + padding: 12px 6px; + text-decoration: none; + transition: 120ms ease; +} + +.home-task:last-child { + border-bottom: 0; +} + +.home-task:hover { + background: rgba(var(--accent-rgb), 0.045); + padding-right: 10px; + padding-left: 10px; +} + +.home-task-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.home-task > span:nth-child(2) { + display: grid; + gap: 2px; +} + +.home-task strong { + color: var(--text-strong); + font-size: 12px; +} + +.home-task small { + color: var(--muted); + font-size: 10px; +} + +.home-task-count { + color: var(--dim); + font-size: 10px; + letter-spacing: 0.05em; + text-align: right; + text-transform: uppercase; + white-space: nowrap; +} + +.home-task.attention .home-task-icon, +.home-task.attention .home-task-count { + border-color: rgba(230, 238, 112, 0.4); + color: var(--warning); +} + +.home-task.danger .home-task-icon, +.home-task.danger .home-task-count { + border-color: rgba(255, 104, 120, 0.4); + color: var(--danger); +} + +.home-task.ready .home-task-icon, +.home-task.ready .home-task-count { + color: var(--accent); +} + +.guardrail-list { + display: grid; + gap: 0; + padding: 2px 18px 8px; +} + +.guardrail-item { + padding: 17px 0; + border-bottom: 1px dashed var(--line-soft); +} + +.guardrail-item:last-child { + border-bottom: 0; +} + +.guardrail-heading, +.wedge-balance { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.guardrail-heading span { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.guardrail-heading strong { + color: var(--accent-strong); + font-size: 15px; +} + +.guardrail-item p { + margin: 8px 0 0; + color: var(--muted); + font-size: 10px; +} + +.wedge-balance { + margin-top: 12px; + border: 1px solid var(--line-soft); + background: var(--surface); + padding: 9px 10px; + color: var(--accent); + font-size: 10px; +} + +.wedge-balance span:last-child { + color: var(--warning); +} + +.home-sync-state { + display: flex; + align-items: center; + gap: 12px; + padding: 22px; +} + +.home-sync-state p { + margin: 0; + color: var(--muted); + font-size: 11px; +} + +.home-workflow { + margin-bottom: 22px; + overflow: hidden; + border: 1px dashed var(--line-soft); + border-radius: var(--radius); + background: rgba(3, 13, 7, 0.64); +} + +.home-workflow-header { + align-items: center; + border-bottom: 1px dashed var(--line-soft); + padding: 18px 20px; +} + +.workflow-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.workflow-step { + position: relative; + display: flex; + min-width: 0; + min-height: 286px; + flex-direction: column; + border-right: 1px dashed var(--line-soft); + padding: 18px; +} + +.workflow-step:last-child { + border-right: 0; +} + +.workflow-number { + margin-bottom: 22px; + color: var(--dim); + font-size: 11px; + letter-spacing: 0.12em; +} + +.workflow-step:not(:last-child)::after { + position: absolute; + top: 20px; + right: -8px; + z-index: 2; + width: 15px; + height: 15px; + border-top: 1px solid var(--line); + border-right: 1px solid var(--line); + background: var(--bg); + content: ""; + transform: rotate(45deg); +} + +.workflow-copy { + flex: 1 1 auto; +} + +.workflow-copy h3 { + margin-bottom: 5px; + font-size: 15px; +} + +.workflow-copy > strong { + display: block; + margin-bottom: 11px; + color: var(--accent-strong); + font-size: 20px; +} + +.workflow-copy p { + margin-bottom: 18px; + color: var(--muted); + font-size: 10px; +} + +.workflow-actions { + display: grid; + gap: 7px; +} + +.workflow-actions form, +.workflow-actions button, +.workflow-actions .button { + width: 100%; +} + +.workflow-actions button, +.workflow-actions .button { + min-height: 35px; + padding: 7px 9px; + font-size: 10px; +} + +.home-manual-controls { + margin-bottom: 22px; +} + +.manual-run-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + padding: 14px; +} + +.manual-run-grid form { + display: grid; + min-width: 0; + gap: 13px; + border: 1px dashed var(--line-soft); + border-radius: 4px; + background: var(--surface); + padding: 13px; +} + +.manual-run-grid span, +.manual-run-grid strong, +.manual-run-grid small { + display: block; +} + +.manual-run-grid small { + margin-top: 5px; + color: var(--muted); + line-height: 1.45; +} + +.manual-run-grid button { + width: 100%; + align-self: end; +} + +.home-scheduler { + overflow: hidden; +} + +.home-scheduler > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 17px 20px; + color: var(--text); + cursor: pointer; + font-size: inherit; + letter-spacing: normal; + list-style: none; + text-transform: none; +} + +.home-scheduler > summary::-webkit-details-marker { + display: none; +} + +.home-scheduler > summary::before { + display: none; +} + +.home-scheduler > summary::after { + color: var(--accent); + content: "+"; + font-size: 18px; +} + +.home-scheduler[open] > summary::after { + content: "-"; +} + +.home-scheduler > summary > span:first-child { + display: grid; +} + +.home-scheduler > summary .eyebrow { + margin-bottom: 3px; +} + +.home-scheduler > summary strong { + color: var(--text-strong); + font-size: 14px; +} + +.home-scheduler > summary small { + margin-top: 3px; + color: var(--muted); +} + +.scheduler-summary-badges { + display: flex; + align-items: center; + gap: 7px; + margin-left: auto; +} + +.home-scheduler[open] > .table-wrap, +.home-scheduler[open] > .empty-state { + border-top: 1px dashed var(--line-soft); +} + +.result-title { + max-width: 620px; + color: var(--text-strong); +} + +.format-badge { + color: var(--warning); + text-transform: uppercase; +} + +.record-list { + display: grid; + gap: 10px; + padding: 14px; +} + +.record { + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); +} + +.record-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 13px 14px; +} + +.record-id { + display: block; + margin-top: 3px; + color: var(--dim); + font-size: 10px; +} + +.record-actions { + display: flex; + align-items: center; + gap: 8px; +} + +details { + border-top: 1px dashed var(--line-soft); +} + +summary { + cursor: pointer; + color: var(--muted); + padding: 9px 14px; + font-size: 10px; + letter-spacing: 0.08em; + list-style: none; + text-transform: uppercase; +} + +summary::before { + margin-right: 7px; + color: var(--line); + content: "[+]"; +} + +details[open] summary::before { + content: "[-]"; +} + +pre { + max-height: 520px; + overflow: auto; + margin: 0; + border-top: 1px dashed var(--line-soft); + background: #010502; + color: var(--text); + padding: 15px; + font: 11px/1.55 "Cascadia Mono", Consolas, monospace; + scrollbar-color: var(--dim) var(--surface); + white-space: pre-wrap; + word-break: break-word; +} + +.config-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 18px; +} + +.config-editor { + display: grid; + gap: 16px; + margin-bottom: 18px; +} + +.setting-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.setting-grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.config-legacy-auth { + grid-column: 1 / -1; + border: 1px dashed var(--line-soft); + padding: 13px; +} + +.config-legacy-auth summary { + cursor: pointer; + color: var(--muted); + font-weight: 700; +} + +.config-legacy-auth > label, +.config-legacy-auth > p { + margin-top: 12px; +} + +.setting-field { + display: grid; + gap: 7px; +} + +.setting-field > span { + color: var(--text-strong); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.setting-field small, +.setting-toggle small { + color: var(--muted); + line-height: 1.45; +} + +.setting-toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 11px; + align-items: start; + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); + padding: 13px; + cursor: pointer; +} + +.setting-toggle input { + width: 18px; + height: 18px; + margin-top: 2px; + accent-color: var(--accent); +} + +.setting-toggle strong, +.setting-toggle small { + display: block; +} + +.setting-toggle strong { + color: var(--text-strong); +} + +.setting-toggle small { + margin-top: 5px; +} + +.config-savebar { + position: sticky; + bottom: 12px; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(3, 14, 7, 0.97); + box-shadow: 0 0 28px rgba(0, 0, 0, 0.55); + padding: 12px 14px; +} + +.config-savebar strong, +.config-savebar span { + display: block; +} + +.full-config-panel { + margin-top: 18px; +} + +.request-account-panel { + margin-top: 18px; +} + +.request-account-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; + margin-top: 16px; +} + +.request-account-card { + display: grid; + gap: 14px; + border: 1px dashed var(--line-soft); + border-radius: var(--radius); + background: var(--surface); + padding: 16px; +} + +.request-account-card > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.request-account-card h3 { + margin: 7px 0 3px; +} + +.request-account-card code { + color: var(--muted); +} + +.request-account-card header small { + display: block; + margin-top: 5px; + color: var(--dim); +} + +.request-account-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.request-account-form .request-account-permission, +.request-account-form > button { + grid-column: 1 / -1; +} + +.request-account-card > form:last-child { + display: flex; + justify-content: flex-end; + border-top: 1px dashed var(--line-soft); + padding-top: 12px; +} + +.request-account-new { + margin-top: 16px; + border-color: var(--line); +} + +.full-config-editor { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.full-config-editor > span { + color: var(--dim); + font-size: 10px; +} + +.full-config-editor textarea { + width: 100%; + min-height: 560px; + resize: vertical; + border: 1px dashed var(--line); + border-radius: 4px; + background: #010502; + color: var(--text); + padding: 14px; + font: 12px/1.5 "Cascadia Mono", Consolas, monospace; + tab-size: 2; +} + +.full-config-editor textarea:focus { + outline: 1px solid var(--accent); + box-shadow: 0 0 22px rgba(var(--accent-rgb), 0.12); +} + +.full-savebar { + margin: 0 18px 18px; +} + +.config-savebar span { + color: var(--muted); + font-size: 11px; +} + +.error-flash { + border-color: var(--danger); + background: rgba(255, 104, 120, 0.08); + color: #ffb7bf; +} + +.organizer-alert > div:first-child { + display: grid; + gap: 5px; + min-width: 0; +} + +.organizer-alert p { + margin: 0; +} + +.organizer-alert code, +.placement-paths code { + overflow-wrap: anywhere; + white-space: normal; +} + +.placement-paths { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.wedge-debug { + display: grid; + gap: 11px; + border: 1px dashed rgba(255, 104, 120, 0.35); + background: rgba(255, 104, 120, 0.035); + padding: 13px; +} + +.wedge-debug > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.wedge-debug h3 { + margin: 0; +} + +.wedge-debug pre { + max-height: 240px; + margin: 8px 0 0; +} + +.config-fact { + border: 1px solid var(--line-soft); + border-radius: 4px; + background: var(--surface); + padding: 13px; +} + +.config-fact span { + display: block; + color: var(--muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.config-fact strong { + display: block; + margin-top: 7px; + color: var(--text-strong); +} + +.security-note { + border-left: 3px solid var(--warning); + background: rgba(230, 238, 112, 0.05); + color: var(--muted); + padding: 11px 14px; +} + +.security-note strong { + color: var(--warning); +} + +.record-note { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 11px; +} + +.error-center { + overflow: hidden; + border-color: rgba(255, 104, 120, 0.32); +} + +.error-list { + display: grid; + gap: 14px; + padding: 14px; +} + +.error-card { + overflow: hidden; + border: 1px solid rgba(255, 104, 120, 0.32); + border-radius: var(--radius); + background: var(--surface); +} + +.error-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 16px; +} + +.error-card-header h2 { + margin: 9px 0 3px; + font-size: 18px; +} + +.error-release { + margin-bottom: 0; + color: var(--text); +} + +.error-card-body { + display: grid; + gap: 15px; + border-top: 1px dashed var(--line-soft); + padding: 16px; +} + +.error-reason { + display: grid; + gap: 7px; + border-left: 3px solid var(--danger); + background: rgba(255, 104, 120, 0.07); + padding: 11px 13px; +} + +.error-reason span { + color: var(--danger); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.error-reason code { + color: #ffc1c8; + font-size: 12px; + word-break: break-word; +} + +.recovery-grid { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); + gap: 18px; +} + +.recovery-grid > div { + border: 1px dashed var(--line-soft); + background: rgba(var(--accent-rgb), 0.025); + padding: 14px; +} + +.recovery-grid p, +.recovery-grid ol { + margin-bottom: 0; + color: var(--muted); +} + +.recovery-grid ol { + display: grid; + gap: 7px; + padding-left: 22px; +} + +.recovery-grid li::marker { + color: var(--line); + font-weight: 700; +} + +.error-links { + display: flex; + flex-wrap: wrap; + gap: 18px; +} + +.error-links a { + color: var(--accent); + font-size: 11px; + text-transform: uppercase; +} + +.success-state strong::before { + margin-right: 8px; + color: var(--accent); + content: "[ok]"; +} + +.module-stage { + display: grid; + gap: 18px; +} + +.module-terminal { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 0 36px var(--shadow), inset 0 0 40px rgba(var(--accent-rgb), 0.035); +} + +.module-terminal > header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px dashed var(--line-soft); + background: rgba(var(--accent-rgb), 0.08); + color: var(--accent); + padding: 9px 13px; + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.module-terminal-body { + min-height: 270px; + padding: clamp(24px, 5vw, 54px); +} + +.module-terminal-body p { + max-width: 760px; + color: var(--muted); +} + +.module-terminal-body .prompt { + color: var(--line); +} + +.module-terminal-body .module-ready { + margin: 36px 0 10px; + color: var(--text-strong); + font-size: clamp(19px, 3vw, 29px); + font-weight: 700; +} + +.module-plan .panel { + min-height: 150px; +} + +.debug-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.debug-window { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #010502; + box-shadow: inset 0 0 24px rgba(43, 220, 88, 0.025); +} + +.debug-window.wide { + grid-column: 1 / -1; +} + +.debug-window > header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px dashed var(--line-soft); + background: rgba(43, 220, 88, 0.055); + color: var(--accent); + padding: 8px 12px; + font-size: 10px; + letter-spacing: 0.1em; +} + +.window-light { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 10px var(--accent); +} + +.debug-facts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + background: var(--line-soft); +} + +.debug-facts > div { + background: #010502; + padding: 18px; +} + +.debug-facts strong, +.debug-facts span { + display: block; +} + +.debug-facts strong { + color: var(--text-strong); + font-size: 25px; +} + +.debug-facts span { + margin-top: 4px; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; +} + +.job-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.job-strip > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border-right: 1px dashed var(--line-soft); + border-bottom: 1px dashed var(--line-soft); + padding: 11px 12px; +} + +.job-strip code { + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; +} + +.job-strip small { + grid-column: 1 / -1; + color: var(--dim); +} + +.console-filters { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.console-filters .active { + border-color: var(--accent); + color: var(--accent); +} + +.activity-console { + max-height: 680px; + overflow: auto; + background: #010502; + font: 11px/1.45 "Cascadia Mono", Consolas, monospace; + scrollbar-color: var(--dim) var(--surface); +} + +.activity-line { + display: grid; + grid-template-columns: 165px 130px minmax(220px, 1fr) auto; + gap: 10px; + align-items: start; + border-bottom: 1px dashed var(--line-soft); + padding: 9px 12px; +} + +.activity-line time { + color: var(--dim); +} + +.activity-line > a { + color: var(--accent); +} + +.activity-line.warning > strong { + color: var(--warning); +} + +.activity-line.error > strong { + color: var(--danger); +} + +.activity-line.success > strong { + color: var(--accent-strong); +} + +.activity-line details { + min-width: 75px; +} + +.activity-line details pre { + min-width: 280px; + margin-top: 8px; + border: 1px solid var(--line-soft); +} + +.page-footer { + display: flex; + justify-content: space-between; + gap: 16px; + margin-top: 28px; + border-top: 1px dashed var(--line-soft); + padding-top: 16px; + color: var(--dim); + font-size: 10px; + text-transform: uppercase; +} + +@media (max-width: 1100px) { + .metric-grid, + .config-summary, + .capacity-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid.two { + grid-template-columns: 1fr; + } + + .home-overview { + grid-template-columns: 1fr; + } + + .workflow-grid { + grid-template-columns: 1fr; + } + + .workflow-step { + min-height: 0; + border-right: 0; + border-bottom: 1px dashed var(--line-soft); + } + + .workflow-step:last-child { + border-bottom: 0; + } + + .workflow-step::after { + display: none; + } + + .hm-filter-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .activity-line { + grid-template-columns: 145px minmax(110px, 1fr); + } + + .activity-line > strong, + .activity-line > details { + grid-column: 1 / -1; + } +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: fixed; + z-index: 30; + width: min(310px, 88vw); + transform: translateX(-105%); + transition: transform 160ms ease; + } + + body.nav-open .sidebar { + transform: translateX(0); + } + + .mobile-bar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px dashed var(--line); + background: color-mix(in srgb, var(--bg) 96%, transparent); + padding: 10px 14px; + } + + .mobile-bar button { + min-height: 34px; + padding: 5px 10px; + } + + .mobile-title { + color: var(--text-strong); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + } + + .page { + padding: 22px 14px 36px; + } + + .page-header { + align-items: flex-start; + flex-direction: column; + } + + .page-meta { + justify-content: flex-start; + } + + .home-section-header, + .home-workflow-header, + .home-scheduler > summary { + align-items: stretch; + flex-direction: column; + } + + .home-search-button { + width: 100%; + } + + .workflow-grid { + grid-template-columns: 1fr; + } + + .workflow-step, + .workflow-step:nth-child(2) { + min-height: 0; + border-right: 0; + border-bottom: 1px dashed var(--line-soft); + } + + .workflow-step:last-child { + border-bottom: 0; + } + + .workflow-step::after, + .workflow-step:not(:last-child)::after { + display: none; + } + + .workflow-number { + margin-bottom: 12px; + } + + .scheduler-summary-badges { + flex-wrap: wrap; + margin-left: 0; + } + + .metric-grid, + .config-summary, + .quick-actions, + .debug-grid, + .capacity-grid, + .setting-grid, + .setting-grid.three { + grid-template-columns: 1fr; + } + + .request-account-form { + grid-template-columns: 1fr; + } + + .config-savebar { + position: static; + align-items: stretch; + flex-direction: column; + } + + .debug-window.wide { + grid-column: auto; + } + + .activity-line { + grid-template-columns: 1fr; + } + + .activity-line > strong, + .activity-line > details { + grid-column: auto; + } + + .metric { + min-height: 108px; + } + + .search-form, + .hm-filter-lead, + .hm-filter-grid, + .record-summary, + .recovery-grid { + grid-template-columns: 1fr; + } + + .hm-release-main, + .hm-release-footer { + align-items: stretch; + flex-direction: column; + } + + .hm-add-form button { + width: 100%; + } + + .hm-release-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hm-release-facts > div { + border-bottom: 1px dashed var(--line-soft); + } + + .hm-release-facts > div:nth-child(2n) { + border-right: 0; + } + + .hm-upload-meta { + text-align: left; + } + + .error-card-header { + flex-direction: column; + } + + .record-actions { + justify-content: flex-start; + } + + .page-footer { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .home-task { + grid-template-columns: 30px minmax(0, 1fr); + } + + .home-task-count { + grid-column: 2; + text-align: left; + } + + .guardrail-heading, + .wedge-balance { + align-items: flex-start; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +.operations-view-header { + align-items: flex-start; + display: flex; + gap: 1rem; + justify-content: space-between; + margin: 1rem 0; +} + +.operations-view-header h2, +.operations-view-header p { + margin: 0; +} + +.error-center-actions { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.operations-view-header p { + color: var(--muted); + margin-top: 0.35rem; +} + +.list-source-grid { + display: grid; + gap: 0.75rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + margin-bottom: 1rem; +} + +.list-source-card { + background: color-mix(in srgb, var(--panel) 92%, transparent); + border: 1px solid var(--line); + color: var(--text); + display: grid; + gap: 0.35rem; + min-height: 112px; + padding: 1rem; + text-decoration: none; +} + +.list-source-card:hover, +.list-source-card.active { + border-color: var(--green); + box-shadow: inset 3px 0 0 var(--green); +} + +.list-source-card strong { + font-size: 1rem; +} + +.list-source-card small { + color: var(--muted); +} + +@media (max-width: 720px) { + .operations-view-header { + display: grid; + } + + .error-center-actions { + align-items: stretch; + flex-direction: column; + } +} + +.request-portal-body { + --accent-rgb: 64, 235, 126; + --line: #40eb7e; + --accent: #8dffad; + --accent-strong: #d8ffe2; + background: + radial-gradient(circle at 15% 5%, rgba(64, 235, 126, 0.12), transparent 30rem), + radial-gradient(circle at 90% 25%, rgba(117, 215, 255, 0.07), transparent 35rem), + #020604; +} + +.request-portal-shell { + width: min(1180px, calc(100% - 36px)); + margin: 0 auto; + padding: 42px 0 24px; +} + +.request-portal-header { + max-width: 790px; + margin: 0 0 28px; +} + +.request-quota { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin: -10px 0 24px; + border: 1px dashed var(--line); + border-radius: var(--radius); + background: rgba(var(--accent-rgb), 0.07); + padding: 13px 16px; + color: var(--muted); +} + +.request-quota strong { + color: var(--accent-strong); + font-size: 18px; +} + +.request-portal-brand-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 34px; +} + +.request-portal-header h1, +.request-unlock-card h1 { + margin: 7px 0 10px; + color: var(--text-strong); + font-size: clamp(2rem, 6vw, 4.5rem); + line-height: 0.98; + letter-spacing: -0.06em; +} + +.request-portal-brand { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.request-logout { + display: flex; + align-items: center; + gap: 10px; + color: var(--muted); + font-size: 11px; + text-transform: uppercase; +} + +.request-brand-mark { + display: inline-grid; + width: 46px; + height: 46px; + place-items: center; + border: 1px solid var(--line); + background: rgba(var(--accent-rgb), 0.08); + box-shadow: 0 0 30px rgba(var(--accent-rgb), 0.14); + color: var(--accent-strong); + font-size: 18px; + font-weight: 800; +} + +.request-goodreads-card { + display: grid; + grid-template-columns: minmax(250px, 0.8fr) minmax(340px, 1.2fr); + gap: 28px; + align-items: end; + margin-bottom: 20px; + border: 1px solid rgba(117, 215, 255, 0.35); + border-radius: var(--radius); + background: linear-gradient(135deg, rgba(5, 24, 16, 0.96), rgba(4, 15, 18, 0.96)); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.26); + padding: 24px; +} + +.request-goodreads-card h2, +.request-results-header h2, +.request-book-match h2 { + margin: 2px 0 7px; + color: var(--text-strong); +} + +.request-goodreads-card p { + margin: 0; + color: var(--muted); +} + +.request-goodreads-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.request-goodreads-form input { + min-width: 0; +} + +.request-success { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 20px; + border: 1px solid var(--line); + background: rgba(var(--accent-rgb), 0.1); + padding: 14px 18px; + color: var(--text-strong); +} + +.request-success span { + color: var(--muted); +} + +.request-book-match { + display: flex; + gap: 20px; + align-items: center; + margin-bottom: 20px; + border: 1px dashed var(--line-soft); + background: rgba(5, 18, 10, 0.78); + padding: 18px; +} + +.request-book-match img { + width: 82px; + height: 118px; + border: 1px solid var(--line-soft); + object-fit: cover; +} + +.request-book-facts, +.request-admin-facts { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.request-book-facts span, +.request-admin-facts span { + border: 1px solid var(--line-soft); + background: var(--surface); + padding: 4px 8px; + color: var(--muted); + font-size: 12px; +} + +.request-search-panel { + margin-bottom: 24px; +} + +.request-results { + display: grid; + gap: 16px; +} + +.request-results-header { + display: flex; + align-items: end; + justify-content: space-between; + color: var(--muted); +} + +.request-release-card { + overflow: visible; +} + +.request-submit-drawer { + border-top: 1px dashed var(--line-soft); + background: rgba(var(--accent-rgb), 0.025); + padding: 0 18px; +} + +.request-submit-drawer summary { + cursor: pointer; + padding: 14px 0; + color: var(--accent-strong); + font-weight: 800; + text-transform: uppercase; +} + +.request-submit-drawer form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 2px 0 18px; +} + +.request-submit-drawer label, +.request-unlock-card label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.request-submit-drawer label small { + color: var(--dim); +} + +.request-submit-drawer .request-note, +.request-submit-drawer button { + grid-column: 1 / -1; +} + +.request-submit-drawer textarea, +.request-admin-actions input { + width: 100%; +} + +.request-honeypot { + position: absolute !important; + left: -10000px !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; +} + +.request-empty { + display: flex; + gap: 18px; + align-items: center; + border: 1px dashed var(--line-soft); + padding: 28px; + color: var(--muted); +} + +.request-empty > span { + color: var(--dim); + font-size: 2rem; + font-weight: 800; +} + +.request-empty strong { + color: var(--text-strong); +} + +.request-empty p { + margin: 3px 0 0; +} + +.request-portal-footer { + display: flex; + justify-content: space-between; + margin-top: 30px; + border-top: 1px dashed var(--line-soft); + padding-top: 14px; + color: var(--dim); + font-size: 11px; + text-transform: uppercase; +} + +.request-unlock-shell { + display: grid; + min-height: 100vh; + place-items: center; + padding: 20px; +} + +.request-unlock-card { + width: min(470px, 100%); + border: 1px solid var(--line); + background: var(--panel); + box-shadow: 0 20px 80px rgba(0, 0, 0, 0.5); + padding: 30px; +} + +.request-unlock-card p { + color: var(--muted); +} + +.request-unlock-card form { + display: grid; + gap: 14px; + margin-top: 24px; +} + +.request-inbox-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; +} + +.request-admin-grid { + display: grid; + gap: 16px; +} + +.request-admin-card .panel-header h2 { + margin: 8px 0 0; +} + +.request-admin-card .panel-header p { + margin: 2px 0 0; + color: var(--muted); +} + +.requester-block { + display: grid; + gap: 3px; + margin-top: 16px; + border-left: 2px solid var(--line); + padding-left: 12px; +} + +.requester-block span, +.requester-block p { + margin: 0; + color: var(--muted); +} + +.request-admin-actions { + display: grid; + grid-template-columns: auto minmax(300px, 1fr); + gap: 12px; + border-top: 1px dashed var(--line-soft); + padding: 16px 20px; +} + +.request-admin-actions form:last-child { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +@media (max-width: 760px) { + .request-portal-shell { + width: min(100% - 20px, 1180px); + padding-top: 24px; + } + + .request-goodreads-card, + .request-goodreads-form, + .request-submit-drawer form, + .request-admin-actions, + .request-admin-actions form:last-child { + grid-template-columns: 1fr; + } + + .request-submit-drawer .request-note, + .request-submit-drawer button { + grid-column: auto; + } + + .request-results-header, + .request-success, + .request-quota, + .request-portal-footer, + .request-portal-brand-row { + align-items: flex-start; + flex-direction: column; + } +} + +/* ========================================================================== + Module Management & Suite Unification + ========================================================================== */ + +.modules-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +.module-card { + display: flex; + flex-direction: column; + height: 100%; +} + +.module-card .panel-body { + display: flex; + flex-direction: column; + flex: 1; + gap: 16px; +} + +.module-header-title { + display: flex; + align-items: center; + gap: 12px; +} + +.module-header-title h2 { + margin: 0; + font-size: 1.15rem; +} + +.module-header-title p { + margin: 0; + font-size: 0.8rem; + color: var(--muted); +} + +.module-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.05em; + background: var(--bg-soft); + border: 1px solid var(--line); +} + +.module-badge.heavymlm { + background: rgba(38, 208, 124, 0.15); + border-color: rgba(38, 208, 124, 0.35); + color: #26d07c; +} + +.module-badge.absidekick { + background: rgba(80, 160, 255, 0.15); + border-color: rgba(80, 160, 255, 0.35); + color: #50a0ff; +} + +.module-badge.mam-spender { + background: rgba(245, 178, 53, 0.15); + border-color: rgba(245, 178, 53, 0.35); + color: #f5b235; +} + +.module-badge.request-portal { + background: rgba(186, 104, 200, 0.15); + border-color: rgba(186, 104, 200, 0.35); + color: #ba68c8; +} + +.module-desc { + color: var(--text-soft); + font-size: 0.9rem; + line-height: 1.5; + margin: 0; + flex: 1; +} + +.module-features { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.module-toggle { + margin-top: auto; + border-top: 1px solid var(--line-soft); + padding-top: 12px; +} + +.actions-panel { + margin-top: 20px; +} + +.split-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.suite-switcher-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.switcher-manage-link { + color: var(--muted); + text-decoration: none; + font-size: 13px; + padding: 2px 4px; + border-radius: 4px; +} + +.switcher-manage-link:hover { + color: var(--text-strong); + background: var(--bg-soft); +} + +.suite-module.module-disabled { + opacity: 0.4; + filter: grayscale(0.8); +} + +.config-security-callout { + grid-column: 1 / -1; + background: rgba(255, 85, 85, 0.08); + border: 1px solid rgba(255, 85, 85, 0.35); + border-radius: 8px; + padding: 16px 20px; + color: #ffaaaa; + line-height: 1.5; +} + +.config-security-callout strong { + display: block; + font-size: 1rem; + color: #ff6e6e; + margin-bottom: 6px; +} + +.config-security-callout p { + margin: 0; + font-size: 0.88rem; + color: rgba(255, 255, 255, 0.85); +} + +.config-security-callout a { + color: #ffb86c; + text-decoration: underline; +} + +/* ========================================================================== + Tutorial & First-Time Install Guide + ========================================================================== */ + +.tutorial-container { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 960px; +} + +.tutorial-section .panel-header { + display: flex; + align-items: flex-start; + gap: 16px; +} + +.step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(var(--accent-rgb), 0.15); + border: 1px solid var(--accent); + color: var(--accent); + font-weight: 700; + font-size: 14px; + margin-bottom: 8px; +} + +.overview-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin: 20px 0; +} + +.overview-card { + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 8px; + padding: 18px; +} + +.overview-card .card-icon { + display: inline-block; + font-weight: 700; + font-size: 1.1rem; + padding: 4px 8px; + border-radius: 6px; + margin-bottom: 10px; +} + +.overview-card.heavymlm .card-icon { background: rgba(38, 208, 124, 0.15); color: #26d07c; } +.overview-card.absidekick .card-icon { background: rgba(80, 160, 255, 0.15); color: #50a0ff; } +.overview-card.mam-spender .card-icon { background: rgba(245, 178, 53, 0.15); color: #f5b235; } + +.overview-card h3 { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.overview-card p { + margin: 0; + font-size: 0.85rem; + color: var(--muted); + line-height: 1.45; +} + +.callout { + display: flex; + gap: 14px; + padding: 14px 18px; + border-radius: 8px; + margin: 16px 0; + font-size: 0.9rem; + line-height: 1.5; +} + +.callout-icon { + font-size: 1.3rem; + line-height: 1; +} + +.callout.tip { + background: rgba(38, 208, 124, 0.08); + border: 1px solid rgba(38, 208, 124, 0.3); + color: #c9f7db; +} + +.callout.note { + background: rgba(80, 160, 255, 0.08); + border: 1px solid rgba(80, 160, 255, 0.3); + color: #cce4ff; +} + +.callout.warning { + background: rgba(255, 85, 85, 0.1); + border: 1px solid rgba(255, 85, 85, 0.4); + color: #ffcccc; +} + +.step-list { + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 10px; + font-size: 0.92rem; + line-height: 1.5; +} + +.step-list li strong { + color: var(--text-strong); +} + +.guide-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 16px; + margin: 16px 0; +} + +.guide-item { + background: var(--bg-soft); + border: 1px solid var(--line); + border-radius: 8px; + padding: 16px; +} + +.guide-item h4 { + margin: 0 0 6px; + font-size: 0.95rem; + color: var(--text-strong); +} + +.guide-item p { + margin: 0; + font-size: 0.85rem; + color: var(--muted); + line-height: 1.45; +} + +.mini-list { + padding-left: 18px; + margin: 8px 0 0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.82rem; + color: var(--muted); +} + +.code-block { + background: #020604; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px 14px; + font-family: var(--font-mono, monospace); + font-size: 0.8rem; + overflow-x: auto; + margin: 8px 0 0; +} + +.security-section { + border-color: rgba(255, 85, 85, 0.35); +} diff --git a/python/src/mlm/static/app.js b/python/src/mlm/static/app.js new file mode 100644 index 00000000..7aca3643 --- /dev/null +++ b/python/src/mlm/static/app.js @@ -0,0 +1,169 @@ +const menuButton = document.querySelector("[data-menu-toggle]"); + +if (menuButton) { + menuButton.addEventListener("click", () => { + const open = document.body.classList.toggle("nav-open"); + menuButton.setAttribute("aria-expanded", String(open)); + }); +} + +document.querySelectorAll(".nav-link").forEach((link) => { + link.addEventListener("click", () => document.body.classList.remove("nav-open")); +}); + +document.querySelectorAll("form[data-confirm]").forEach((form) => { + form.addEventListener("submit", (event) => { + if (!window.confirm(form.dataset.confirm || "Continue?")) { + event.preventDefault(); + } + }); +}); + +function localizeTimes(root = document) { + root.querySelectorAll("[data-local-time]").forEach((element) => { + const value = element.getAttribute("data-local-time"); + if (!value) return; + const parsed = new Date(value); + if (!Number.isNaN(parsed.valueOf())) { + element.textContent = parsed.toLocaleString(); + element.title = value; + } + }); +} + +localizeTimes(); + +const jobMonitor = document.querySelector("[data-job-monitor]"); +if (jobMonitor) { + const focus = jobMonitor.dataset.focusJob || ""; + const title = jobMonitor.querySelector("[data-job-title]"); + const summary = jobMonitor.querySelector("[data-job-summary]"); + const state = jobMonitor.querySelector("[data-job-state]"); + const eventList = jobMonitor.querySelector("[data-job-events]"); + const eventCount = jobMonitor.querySelector("[data-job-event-count]"); + const progressBar = jobMonitor.querySelector("[data-job-progress-bar]"); + + const matchingJobs = (jobs) => { + const entries = Object.entries(jobs); + if (focus) { + const matches = entries.filter( + ([name]) => name === focus || name.startsWith(`${focus}:`), + ); + if (matches.length) return matches; + } + return entries.filter(([, value]) => value.running); + }; + + const eventNode = (jobName, event) => { + const item = document.createElement("li"); + item.className = event.level || "info"; + + const line = document.createElement("div"); + const time = document.createElement("time"); + time.dataset.localTime = event.created_at || ""; + time.textContent = event.created_at || ""; + const job = document.createElement("code"); + job.textContent = jobName; + const message = document.createElement("span"); + message.textContent = event.message || "Working"; + line.append(time, job, message); + item.append(line); + + if (event.context && Object.keys(event.context).length) { + const details = document.createElement("details"); + const detailsSummary = document.createElement("summary"); + detailsSummary.textContent = "details"; + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(event.context, null, 2); + details.append(detailsSummary, pre); + item.append(details); + } + return item; + }; + + const refreshJobs = async () => { + try { + const response = await fetch("/api/jobs", { + headers: { "X-HeavyMLM-Refresh": "jobs" }, + cache: "no-store", + }); + if (!response.ok) return; + const payload = await response.json(); + const matches = matchingJobs(payload.jobs || {}); + if (!matches.length) return; + + jobMonitor.hidden = false; + const running = matches.some(([, value]) => value.running); + const failed = matches.find( + ([, value]) => value.last_error || Number(value.last_result?.failed || 0) > 0, + ); + const events = matches + .flatMap(([name, value]) => + (value.progress || []).map((event) => ({ name, event })), + ) + .sort((left, right) => + String(left.event.created_at).localeCompare( + String(right.event.created_at), + ), + ) + .slice(-40); + + title.textContent = + matches.length === 1 + ? matches[0][0] + : `${focus || "Background"} · ${matches.length} workers`; + state.textContent = failed ? "Error" : running ? "Running" : "Complete"; + state.className = `status ${failed ? "error" : running ? "running" : "ok"}`; + const latest = events.at(-1)?.event; + const latestFailure = failed?.[1].last_result?.failures?.at(-1); + summary.textContent = failed + ? failed[1].last_error || latestFailure?.error || "Job failed; open Errors for details" + : latest?.message || (running ? "Working…" : "Job complete"); + + const progressEvent = [...events] + .reverse() + .find(({ event }) => event.context?.current && event.context?.total); + const current = Number(progressEvent?.event.context.current || 0); + const total = Number(progressEvent?.event.context.total || 0); + progressBar.style.width = running + ? `${total ? Math.min(100, (current * 100) / total) : 12}%` + : "100%"; + + eventList.replaceChildren( + ...events.map(({ name, event }) => eventNode(name, event)), + ); + eventCount.textContent = `${events.length} event${events.length === 1 ? "" : "s"}`; + localizeTimes(eventList); + } catch { + summary.textContent = "Live updates temporarily unavailable; the job is still running."; + } + }; + + refreshJobs(); + window.setInterval(refreshJobs, 1000); +} + +const liveDiagnostics = document.querySelector("[data-live-diagnostics]"); +if (liveDiagnostics) { + const refreshDiagnostics = async () => { + if (document.querySelector(".activity-console details[open]")) return; + try { + const response = await fetch(window.location.href, { + headers: { "X-HeavyMLM-Refresh": "diagnostics" }, + }); + if (!response.ok) return; + const nextPage = new DOMParser().parseFromString(await response.text(), "text/html"); + [".debug-grid", ".activity-console"].forEach((selector) => { + const current = document.querySelector(selector); + const replacement = nextPage.querySelector(selector); + if (current && replacement) { + current.innerHTML = replacement.innerHTML; + localizeTimes(current); + } + }); + } catch { + // Keep the current diagnostic snapshot visible if a refresh is unavailable. + } + }; + window.setInterval(refreshDiagnostics, 5000); +} diff --git a/python/src/mlm/static/mam-spender.js b/python/src/mlm/static/mam-spender.js new file mode 100644 index 00000000..bfa7d53f --- /dev/null +++ b/python/src/mlm/static/mam-spender.js @@ -0,0 +1,559 @@ +(() => { + const stateNode = document.querySelector("#mam-spender-state"); + if (!stateNode) return; + + let state = JSON.parse(stateNode.textContent || "{}"); + let page = 1; + let chartMode = "pie"; + let formDirty = false; + const number = new Intl.NumberFormat(); + + const one = (selector) => document.querySelector(selector); + const all = (selector) => [...document.querySelectorAll(selector)]; + const formatNumber = (value, fallback = "N/A") => + value === null || value === undefined || value === "" + ? fallback + : number.format(Number(value)); + const formatTime = (value) => { + if (!value) return "N/A"; + const parsed = new Date(value); + return Number.isNaN(parsed.valueOf()) ? String(value) : parsed.toLocaleString(); + }; + const formatCountdown = (seconds) => { + if (seconds === null || seconds === undefined) return "Paused"; + const safe = Math.max(0, Number(seconds)); + const days = Math.floor(safe / 86400); + const hours = Math.floor((safe % 86400) / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const secs = Math.floor(safe % 60); + return days + ? `${days}d ${hours}h ${minutes}m` + : `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + }; + + function setText(selector, value) { + const target = one(selector); + if (target) target.textContent = value; + } + + function showNotice(message, kind = "success") { + const notice = one("[data-spender-notice]"); + if (!notice) return; + notice.textContent = message; + notice.className = `spender-notice ${kind}`; + notice.hidden = false; + window.setTimeout(() => { + notice.hidden = true; + }, 7000); + } + + async function api(path, body = {}) { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + let payload = {}; + try { + payload = await response.json(); + } catch { + throw new Error(`The suite returned HTTP ${response.status} without JSON.`); + } + if (!response.ok) throw new Error(payload.detail || payload.error || "Request failed."); + state = payload; + render(); + return payload; + } + + function renderStatus() { + const status = one("[data-spender-status]"); + if (status) { + status.textContent = state.automation_running + ? "Running" + : state.scheduler_enabled + ? "Scheduled" + : "Paused"; + status.className = `status ${state.automation_running ? "running" : state.scheduler_enabled ? "ok" : "waiting"}`; + } + setText("[data-spender-field='last_scan_points']", formatNumber(state.last_scan_points)); + setText( + "[data-spender-field='points_per_hour']", + state.points_per_min === null || state.points_per_min === undefined + ? "N/A" + : formatNumber(Math.round(Number(state.points_per_min) * 60)), + ); + setText("[data-spender-field='cumulative_upload_gb']", `${formatNumber(state.totals?.cumulative_upload_gb, "0")} GiB`); + setText("[data-spender-field='cumulative_freeleech_wedges']", formatNumber(state.totals?.cumulative_freeleech_wedges, "0")); + setText("[data-spender-field='cumulative_points_spent']", formatNumber(state.totals?.cumulative_points_spent, "0")); + setText("[data-spender-field='cumulative_vip_purchases']", formatNumber(state.totals?.cumulative_vip_purchases, "0")); + setText("[data-spender-field='cumulative_freeleech_points_spent']", formatNumber(state.totals?.cumulative_freeleech_points_spent, "0")); + setText("[data-spender-field='points_buffer']", formatNumber(state.settings?.points_buffer, "0")); + setText("[data-spender-field='dashboard_reserve']", `${formatNumber(state.settings?.points_buffer, "0")} points`); + setText("[data-spender-field='dashboard_interval']", `${formatNumber(state.settings?.next_run_delay_minutes, "0")} minutes`); + setText("[data-spender-field='next_run']", state.scheduler_enabled ? formatCountdown(state.next_run_seconds) : "Paused"); + ["username", "vip_expires", "uploaded", "downloaded", "ratio"].forEach((key) => { + setText(`[data-spender-field='${key}']`, state.user?.[key] || "N/A"); + }); + const mode = state.settings?.fl_only + ? "Wedge-only" + : state.settings?.alternate_fl_upload + ? `Alternating · next ${String(state.settings.alternate_next_purchase || "").replaceAll("_", " ")}` + : state.settings?.buy_upload_credit + ? "Upload credit enabled" + : "Purchases limited to VIP renewal"; + setText("[data-spender-mode-summary]", `Current mode: ${mode}`); + setText("[data-spender-alternate]", `Next alternating target: ${String(state.settings?.alternate_next_purchase || "freeleech_wedge").replaceAll("_", " ")}.`); + } + + function renderLog() { + const target = one("[data-spender-log]"); + if (!target) return; + const rows = state.logs || []; + target.replaceChildren(); + if (!rows.length) { + const empty = document.createElement("p"); + empty.textContent = "No MAM-Spender activity yet."; + target.append(empty); + return; + } + rows.forEach((row) => { + const line = document.createElement("p"); + line.className = row.level || "info"; + const time = document.createElement("time"); + time.textContent = formatTime(row.created_at); + const message = document.createElement("span"); + message.textContent = row.message || ""; + line.append(time, " ", message); + if (row.context && Object.keys(row.context).length) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = "context"; + const pre = document.createElement("pre"); + pre.textContent = JSON.stringify(row.context, null, 2); + details.append(summary, pre); + line.append(details); + } + target.append(line); + }); + target.scrollTop = target.scrollHeight; + } + + function row(cells) { + const item = document.createElement("tr"); + cells.forEach((value) => { + const cell = document.createElement("td"); + cell.textContent = String(value); + item.append(cell); + }); + return item; + } + + function emptyRow(columns, message) { + const item = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = columns; + cell.textContent = message; + item.append(cell); + return item; + } + + function renderHistory() { + const target = one("[data-spender-history]"); + if (target) { + const history = state.history || []; + target.replaceChildren( + ...(history.length + ? history.map((entry) => row([ + formatTime(entry.started_at || entry.created_at), + entry.result || entry.kind || "N/A", + formatNumber(entry.points_spent, "0"), + entry.upload_gb ? `${formatNumber(entry.upload_gb)} GiB` : "—", + entry.freeleech_wedges ? formatNumber(entry.freeleech_wedges) : "—", + entry.vip_purchased ? "Yes" : "—", + ])) + : [emptyRow(6, "No automation history yet.")]), + ); + } + renderBonusHistory(); + } + + function renderBonusHistory() { + const target = one("[data-spender-bonus-history]"); + if (!target) return; + const history = state.bonus_history || []; + const pageSize = Number(one("[data-spender-page-size]")?.value || 10); + const pages = Math.max(1, Math.ceil(history.length / pageSize)); + page = Math.max(1, Math.min(page, pages)); + const start = (page - 1) * pageSize; + const visible = history.slice(start, start + pageSize); + target.replaceChildren( + ...(visible.length + ? visible.map((entry) => row([ + formatTime(entry.timestamp), + entry.type || "N/A", + formatNumber(entry.amount, "0"), + entry.title || "N/A", + entry.other_name || "N/A", + ])) + : [emptyRow(5, "No MaM bonus history loaded yet.")]), + ); + setText( + "[data-spender-page-status]", + history.length + ? `Showing ${start + 1}–${start + visible.length} of ${history.length} · page ${page} of ${pages}` + : "Page 0 of 0", + ); + const previous = one("[data-spender-prev]"); + const next = one("[data-spender-next]"); + if (previous) previous.disabled = page <= 1; + if (next) next.disabled = page >= pages || !history.length; + setText( + "[data-spender-bonus-status]", + state.bonus_history_error + ? `Refresh failed: ${state.bonus_history_error}` + : state.bonus_history_fetched_at + ? `Last refreshed ${formatTime(state.bonus_history_fetched_at)} · ${history.length} cached records.` + : "Up to 500 returned point and wedge records.", + ); + } + + function renderMamData() { + const data = state.mam_user_data || {}; + all("[data-mam-field]").forEach((element) => { + element.textContent = data[element.dataset.mamField] || "N/A"; + }); + setText( + "[data-spender-account-status]", + state.mam_user_error + ? `Refresh failed: ${state.mam_user_error}` + : state.mam_user_fetched_at + ? `Last refreshed ${formatTime(state.mam_user_fetched_at)}.` + : "Refresh to load the latest account snapshot.", + ); + const target = one("[data-spender-notifications]"); + if (target) { + target.replaceChildren(); + (data.notifications || []).forEach((message) => { + const item = document.createElement("p"); + item.textContent = message; + target.append(item); + }); + } + } + + function renderEvents() { + const target = one("[data-spender-events]"); + if (!target) return; + const events = [...(state.spend_events || [])].reverse(); + target.replaceChildren( + ...(events.length + ? events.map((event) => row([ + formatTime(event.created_at), + event.label || event.category || "N/A", + formatNumber(event.points_spent, "0"), + event.units ? `${formatNumber(event.units)} ${event.unit_label || ""}` : "—", + formatNumber(event.balance_after), + ])) + : [emptyRow(5, "No confirmed spending events yet.")]), + ); + } + + const categoryLabel = { + upload_credit: "Upload Credit", + freeleech_wedge: "Freeleech Wedge", + vip: "VIP Renewal", + }; + const categoryColor = { + upload_credit: "#62ff96", + freeleech_wedge: "#d7ff64", + vip: "#63d8ff", + }; + + function drawChart() { + const canvas = one("[data-spender-chart]"); + if (!canvas) return; + const context = canvas.getContext("2d"); + const width = canvas.width; + const height = canvas.height; + context.clearRect(0, 0, width, height); + const events = (state.spend_events || []).filter((event) => Number(event.points_spent || 0) > 0); + const totals = {}; + events.forEach((event) => { + const category = event.category || "other"; + totals[category] = (totals[category] || 0) + Number(event.points_spent || 0); + }); + const entries = Object.entries(totals); + if (!entries.length) { + context.fillStyle = "#69a77a"; + context.font = "22px ui-monospace, monospace"; + context.fillText("No confirmed spending data yet.", 48, height / 2); + return; + } + if (chartMode === "pie") drawPie(context, entries, width, height); + if (chartMode === "bar") drawBars(context, entries, width, height); + if (chartMode === "timeline") drawTimeline(context, events, width, height); + setText("[data-spender-chart-title]", chartMode === "timeline" ? "Cumulative spending timeline" : chartMode === "bar" ? "Spending by category" : "Spending share by category"); + const legend = one("[data-spender-chart-legend]"); + if (legend) { + legend.replaceChildren(); + entries.forEach(([category, points]) => { + const item = document.createElement("span"); + item.style.setProperty("--legend", categoryColor[category] || "#a7c7af"); + item.textContent = `${categoryLabel[category] || category}: ${formatNumber(points)} pts`; + legend.append(item); + }); + } + } + + function drawPie(context, entries, width, height) { + const total = entries.reduce((sum, [, value]) => sum + value, 0); + const centerX = width * 0.36; + const centerY = height / 2; + const radius = Math.min(height * 0.37, width * 0.2); + let angle = -Math.PI / 2; + entries.forEach(([category, points]) => { + const next = angle + (points / total) * Math.PI * 2; + context.beginPath(); + context.moveTo(centerX, centerY); + context.arc(centerX, centerY, radius, angle, next); + context.closePath(); + context.fillStyle = categoryColor[category] || "#a7c7af"; + context.fill(); + angle = next; + }); + context.beginPath(); + context.arc(centerX, centerY, radius * 0.55, 0, Math.PI * 2); + context.fillStyle = "#06100a"; + context.fill(); + context.fillStyle = "#dbffe5"; + context.font = "bold 24px ui-monospace, monospace"; + context.textAlign = "center"; + context.fillText(formatNumber(total), centerX, centerY); + context.font = "15px ui-monospace, monospace"; + context.fillStyle = "#69a77a"; + context.fillText("points", centerX, centerY + 25); + context.textAlign = "start"; + } + + function drawBars(context, entries, width, height) { + const max = Math.max(...entries.map(([, value]) => value), 1); + entries.forEach(([category, points], index) => { + const y = 65 + index * 100; + const barWidth = (points / max) * (width - 380); + context.fillStyle = categoryColor[category] || "#a7c7af"; + context.fillRect(230, y, barWidth, 44); + context.fillStyle = "#dbffe5"; + context.font = "18px ui-monospace, monospace"; + context.fillText(categoryLabel[category] || category, 35, y + 28); + context.fillText(`${formatNumber(points)} pts`, 245 + barWidth, y + 28); + }); + } + + function drawTimeline(context, events, width, height) { + const ordered = [...events].sort((left, right) => new Date(left.created_at) - new Date(right.created_at)); + let cumulative = 0; + const points = ordered.map((event) => ({ + value: (cumulative += Number(event.points_spent || 0)), + category: event.category, + })); + const max = Math.max(...points.map((point) => point.value), 1); + const left = 70; + const top = 45; + const right = width - 45; + const bottom = height - 60; + context.strokeStyle = "rgba(98,255,150,.25)"; + context.strokeRect(left, top, right - left, bottom - top); + context.beginPath(); + points.forEach((point, index) => { + const x = left + (points.length === 1 ? 0.5 : index / (points.length - 1)) * (right - left); + const y = bottom - (point.value / max) * (bottom - top); + if (!index) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = "#62ff96"; + context.lineWidth = 4; + context.stroke(); + context.fillStyle = "#dbffe5"; + context.font = "17px ui-monospace, monospace"; + context.fillText(`${formatNumber(max)} cumulative points`, left, 28); + } + + function render() { + renderStatus(); + renderLog(); + renderHistory(); + renderMamData(); + renderEvents(); + drawChart(); + } + + function nextWeekdayUtc(day, hour) { + const current = new Date(); + const result = new Date(current); + result.setUTCMinutes(0, 0, 0); + result.setUTCHours(hour); + result.setUTCDate(result.getUTCDate() + ((day - result.getUTCDay() + 7) % 7)); + if (result <= current) result.setUTCDate(result.getUTCDate() + 7); + return result; + } + + function renderMarquee() { + const target = one("[data-spender-marquee]"); + if (!target) return; + const current = new Date(); + const vault = new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), current.getUTCDate() + 1)); + const lottoReset = nextWeekdayUtc(1, 0); + const lottoDrawing = nextWeekdayUtc(1, 9); + const until = (date) => formatCountdown(Math.floor((date - Date.now()) / 1000)); + target.textContent = [ + `LOCAL ${current.toLocaleString()}`, + `MAM UTC ${current.toLocaleString([], { timeZone: "UTC", timeZoneName: "short" })}`, + `VAULT RESET ${until(vault)}`, + `LOTTO RESET ${until(lottoReset)}`, + `LOTTO DRAW ${until(lottoDrawing)}`, + ].join(" // "); + } + + all("[data-spender-action]").forEach((button) => { + button.addEventListener("click", async () => { + const action = button.dataset.spenderAction; + const paths = { + start: "start", + pause: "pause", + run: "run", + "run-fl": "run", + "refresh-account": "refresh-account", + "refresh-bonus-history": "refresh-bonus-history", + }; + button.disabled = true; + try { + await api(`/api/mam-spender/${paths[action]}`, { + fl_only_override: action === "run-fl", + }); + showNotice(action.startsWith("refresh") ? "Fresh MaM data loaded." : "MAM-Spender action accepted."); + } catch (error) { + showNotice(error.message, "error"); + } finally { + button.disabled = false; + } + }); + }); + + const settings = one("[data-spender-settings]"); + if (settings) { + settings.addEventListener("input", () => { formDirty = true; }); + settings.addEventListener("submit", async (event) => { + event.preventDefault(); + const data = new FormData(settings); + const body = { + buy_vip: data.has("buy_vip"), + buy_upload_credit: data.has("buy_upload_credit"), + fl_only: data.has("fl_only"), + alternate_fl_upload: data.has("alternate_fl_upload"), + points_buffer: Number(data.get("points_buffer")), + next_run_delay_minutes: Number(data.get("next_run_delay_minutes")), + }; + try { + await api("/api/mam-spender/settings", body); + formDirty = false; + showNotice("Purchase settings saved and applied immediately."); + } catch (error) { + showNotice(error.message, "error"); + } + }); + } + + all("[data-spender-setting-value]").forEach((button) => { + button.addEventListener("click", () => { + if (!settings) return; + const input = settings.elements.namedItem(button.dataset.spenderSettingTarget); + if (!(input instanceof HTMLInputElement)) return; + input.value = button.dataset.spenderSettingValue; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + }); + + one("[data-spender-reset]")?.addEventListener("click", async () => { + if (!window.confirm("Reset cumulative MAM-Spender totals? Purchase history remains intact.")) return; + try { + await api("/api/mam-spender/reset-totals"); + showNotice("Cumulative totals reset. History was preserved."); + } catch (error) { + showNotice(error.message, "error"); + } + }); + + one("[data-spender-save-session]")?.addEventListener("click", async () => { + const input = one("[data-spender-session]"); + if (!input?.value.trim()) return showNotice("Paste a Session_ID or cookie export first.", "error"); + try { + await api("/api/mam-spender/session", { value: input.value }); + input.value = ""; + showNotice("Shared MaM API session saved. The cookie was not echoed back."); + } catch (error) { + showNotice(error.message, "error"); + } + }); + + one("[data-spender-session-file]")?.addEventListener("change", async (event) => { + const file = event.target.files?.[0]; + const input = one("[data-spender-session]"); + if (!file || !input) return; + try { + input.value = await file.text(); + showNotice(`Loaded ${file.name}. Review it, then save the Session_ID.`); + } catch { + showNotice(`Could not read ${file.name}.`, "error"); + } + }); + + one("[data-spender-import-button]")?.addEventListener("click", async () => { + const input = one("[data-spender-import]"); + if (!input?.value.trim()) return showNotice("Paste the old config.json first.", "error"); + if (!window.confirm("Import standalone MAM-Spender settings and history into this module?")) return; + try { + await api("/api/mam-spender/import", { config: input.value }); + input.value = ""; + showNotice("Web Edition data imported."); + } catch (error) { + showNotice(error.message, "error"); + } + }); + + one("[data-spender-page-size]")?.addEventListener("change", () => { + page = 1; + renderBonusHistory(); + }); + one("[data-spender-prev]")?.addEventListener("click", () => { + page -= 1; + renderBonusHistory(); + }); + one("[data-spender-next]")?.addEventListener("click", () => { + page += 1; + renderBonusHistory(); + }); + all("[data-chart-mode]").forEach((button) => { + button.addEventListener("click", () => { + chartMode = button.dataset.chartMode; + all("[data-chart-mode]").forEach((item) => item.classList.toggle("active", item === button)); + drawChart(); + }); + }); + + async function refreshState() { + if (formDirty) return; + try { + const response = await fetch("/api/mam-spender/state", { cache: "no-store" }); + if (!response.ok) return; + state = await response.json(); + render(); + } catch { + // Keep the most recent state visible while the server reconnects. + } + } + + render(); + renderMarquee(); + window.setInterval(renderMarquee, 1000); + window.setInterval(refreshState, 2000); +})(); diff --git a/python/src/mlm/templates/absidekick.html b/python/src/mlm/templates/absidekick.html new file mode 100644 index 00000000..4cdc1039 --- /dev/null +++ b/python/src/mlm/templates/absidekick.html @@ -0,0 +1,503 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

MyAnonaSuite / Audiobookshelf API runner

+

ABSidekick

+

Score, review, and repair Audiobookshelf metadata without browser automation.

+
+
+ {{ absidekick_source_version }} integrated + +
+
+ +
+
+
+ Status + Idle +
+
+ Processed + 0 / 0 +
+
+ Matched + 0 +
+
+ Unmatched + 0 +
+
+ Review + 0 +
+
+ +
+ +
+

Live activity

+ Ready + No ABSidekick operation is currently running. + +
+
+ + Idle +
+
+ +
+ + +
+
+
+
+
+

Server

+

Connection

+
+
+ Token not saved + +
+
+
+ + + + +
+ +
Saving is enabled by default. The token is stored beside the MyAnonaSuite database and is never returned to the browser.
+ +
+
+
+

Native metadata provider

+

Google Books API

+
+ Not configured +
+

+ Google searches run directly from MyAnonaSuite and never through Audiobookshelf's anonymous Google provider. Once this key passes its live test, automatic runs try Audiobookshelf first, Google second, and native Open Library third. +

+
+ +
+ + +
+
+
+
+ How to create and secure a Google Books API key +
    +
  1. Open Google Cloud: Create a project, sign in, and create or select a project for MyAnonaSuite.
  2. +
  3. Open the Books API page and select Enable.
  4. +
  5. Open APIs & Services → Credentials, choose Create credentials → API key, and copy the new key.
  6. +
  7. Select Edit API key. Under API restrictions choose Restrict key, then select only Books API.
  8. +
  9. Optional but recommended for a server with a stable public IP: set Application restrictions to IP addresses and add the public outbound IP of the computer running MyAnonaSuite. VPN users should leave this unrestricted unless the VPN provides a stable exit IP.
  10. +
  11. Paste the key above and select Test & Enable. A successful live Google response stores the key in the private ABSidekick settings file and enables Google searches.
  12. +
+

Do not use an HTTP referrer restriction: MyAnonaSuite calls Google from Python, not from the browser. The saved key is never returned to the web page or sent to Audiobookshelf.

+
+
+ +
+
+
+

Native metadata provider

+

Open Library Search

+
+ Enabled +
+

+ Open Library is the automatic third lookup after Audiobookshelf and Google fail the matching policy. It needs no API key. Searches use the official JSON API, are cached for the current run, and obey Open Library's published request limits. +

+
+ + +
+
+
+ Why Open Library requests a contact email +

Open Library asks regular API users to identify their application with a contact email. Identified clients receive a documented limit of three requests per second; unidentified clients are limited to one request per second. MyAnonaSuite enforces the appropriate limit automatically.

+

The address is sent only to Open Library in the API request headers so their administrators can contact the installation owner about unusual traffic. See the official Open Library API guidelines.

+
+
+
+ +
+
+
+

Scope

+

Targeting

+
+ +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + + + + + +
+
+ +
+
+
+

Scoring

+

Matching

+
+
+
+ +
+
+
+ + + + +
+
+ The similarity score measures how alike the available fields are; it is not an approval percentage. With safety checks enabled, a score of 100 can still require review when only the title is known, author data is missing, editions are tied, or the work identity conflicts. An ISBN difference is informational when title and author are both present and strongly agree. An exact ASIN or ISBN also makes a different secondary identifier or stored duration non-blocking, while ASIN, duration, wrong-author, series-number, collection, and tied-result conflicts otherwise still require review. A shorter or incorrect current ABS title does not veto a match when the folder/search title and the remaining evidence pass policy; it is shown as a match note instead. Every job log names the exact failed check or non-blocking note. +
+
+ + + + +
+ +
+ + + +
+
+ + + + + + + + + + +
+
+ Adaptive search peels strongly evidenced prefixes in layers, including decimal or lettered positions, ranges, compact track numbers, bracketed release labels, and disc suffixes. It tries the cleanest book title first and keeps intermediate and original titles as bounded fallbacks. Generic placeholders such as “Book 58” also try a distinct ABS folder name first. Ambiguous numeric work titles keep their original form first. The Search path shows every exact title and author attempted, and marks author-free broadening as title only. Series repair is independent of broad metadata overwrite: it writes structured series names and book numbers returned by the selected provider, while preserving multiple series. When the provider omits series, opted-in embedded tags or an explicit subtitle such as “Series Name, Book 3” can supply it. File evidence is separately opt-in: when enabled, ABSidekick reads the per-file tags and filenames Audiobookshelf already reports. Repeated track filenames must agree, while chapter-only names are rejected. It does not open or modify media files. +
+

Score Weights

+
+ + + + + + +
+
+ +
+
+
+

Markers

+

Tags & Actions

+
+
+
+ + + +
+
+ + + + + + +
+
+ +
+
+
+

Execution

+

Run Controls

+
+
+ + +
+
+
+ + + + + + +
+
Metadata searches never use the general ABS retry count. Native Google and Open Library requests retry one transient failure and open a run-local circuit breaker after three consecutively failed items.
+
+ + +
+
+ + + + +
+ +
+ +
+
+
+

Human Checkpoint

+

Review Desk

+
+
+ + +
+
+
+ + + + +
+
+ Approve writes the selected candidate to Audiobookshelf and clears the review tag. Use the manual search on any review item to edit its title, author, and provider; the row stays open and reports whether the new search found a confident match. +
+
+
+
+
+
+ +
+
+
+

Telemetry

+

Live Log

+
+ +
+
+ + + + + +
+
+
+ + Page 0 / 0 + +
+
+
+ + +
+{% endblock %} +{% block scripts %}{% endblock %} diff --git a/python/src/mlm/templates/base.html b/python/src/mlm/templates/base.html new file mode 100644 index 00000000..04d485ec --- /dev/null +++ b/python/src/mlm/templates/base.html @@ -0,0 +1,167 @@ + + + + + + + {{ title }} · MyAnonaSuite + + {% if suite_module == "absidekick" %}{% endif %} + + +
+ + +
+
+ MyAnonaSuite / {{ suite_info.name }} + +
+
+ {% block content %}{% endblock %} +
+ MyAnonaSuite {{ version }} / {{ suite_info.name }} + {{ record_total }} indexed records +
+
+
+
+ +{% block scripts %}{% endblock %} + + diff --git a/python/src/mlm/templates/config.html b/python/src/mlm/templates/config.html new file mode 100644 index 00000000..16aafb60 --- /dev/null +++ b/python/src/mlm/templates/config.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} +{% block content %} + + +{% if saved %} +
Settings saved successfully!Saved
+{% endif %} +{% if error %} + +{% endif %} + +
+
Slots used{% if mam_stats %}{{ mam_stats.slots_used }} / {{ mam_stats.slots_total }}{% else %}Syncing…{% endif %}
+
Configured ceiling{{ config.max_unsat_slots if config.max_unsat_slots is not none else "Site limit" }}
+
Wedges available{{ mam_stats.wedges if mam_stats else "Syncing…" }}
+
Wedges reserved{{ config.wedge_buffer }}
+
+ +
+
+
+
+

Download & Ratio Rules

+

Set simple safety limits so downloading never hurts your tracker ratio or uses up all your slots.

+
+ Applies immediately +
+
+ + + + + + + + + +
+
+ +
+
+

Request Portal & Web Sharing

Share a safe, locked-down book request page on your own domain with family and friends.

+ Applies immediately +
+
+ + + + + + + + + +
+ Legacy shared access code +

Kept for older setups. Individual username/password logins are recommended.

+ + +
+
+
+ +
+
+

Scheduler timing

Saving restarts the in-process scheduler so new intervals take effect immediately.

+
+
+ + + +
+
+ +
+
Quick policy saveApplies these common runtime controls immediately and leaves every nested section untouched.
+ +
+
+ + + +
+
+

Complete configuration

Edit every supported setting and nested section in the actual TOML document.

+ Full control +
+ {% if config_toml is not none %} +
+
+
Local only: This editor includes credentials because it is available only from the computer running MyAnonaSuite. The entire document is validated before replacement.
+ +
+
+
Validate, save, and applySchedulers, connections, and live policy reload immediately; existing queued items are re-evaluated under the new runtime policy on their next pass. Host and port changes take effect on the next launch because the current listener cannot move itself.
+ +
+
+ {% else %} +
+
Protected: The complete editor is visible only on the computer running MyAnonaSuite. Open http://127.0.0.1:3157/config locally to use it.
+
Show redacted configuration
{{ config | tojson(indent=2) }}
+
+ {% endif %} +
+{% endblock %} diff --git a/python/src/mlm/templates/diagnostics.html b/python/src/mlm/templates/diagnostics.html new file mode 100644 index 00000000..826f2ae0 --- /dev/null +++ b/python/src/mlm/templates/diagnostics.html @@ -0,0 +1,77 @@ +
+

Live diagnostics

A five-second live view of source refreshes, matching decisions, queue checks, downloads, and scheduled jobs.

+
+ {% if live %} + Auto-refresh on + Pause + {% else %} + Auto-refresh paused + Resume + {% endif %} + Clear filter +
+
+ +
+
+
PIPELINE_STATE
+
+
{{ pipeline.awaiting }}awaiting checks
+
{{ pipeline.downloading }}in qBittorrent
+
+
+ +
+
LIST_MEMORY
+
+
{{ list_tracking.get("selected", 0) + list_tracking.get("already_grabbed", 0) }}grabbed
+
{{ list_tracking.get("no_match", 0) }}no match
+
{{ list_tracking.get("legacy", 0) }}awaiting refresh
+
+
+ +
+
SCHEDULER_JOBS
+
+ {% for name,status in jobs.items() %} +
+ {{ name }} + {% if status.running %} + running + {% elif status.last_error %} + error + {% else %} + idle + {% endif %} + {% if status.last_run %}{{ status.last_run }}{% else %}never run{% endif %} +
+ {% endfor %} +
+
+
+ +
+
+
+

Activity console

+

{{ activity | length }} most recent trace messages{% if component %} for {{ component }}{% endif %}.

+
+
+ {% for value,label in [("","All"),("scheduler","Scheduler"),("downloader","Downloader"),("organizer","Organizer"),("lists","Lists")] %} + {{ label }} + {% endfor %} +
+
+
+ {% for entry in activity %} +
+ + [{{ entry.component }}] + {{ entry.message }} + {% if entry.context %}
context
{{ entry.context | tojson(indent=2) }}
{% endif %} +
+ {% else %} +
No trace messages yet

Run Refresh lists or Process queue to populate this console.

+ {% endfor %} +
+
diff --git a/python/src/mlm/templates/errors.html b/python/src/mlm/templates/errors.html new file mode 100644 index 00000000..11e77c42 --- /dev/null +++ b/python/src/mlm/templates/errors.html @@ -0,0 +1,122 @@ +
+

Recovery center

Every failure includes its exact reason, a recovery path, and a direct way to retry or dismiss it.

+
+ {{ total }} unresolved +
+ +
+
+
+ +{% if retried %} +
The stored error was cleared and the downloader was asked to retry it.
+{% elif dismissed_all is not none %} +
Dismissed {{ dismissed_all }} stored error{% if dismissed_all != 1 %}s{% endif %}. Torrents, downloads, files, and job history were not changed.
+{% elif dismissed %} +
The stored error was dismissed.
+{% endif %} + +
+ {% if rows %} +
+ {% for row in rows %} + {% set guidance = row.get("_guidance") %} + {% set meta = row.get("meta") or {} %} + {% set error_context = row.get("context") or {} %} +
+
+
+ Action needed +

{{ guidance.title }}

+

{{ row.get("title") or meta.get("title") or "Unknown release" }}

+ + {% if row.get("_mam_id") %}MaM #{{ row.get("_mam_id") }}{% endif %} + {% if meta.get("authors") %} · {{ meta.get("authors") | join(", ") }}{% endif %} + {% if row.get("created_at") %} · {% endif %} + +
+
+ {% if row.get("_retryable") %} +
+ + + +
+ {% elif row.get("_mam_id") %} + No longer pending + {% endif %} +
+ + +
+
+
+
+
+ Exact error + {{ row.get("error") or "No technical message was recorded." }} +
+ {% if error_context.get("source") or error_context.get("destination") %} +
+

Source

{{ error_context.get("source") or "Not recorded" }}
+

Destination

{{ error_context.get("destination") or "Not recorded" }}
+ {% if error_context.get("method") %}

Method

{{ error_context.get("method") }}
{% endif %} + {% if error_context.get("remediation") %}

Recommended fix

{{ error_context.get("remediation") }}

{% endif %} +
+ {% endif %} + {% if error_context.get("wedge_attempted") %} +
+

Wedge attempt debug

Not applied
+
+

Policy

{% if error_context.get("prefer_wedges") %}Prefer wedges{% else %}{{ error_context.get("cost") }}{% endif %}
+

Failure fallback

{% if error_context.get("download_on_wedge_failure") %}Enabled (ratio safeguards apply){% else %}Disabled (strict){% endif %}
+

Balance

{{ error_context.get("wedges_before", "?") }} before · {{ error_context.get("wedges_after", "not confirmed") }} after · {{ error_context.get("wedge_buffer", "?") }} reserved
+

Failure stage

{{ error_context.get("stage", "unknown") }}{% if error_context.get("wedge_reason") %} / {{ error_context.get("wedge_reason") }}{% endif %}
+

HTTP response

{{ error_context.get("http_status", "not received") }}{% if error_context.get("content_type") %} · {{ error_context.get("content_type") }}{% endif %}
+ {% if error_context.get("endpoint") %}

MaM endpoint

{{ error_context.get("endpoint") }}
{% endif %} +
+ {% if error_context.get("tracker_response") %}
MaM wedge response
{{ error_context.get("tracker_response") | tojson(indent=2) }}
{% endif %} + {% if error_context.get("response_preview") %}
Raw response preview
{{ error_context.get("response_preview") }}
{% endif %} +
+ {% endif %} +
+
+

What happened

+

{{ guidance.reason }}

+
+
+

Path forward

+
    + {% for step in guidance.steps %}
  1. {{ step }}
  2. {% endfor %} +
+
+
+ +
+
+ Raw error record +
{{ row | tojson(indent=2) }}
+
+
+ {% endfor %} +
+ {% else %} +
+
+ No unresolved errors +

Downloader and organizer failures will appear here with their reason and recovery steps.

+
+
+ {% endif %} +
+ +{% if page_count > 1 %} + +{% endif %} diff --git a/python/src/mlm/templates/heavymlm/search.html b/python/src/mlm/templates/heavymlm/search.html new file mode 100644 index 00000000..78385c81 --- /dev/null +++ b/python/src/mlm/templates/heavymlm/search.html @@ -0,0 +1,187 @@ +{% extends "base.html" %} +{% block content %} + + +{% if series_queued is not none %} +
+ + {% if series_queued > 0 %} + Queued {{ series_queued }} book{% if series_queued != 1 %}s{% endif %} for series "{{ series_name }}". + {% if series_existing > 0 %}({{ series_existing }} book{% if series_existing != 1 %}s{% endif %} already in your library or queue).{% endif %} + {% elif series_existing > 0 %} + All {{ series_existing }} book{% if series_existing != 1 %}s{% endif %} in series "{{ series_name }}" are already in your library or queue! + {% else %} + No matching books found on MaM for series "{{ series_name }}". + {% endif %} + + Series Updated +
+{% endif %} + +
+
+ +
+
+ +{% if filters.series %} +
+
+
+

Series Collection

+

📚 Series: {{ filters.series }}

+

Grab all available volumes and missing books in this series at once.

+
+
+ + + + +
+
+
+{% endif %} + +{% if error %} + +{% endif %} + +{% if rows %} +
+ {% for row in rows %} +
+
+
+ {% for badge in row.availability %}{{ badge.label }}{% endfor %} +
+ MaM #{{ row.id }} +
+ +
+
+

{{ row.title }}

+ {% if row.authors %} +

by {{ row.authors | join(", ") }}

+ {% else %} +

Author information unavailable

+ {% endif %} + + {% if row.series %} +

Series + {% for series in row.series %} + {{ series.name }}{% if series.entries %} #{{ series.entries | join(", ") }}{% endif %}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if row.narrators %}

Narrated by {{ row.narrators | join(", ") }}

{% endif %} +
+ +
+
+ + +
+ {% if row.series %} +
+ + + + +
+ {% endif %} +
+
+ +
+
Format
{% if row.filetypes %}{{ row.filetypes | join(" / ") }}{% else %}Unknown{% endif %}
+
Category
{{ row.category }}
+
Size
{{ row.size }}
+
Language
{{ row.language }}
+
Files
{{ row.num_files or "—" }}
+
+ +
+
+ {{ row.seeders }} seeders + {{ row.leechers }} leechers + {{ row.snatched }} snatched +
+ {% if row.uploader or row.uploaded_at %} + {% if row.uploader %}Uploaded by {{ row.uploader }}{% endif %}{% if row.uploader and row.uploaded_at %} · {% endif %}{{ row.uploaded_at }} + {% endif %} +
+
+ {% endfor %} +
+{% elif searched and not error %} +
+
No releases passed every filter

Broaden one field at a time. All filled filters use AND logic.

+
+{% else %} +
+
Ready to search

Enter a title, author, narrator, or identifier above.

+
+{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/index.html b/python/src/mlm/templates/index.html new file mode 100644 index 00000000..3150bb67 --- /dev/null +++ b/python/src/mlm/templates/index.html @@ -0,0 +1,252 @@ +{% extends "base.html" %} +{% block content %} +{% set error_count = counts.get("errored_torrents", 0) %} +{% set request_count = request_tracking.get("pending", 0) %} +{% set download_count = pipeline.downloading %} +{% set duplicate_count = counts.get("duplicate_torrents", 0) %} +{% set urgent_count = error_count + request_count %} +{% set job_summary = namespace(running=0, issues=0) %} +{% for name,status in jobs.items() %} + {% if status.running %}{% set job_summary.running = job_summary.running + 1 %}{% endif %} + {% if status.last_error or (status.last_result is mapping and status.last_result.get("failed", 0)) %} + {% set job_summary.issues = job_summary.issues + 1 %} + {% endif %} +{% endfor %} + + + +{% for name,status in jobs.items() %} +{% if name.startswith("organizer") and status.last_result is mapping and status.last_result.get("failed", 0) %} +{% set failures = status.last_result.get("failures") or [] %} +{% set failure = failures[-1] if failures else {} %} + +{% endif %} +{% endfor %} + +
+
+
+

Working now

+

{% if triggered %}Starting {{ triggered }}{% else %}Background job{% endif %}

+

Waiting for the first progress update.

+
+ Starting +
+ +
+ View live details0 events +
  1. Waiting for live job data...
+
+
+ +
+
+
+
+

Start here

+ {% if urgent_count %} +

{{ urgent_count }} item{% if urgent_count != 1 %}s{% endif %} need your attention

+

Handle these first; routine downloads and organization can continue in the background.

+ {% else %} +

Everything important is under control

+

No requests or errors need you right now. Search for a book or refresh your lists when you are ready.

+ {% endif %} +
+ Search MaM +
+ + +
+ +
+
+
+

Download guardrails

+

Your safety buffers

+

HeavyMLM stops before crossing either limit.

+
+ Change limits +
+ {% if mam_stats %} + {% set slot_cap_percent = (mam_stats.slots_used * 100 / mam_stats.slot_cap) if mam_stats.slot_cap else 0 %} + {% set slot_remaining = [mam_stats.slot_cap - mam_stats.slots_used, 0] | max %} + {% set wedge_spendable = [mam_stats.wedges - mam_stats.wedge_buffer, 0] | max %} +
+
+
Usable download slots{{ slot_remaining }} left
+
+

{{ mam_stats.slots_used }} currently used | stop at {{ mam_stats.slot_cap }} | account total {{ mam_stats.slots_total }}

+
+
+
Freeleech wedges{{ wedge_spendable }} spendable
+
{{ mam_stats.wedges }} available{{ mam_stats.wedge_buffer }} protected
+

{% if wedge_spendable %}Wedge-first downloads can continue without touching your reserve.{% else %}Your reserve has been reached; HeavyMLM will not spend another wedge.{% endif %}

+
+
+ {% else %} +
Syncing

MaM account limits will appear after the next account refresh.

+ {% endif %} +
+
+ +
+
+
+

The big picture

+

How HeavyMLM works

+

Books move from left to right automatically. There is no download queue to manage.

+
+ Schedules remain active +
+ +
+
+ 01 +
+

Find books

+ {{ counts.get("lists", 0) }} list source{% if counts.get("lists", 0) != 1 %}s{% endif %} +

Search manually, refresh Goodreads/Notion, or check configured bookmarks.

+
+ +
+ +
+ 02 +
+

Download & organize

+ {{ download_count }} active in qBittorrent +

HeavyMLM sends selected releases automatically. qBittorrent downloads them, then HeavyMLM organizes completed books.

+
+ +
+ +
+ 03 +
+

Enjoy the library

+ {{ counts.get("torrents", 0) }} processed +

Completed books are linked into your configured audiobook and ebook libraries.

+
+
+ Open processed library + {% if duplicate_count %}Review duplicates{% endif %} +
+
+
+
+ +
+
+
+

Manual overrides

+

Run HeavyMLM now

+

Schedules remain enabled. These buttons simply start a worker immediately when you do not want to wait for its next interval.

+
+ Optional controls +
+
+
Refresh listsPull Goodreads and Notion changes now.
+
Check autograbsSearch bookmarks and configured automatic rules.
+
Download handoffSend eligible selected releases to qBittorrent now.
+
Organize completedCheck qBittorrent and publish completed books.
+
Clean duplicatesRun the configured replacement and cleanup policy.
+
+
+ +
+ + + Automation details + Background schedules + Open this when you want the technical status of each worker. + + + {% if job_summary.issues %}{{ job_summary.issues }} issue{% if job_summary.issues != 1 %}s{% endif %}{% endif %} + {% if job_summary.running %}{{ job_summary.running }} running{% endif %} + {{ jobs | length }} total + + + {% if jobs %} +
+ + + + {% for name,status in jobs.items() %} + + + + + + + {% endfor %} + +
JobStatusLast resultLast run
{{ name }}{% if status.last_error %}
{{ status.last_error }}
{% endif %}
{% if status.running %}Running{% elif status.last_error %}Error{% else %}Idle{% endif %} + {% if status.last_result is mapping and status.last_result.get("downloaded") is not none %} + {{ status.last_result.get("downloaded", 0) }} downloaded | {{ status.last_result.get("failed", 0) }} failed | {{ status.last_result.get("skipped", 0) }} deferred + {% set reasons = status.last_result.get("skip_reasons") or {} %} + {% if reasons.get("unsat_slots") %}
{{ reasons.get("unsat_slots") }} waiting for an available MaM slot
{% endif %} + {% if reasons.get("ratio_buffer") %}
{{ reasons.get("ratio_buffer") }} held by the configured ratio reserve
{% endif %} + {% elif status.last_result is mapping and status.last_result.get("linked") is not none %} + {{ status.last_result.get("linked", 0) }} organized{% if status.last_result.get("failed", 0) %} {{ status.last_result.get("failed") }} failed{% endif %} | {{ status.last_result.get("already_existing", 0) }} already existed | {{ status.last_result.get("incomplete", 0) }} still downloading | {{ status.last_result.get("skipped", (status.last_result.get("skip_reasons") or {}).values() | sum) }} skipped + {% set failures = status.last_result.get("failures") or [] %} + {% if failures %}
{{ failures[-1].get("error") }}
Open recovery{% endif %} + {% set reasons = status.last_result.get("skip_reasons") or {} %} + {% if reasons.get("no_matching_library") %}
{{ reasons.get("no_matching_library") }} had no matching library category or path
{% endif %} + {% if reasons.get("no_preferred_files") %}
{{ reasons.get("no_preferred_files") }} had no preferred audio or ebook file
{% endif %} + {% elif status.last_result is mapping and status.last_result.get("refreshed") is not none %} + {{ status.last_result.get("refreshed", 0) }} refreshed | {{ status.last_result.get("selected", 0) }} selected{% if status.last_result.get("already_grabbed") is not none %} | {{ status.last_result.get("already_grabbed", 0) }} remembered{% endif %}{% if status.last_result.get("failed") is not none %} | {{ status.last_result.get("failed", 0) }} failed{% endif %} + {% elif status.last_result is not none %}{{ status.last_result }}{% else %}No run yet{% endif %} +
{% if status.last_run %}{{ status.last_run }}{% else %}Never{% endif %}
+
+ {% else %} +
No jobs registered yet

Scheduler status appears after application startup.

+ {% endif %} +
+{% endblock %} diff --git a/python/src/mlm/templates/library.html b/python/src/mlm/templates/library.html new file mode 100644 index 00000000..2c394f32 --- /dev/null +++ b/python/src/mlm/templates/library.html @@ -0,0 +1,243 @@ +{% extends "base.html" %} +{% block content %} + + +{% if abs_synced is not none %} +
+ + Successfully synced {{ abs_synced }} audiobook{% if abs_synced != 1 %}s{% endif %} and their series from your Audiobookshelf library! + + ABS Catalog Synced +
+{% endif %} + +{% if abs_sync_error %} + +{% endif %} + +{% if series_queued is not none %} +
+ + {% if series_queued > 0 %} + Queued {{ series_queued }} missing book{% if series_queued != 1 %}s{% endif %} for series "{{ series_name }}". + {% if series_existing > 0 %}({{ series_existing }} book{% if series_existing != 1 %}s{% endif %} already in your library or queue).{% endif %} + {% elif series_existing > 0 %} + All {{ series_existing }} book{% if series_existing != 1 %}s{% endif %} in series "{{ series_name }}" are already in your library or queue! + {% else %} + No releases found on MaM for series "{{ series_name }}". + {% endif %} + + Missing Books Queued +
+{% endif %} + + + +{% if library_view == "missing" %} +
+
+
+

Find Missing Books on MaM

+

Scan any series against MyAnonamouse to detect un-downloaded installments, or review the series already detected across your library and Audiobookshelf below.

+
+
+ + +
+ +{% if series_resolution %} +
+
+
+

MaM Series Scanner

+

Series: {{ series_resolution.series_name }}

+

+ Found {{ series_resolution.total_books }} book{% if series_resolution.total_books != 1 %}s{% endif %} on MaM. + {{ series_resolution.already_present | length }} already in your library or queue, + {{ series_resolution.missing_books | length }} missing. +

+
+ {% if series_resolution.missing_books %} +
+ + +
+ {% endif %} +
+ + {% if series_resolution.error %} +
+ Error scanning series: {{ series_resolution.error }} +
+ {% elif series_resolution.missing_books %} +
+ {% for book in series_resolution.missing_books %} + {% set rel = book.release %} +
+
+
+ Book #{{ book.volume }}: {{ rel.title }} + + {% if rel.authors %}{{ rel.authors | join(", ") }} · {% endif %} + {% if rel.filetypes %}{{ rel.filetypes | join(", ") | upper }} · {% endif %} + {{ rel.size }} · + {% for badge in rel.availability %} + {% if badge.tone == "free" %}{{ badge.label }} · {% endif %} + {% endfor %} + {{ rel.seeders }} seeders + +
+
+
+ + + +
+
+
+
+ {% endfor %} +
+ {% else %} +
+
+ All books present! +

All {{ series_resolution.total_books }} known books for series "{{ series_resolution.series_name }}" are already in your library or download queue.

+
+
+ {% endif %} +
+{% endif %} + +
+
+
+

Library Series Audit

+

Series detected across your organized downloads and Audiobookshelf library. Scan any series with 1 click to fetch missing books from MaM.

+
+ {{ library_series | length }} series tracked +
+ + {% if library_series %} +
+ {% for s in library_series %} +
+
+
+ {{ s.series_name }} + + {% if s.authors %}{{ s.authors | join(", ") }} · {% endif %} + {{ s.volumes_count }} book{% if s.volumes_count != 1 %}s{% endif %} in library + (Volumes: {{ s.volumes | join(", ") }}) + + {% if s.gaps %} + Detected gaps: Missing {{ s.gaps | join(", ") }} + {% endif %} +
+
+ {% if s.gaps %} + Missing {{ s.gaps | join(", ") }} + {% else %} + Sequence intact + {% endif %} +
+ Check on MaM +
+ + +
+ Search +
+
+
+
+ {% endfor %} +
+ {% else %} +
+
+ No series detected yet +

Sync with Audiobookshelf or let HeavyMLM organize your downloads into series folders, and detected series and volume gaps will appear here.

+
+
+ {% endif %} +
+ +{% else %} +
+
+
+

{% if library_view == "abs" %}Audiobookshelf Synchronized Books{% elif library_view == "duplicates" %}Duplicate and replacement candidates{% else %}Processed library records{% endif %}

+

{% if library_view == "abs" %}Audiobooks indexed directly from your Audiobookshelf server to prevent duplicate downloads and detect missing series installments.{% elif library_view == "duplicates" %}Candidates retained for HeavyMLM cleanup and quality-replacement decisions.{% else %}Torrent records already inspected and tracked by HeavyMLM's library pipeline.{% endif %}

+
+ {% if rows %}{{ first_record }}–{{ last_record }} of {{ total }}{% else %}0 records{% endif %} +
+ {% if rows %}
+ {% for row in rows %} + {% set meta = row.get("meta") or {} %} +
+
+
+ {{ row.get("title") or meta.get("title") or row.get("title_search") or row.get("id") or "Untitled record" }} + + {% if row.get("mam_id") %}MaM #{{ row.get("mam_id") }} · {% elif row.get("abs_id") %}ABS #{{ row.get("abs_id") }} · {% endif %} + {% if meta.get("authors") or row.get("authors") %}{{ (meta.get("authors") or row.get("authors")) | join(", ") }} · {% endif %} + {% if row.get("library_path") %}{{ row.get("library_path") }}{% endif %} + + {% if library_view == "duplicates" and row.get("duplicate_of") %}Duplicate of {{ row.get("duplicate_of") }}{% endif %} +
+ {% if library_view == "abs" %}Audiobookshelf{% elif library_view == "duplicates" %}Review candidate{% else %}Processed{% endif %} +
+
Record details
{{ row | tojson(indent=2) }}
+
+ {% endfor %} +
{% else %}
No records in this view

{% if library_view == "abs" %}Click "Sync with Audiobookshelf" above to import your books from ABS.{% else %}HeavyMLM will populate it as the organizer and cleaner process your library.{% endif %}

{% endif %} +
+ +{% if page_count > 1 %}{% endif %} +{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/lists.html b/python/src/mlm/templates/lists.html new file mode 100644 index 00000000..7d9c202c --- /dev/null +++ b/python/src/mlm/templates/lists.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block content %} + + +
+ + All sources{{ counts.get("list_items", 0) }} itemsShow combined source memory + + {% for source in sources %} + + Tracked source{{ source.title or source.id }}{{ item_counts.get(source.id|string, 0) }} items{% if source.updated_at %} · refreshed {% endif %} + + {% endfor %} +
+ +
+
+

{% if selected_source %}{{ selected_source.title }}{% else %}Tracked list items{% endif %}

Persistent memory prevents earlier grabs from being downloaded again on the next refresh.

+ {% if rows %}{{ first_record }}–{{ last_record }} of {{ total }}{% else %}0 items{% endif %} +
+ {% if rows %}
+ {% for row in rows %} +
+
+
+ {{ row.get("title") or "Untitled list item" }} + {% if row.get("authors") %}{{ row.get("authors") | join(", ") }}{% endif %}{% if row.get("selected_mam_ids") %} · MaM {{ row.get("selected_mam_ids") | join(", ") }}{% endif %}{% if row.get("check_count") is not none %} · checked {{ row.get("check_count") }} times{% endif %} + {% if row.get("last_result") %}{{ row.get("last_result") }}{% endif %} +
+
+ {% if row.get("source_status") == "removed" %}Removed from source + {% elif row.get("status") in ["selected", "already_grabbed"] %}{{ row.get("status") | replace("_", " ") }} + {% elif row.get("status") == "partial" %}Missing one format + {% elif row.get("status") == "no_match" %}No match + {% else %}Tracked{% endif %} +
+
+
Item details
{{ row | tojson(indent=2) }}
+
+ {% endfor %} +
{% else %}
No tracked items in this view

Refresh configured lists to populate their source memory.

{% endif %} +
+ +{% if page_count > 1 %}{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/mam_spender.html b/python/src/mlm/templates/mam_spender.html new file mode 100644 index 00000000..9ae5167c --- /dev/null +++ b/python/src/mlm/templates/mam_spender.html @@ -0,0 +1,173 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+
Loading MaM clocks...
+
+ + + +{% if spender_view == "dashboard" %} +
+
Bonus balance{{ spender.last_scan_points or "N/A" }}last verified scan
+
Points / hour{% if spender.points_per_min is not none %}{{ (spender.points_per_min * 60) | round | int }}{% else %}N/A{% endif %}observed earning rate
+
Upload bought{{ spender.totals.cumulative_upload_gb }} GiBconfirmed purchases
+
Wedges bought{{ spender.totals.cumulative_freeleech_wedges }}50,000 points each
+
Points spent{{ spender.totals.cumulative_points_spent }}all confirmed spending
+
Next scan{% if spender.scheduler_enabled %}Scheduled{% else %}Paused{% endif %}live countdown
+
+ +
+
+
SPEND_AUDIT.log
+
+ {% for row in spender.logs %}

{{ row.message }}

{% else %}

No MAM-Spender activity yet.

{% endfor %} +
+
+ +
+

Account snapshot

{{ spender.user.username }}

+
+
VIP expires
{{ spender.user.vip_expires }}
+
Uploaded
{{ spender.user.uploaded }}
+
Downloaded
{{ spender.user.downloaded }}
+
Ratio
{{ spender.user.ratio }}
+
VIP renewals
{{ spender.totals.cumulative_vip_purchases }}
+
Wedge points
{{ spender.totals.cumulative_freeleech_points_spent }}
+
+
+ +
+
+ +
+ {% include "modules/mam_spender/_purchase_settings.html" %} +
+

Live configuration

+

Changes apply immediately

+

These are the controls used most often in the original Web Edition. Save here without leaving the dashboard; authentication, migration, and runtime details remain on the dedicated Config page.

+
+
Scheduler
{% if spender.scheduler_enabled %}Enabled{% else %}Paused{% endif %}
+
Next target
{{ spender.settings.alternate_next_purchase | replace('_', ' ') }}
+
Reserve
{{ spender.settings.points_buffer }} points
+
Interval
{{ spender.settings.next_run_delay_minutes }} minutes
+
+ +
+
+ +
+

Purchase guardrails

+
+
50 GiBper 25,000 points
+
150 GiBmaximum per run
+
50,000per freeleech wedge
+
{{ spender.settings.points_buffer }}points kept in reserve
+
+

Purchases are irreversible. MAM-Spender records a purchase only after the bonus balance confirms the expected decrease.

+
+{% elif spender_view == "config" %} +
+

Independent module settings

MAM-Spender configuration

+ Saved locally +
+
+ {% include "modules/mam_spender/_purchase_settings.html" %} + +
+
+

Shared authentication

+

MaM Session_ID

+

MAM-Spender and HeavyMLM now use one API session. Paste a raw Session_ID, Cookie header, Netscape/curl cookie file contents, or browser JSON export.

+ + +
Open MaM Security
+

The value is never returned to the browser after saving.

+
+ +
+

Web Edition migration

+

Import old config.json

+

Paste the complete standalone data/config.json to migrate settings, totals, local history, spending events, cached MaM data, and saved Session_ID.

+ + +
+
+
+ +
+

Runtime identity

This is the integrated Web Edition module

Open suite network config
+
+
Suite build{{ spender_runtime.suite_version }}
+
Ported feature baseline{{ spender_runtime.source_version }}
+
Active listener{{ spender_runtime.host }}:{{ spender_runtime.port }}
+
Module databaseSQLite schema v4
+
+ {% if spender_runtime.config_path %}

Suite config: {{ spender_runtime.config_path }}

{% endif %} +

The module owns its purchase policy, scheduler, Session_ID import, history, graphs, and cached MaM data. Host and port remain suite-wide so you only run one server and maintain one reverse proxy.

+
Session instructions and purchase warnings +

Create the API session on the same internet connection as MyAnonaSuite. In MaM, open Preferences → Security, create an API session with the appropriate IP/ASN lock, then paste it above. Recreate it whenever the locked network identity changes.

+
  • Minimum upload purchase: 50 GiB.
  • Upload credit: 25,000 points per 50 GiB.
  • Upload run cap: 150 GiB.
  • Freeleech Wedges: 50,000 points each.
  • Purchases cannot be reversed.
+

MAM-Spender Web support thread

+
+
+{% elif spender_view == "history" %} +
+

Automation history

Every run, including no-op scans and failures.

+
TimeResultPointsUploadWedgesVIP
+
+
+

MaM bonus history

Up to 500 returned point and wedge records.

+
TimeTypeAmountTitleOther user
+
Page 0 of 0
+
+{% elif spender_view == "analytics" %} +
+

Local spending ledger

Spending by category

+ +
+
+

Confirmed spending events

Only purchases verified against the post-purchase point balance.

TimeSpent onPointsUnitsBalance after
+{% elif spender_view == "mam-data" %} +
+

Live account intelligence

All MaM data

+ +

{% if spender.mam_user_error %}{{ spender.mam_user_error }}{% elif spender.mam_user_fetched_at %}Last refreshed {{ spender.mam_user_fetched_at }}{% else %}Refresh to load the latest account snapshot.{% endif %}

+
+
+
+ +

Special thanks

Web Edition credits

The MaM staff; Knightguard; BareMetal / B4reMetal; Curiosgamez; Mr. Cookies; PsyckoRN; wokka; romey; and everyone who helped in the support thread.

+
+{% endif %} + + +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/python/src/mlm/templates/modules.html b/python/src/mlm/templates/modules.html new file mode 100644 index 00000000..27776415 --- /dev/null +++ b/python/src/mlm/templates/modules.html @@ -0,0 +1,165 @@ +{% extends "base.html" %} +{% block content %} + + +{% if saved %} +
Your active module choices have been saved and applied!Saved
+{% endif %} +{% if error %} + +{% endif %} + +
+
+ + +
+
+
+ H_ +
+

HeavyMLM

+

Book downloader & library organizer

+
+
+ + {% if 'heavymlm' in enabled_modules %}Active{% else %}Disabled{% endif %} + +
+
+

+ Keeps an eye on your MyAnonamouse bookmarks, custom snatchlists, and Goodreads/Notion reading lists. Downloads new books through qBittorrent with ratio safeguards and sorts completed files neatly into your media folders. +

+
+ Autograbber + Ratio Guard + Wedges First + Zero-Space Hardlinks + Series Sorter +
+ +
+
+ + +
+
+
+ A_ +
+

ABSidekick

+

Audiobookshelf metadata & review helper

+
+
+ + {% if 'absidekick' in enabled_modules %}Active{% else %}Disabled{% endif %} + +
+
+

+ Connects to your Audiobookshelf server to find missing book details, match summaries and covers across Audible, Open Library, and Google Books, tidy up series numbering, and let you review matches easily. +

+
+ Audiobookshelf API + Audible + Open Library + Google Books + Review Desk +
+ +
+
+ + +
+
+
+ M$ +
+

MAM-Spender

+

Bonus point optimizer & VIP buyer

+
+
+ + {% if 'mam_spender' in enabled_modules %}Active{% else %}Disabled{% endif %} + +
+
+

+ Automates spending your accumulated MyAnonamouse bonus points. Keeps your account VIP status continuously renewed, buys freeleech wedges, tracks your point growth over time, and graphs your earnings. +

+
+ Auto VIP + Auto Wedges + Point Buffer + Visual Graphs + Purchase Log +
+ +
+
+ + +
+
+
+ R_ +
+

Request Portal

+

Safe request page for family and friends

+
+
+ + {% if config.request_portal_enabled %}Active{% else %}Disabled{% endif %} + +
+
+

+ A locked-down, public-friendly web page where friends and family can search Goodreads and submit book requests straight into your approval inbox or download queue. +

+
+ Goodreads Search + User Passwords + Auto-Approve Option + Safe Domain Mode + Anti-Spam Limits +
+ +
+
+ +
+ +
+
+
+ Save Your Active Modules +

Turning off a module pauses its background tasks and hides its tabs so you only see what you actually use.

+
+ +
+
+
+{% endblock %} diff --git a/python/src/mlm/templates/modules/mam_spender/_purchase_settings.html b/python/src/mlm/templates/modules/mam_spender/_purchase_settings.html new file mode 100644 index 00000000..0a5680eb --- /dev/null +++ b/python/src/mlm/templates/modules/mam_spender/_purchase_settings.html @@ -0,0 +1,28 @@ +
+

Purchase policy

+

What should the spender buy?

+ + + + + +
+
+ +
+ {% for value,label in [(10000,"10k"),(15000,"15k"),(20000,"20k"),(25000,"25k")] %}{% endfor %} +
+
+
+ +
+ {% for value,label in [(2,"2m"),(60,"1h"),(360,"6h"),(720,"12h"),(1440,"24h")] %}{% endfor %} +
+
+
+
+ + +
+

Next alternating target: {{ spender.settings.alternate_next_purchase | replace('_', ' ') }}.

+
diff --git a/python/src/mlm/templates/operations.html b/python/src/mlm/templates/operations.html new file mode 100644 index 00000000..c6fde3f1 --- /dev/null +++ b/python/src/mlm/templates/operations.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block content %} + + + + +{% if operations_view == "diagnostics" %} + {% include "diagnostics.html" %} +{% elif operations_view == "errors" %} + {% include "errors.html" %} +{% else %} +
+
+

Lifecycle events

Stored organizer, tracker, library, and cleanup events in newest-first order.

+ {% if rows %}{{ first_record }}–{{ last_record }} of {{ total }}{% else %}0 records{% endif %} +
+ {% if rows %}
+ {% for row in rows %} + {% set event_value = row.get("event") %} +
+
+
+ {% if event_value is string %}{{ event_value }}{% elif event_value %}{{ event_value.keys() | list | first }}{% else %}Event{% endif %} + {% if row.get("mam_id") %}MaM #{{ row.get("mam_id") }}{% endif %}{% if row.get("torrent_id") %} · {{ row.get("torrent_id") }}{% endif %}{% if row.get("created_at") %} · {% endif %} +
+ Recorded +
+
Event details
{{ row | tojson(indent=2) }}
+
+ {% endfor %} +
{% else %}
No events yet

Organizer and cleanup lifecycle changes will appear here.

{% endif %} +
+{% if page_count > 1 %}{% endif %} +{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/records.html b/python/src/mlm/templates/records.html new file mode 100644 index 00000000..ee52d5a7 --- /dev/null +++ b/python/src/mlm/templates/records.html @@ -0,0 +1,106 @@ +{% extends "base.html" %} +{% block content %} + + +
+ {% if rows %} +
+ {% for row in rows %} + {% set meta = row.get("meta") or {} %} + {% set summary = row.get("title") or meta.get("title") or row.get("name") or row.get("id") or row.get("mam_id") or row.get("guid") %} +
+
+
+ {{ summary or "Untitled record" }} + + {% if row.get("mam_id") %}MaM #{{ row.get("mam_id") }}{% endif %} + {% if row.get("id") %} · ID {{ row.get("id") }}{% endif %} + {% if meta.get("authors") %} · {{ meta.get("authors") | join(", ") }}{% endif %} + + {% if table in ["lists", "list_items"] and row.get("last_result") %} + {{ row.get("last_result") }} + {% endif %} +
+
+ {% if table == "selected_torrents" %} +
+ + +
+ {% endif %} + {% if table == "selected_torrents" and row.get("started_at") %} + In qBittorrent + {% elif table == "selected_torrents" %} + Awaiting checks + {% elif table == "list_items" and row.get("source_status") == "removed" %} + Removed from source + {% elif table == "list_items" and row.get("status") in ["selected", "already_grabbed"] %} + {{ row.get("status") | replace("_", " ") }} + {% elif table == "list_items" and row.get("status") == "partial" %} + Missing one format + {% elif table == "list_items" and row.get("status") == "no_match" %} + No match + {% else %} + Stored + {% endif %} +
+
+
+ Raw record +
{{ row | tojson(indent=2) }}
+
+
+ {% endfor %} +
+ {% else %} +
+
+ No {{ title | lower }} yet +

This view will populate as the HeavyMLM module processes your pipeline.

+ {% if table == "selected_torrents" %}

Search MaM

{% endif %} +
+
+ {% endif %} +
+{% if page_count > 1 %} + +{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/request_portal.html b/python/src/mlm/templates/request_portal.html new file mode 100644 index 00000000..ce449ca3 --- /dev/null +++ b/python/src/mlm/templates/request_portal.html @@ -0,0 +1,186 @@ + + + + + + + {{ portal_title }} · MyAnonaSuite + + + +
+
+
+
R_MyAnonaSuite
+ {% if login_required %}
{{ portal_username or "Requester" }}{% if portal_auto_approve %} · auto-approval enabled{% endif %}
{% endif %} +
+

Private library acquisition portal

+

{{ portal_title }}

+

Find the exact audiobook, ebook, or entire series you want, then submit it{% if portal_auto_approve %} directly to the automated download policy{% else %} for approval{% endif %}.

+
+ + {% if portal_weekly_limit %} +
+ {{ portal_weekly_remaining }} request{% if portal_weekly_remaining != 1 %}s{% endif %} left + {{ portal_weekly_used }} of {{ portal_weekly_limit }} used in the rolling seven-day window +
+ {% endif %} + + {% if submission_result %} +
+ {% if submission_result == "approved" %}Request automatically approved.HeavyMLM scheduled it under the normal download guardrails. + {% elif submission_result == "series_approved" %}Series request automatically approved!HeavyMLM scheduled {{ series_count }} missing book{% if series_count != 1 %}s{% endif %} in series "{{ series_name }}"{% if series_existing > 0 %} ({{ series_existing }} was already in the library){% endif %}. + {% elif submission_result == "series_pending" %}Series request received!Submitted {{ series_count }} book{% if series_count != 1 %}s{% endif %} in series "{{ series_name }}" for library-owner approval{% if series_existing > 0 %} ({{ series_existing }} was already available){% endif %}. + {% elif submission_result == "series_fulfilled" %}You already have this entire series!All {{ series_total }} books in series "{{ series_name }}" are already in the library or download queue. + {% elif submission_result == "fulfilled" %}You already have this release.It is already in the library or pending for download. + {% else %}Request received.It is waiting in the approval inbox.{% endif %} +
+ {% endif %} + {% if submission_error %}{% endif %} + +
+
+

Goodreads link reader

+

Paste a Goodreads book link

+

We’ll read the public title, author, series, language, and ISBN, then use them to find matching MaM releases.

+
+
+ + +
+
+ + {% if goodreads_error %}{% endif %} + {% if goodreads %} +
+ {% if goodreads.cover_url %}Cover of {{ goodreads.title }}{% endif %} +
+ Goodreads metadata loaded +

{{ goodreads.title }}

+ {% if goodreads.authors %}

by {{ goodreads.authors | join(", ") }}

{% endif %} +
+ {% if goodreads.series %}{{ goodreads.series }}{% if goodreads.series_position %} #{{ goodreads.series_position }}{% endif %}{% endif %} + {% if goodreads.language %}{{ goodreads.language }}{% endif %} + {% if goodreads.isbn %}ISBN {{ goodreads.isbn }}{% endif %} + {% if goodreads.format %}{{ goodreads.format }}{% endif %} +
+ {% if goodreads.series %} +
+ Request Entire Series ({{ goodreads.series }}) +
+ + + + + + + +
+
+ {% endif %} +
+
+ {% endif %} + +
+

Advanced search

Every filled field is combined using AND logic.

{% if rows %}{{ rows | length }} matches{% endif %}
+
+ +
+
+ + {% if filters.series %} +
+
+
+

Series Collection

+

📚 Series: {{ filters.series }}

+

Request all missing books in this series at once.

+
+
+ + + + + +
+
+
+ {% endif %} + + {% if error %}{% endif %} + + {% if rows %} +
+

Search results

Choose a release

Scanned {{ scanned }}{% if found > scanned %} of {{ found }}{% endif %}
+ {% for row in rows %} +
+
{% for badge in row.availability %}{{ badge.label }}{% endfor %}
MaM #{{ row.id }}
+
+
+

{{ row.title }}

+ {% if row.authors %}

by {{ row.authors | join(", ") }}

{% else %}

Author information unavailable

{% endif %} + {% if row.series %} +

Series + {% for series in row.series %} + {{ series.name }}{% if series.entries %} #{{ series.entries | join(", ") }}{% endif %}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if row.narrators %}

Narrated by {{ row.narrators | join(", ") }}

{% endif %} +
+
+
Format
{{ row.filetypes | join(" / ") or "Unknown" }}
Category
{{ row.category }}
Size
{{ row.size }}
Language
{{ row.language }}
Files
{{ row.num_files or "—" }}
+
{{ row.seeders }} seeders{{ row.leechers }} leechers{{ row.snatched }} snatched
+
+ Request this release +
+ + + + + + + +
+ {% if row.series %} +
+ + + + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% elif searched and not error %} +
No releases passed every filter

Broaden one field at a time, or remove the series filter if Goodreads selected a different edition.

+ {% else %} +
R?
Start with a Goodreads link or advanced search

Results include format, author, series, language, cost, size, and seed health.

+ {% endif %} + +
MyAnonaSuite {{ version }}{% if portal_auto_approve %}Trusted account · automatic approval{% else %}Requests require library-owner approval{% endif %}
+
+ + diff --git a/python/src/mlm/templates/request_unlock.html b/python/src/mlm/templates/request_unlock.html new file mode 100644 index 00000000..aef159df --- /dev/null +++ b/python/src/mlm/templates/request_unlock.html @@ -0,0 +1,34 @@ + + + + + + + {{ portal_title }} · MyAnonaSuite + + + +
+
+ R_ +

MyAnonaSuite / private request portal

+

{{ portal_title }}

+

{% if login_mode == "credentials" %}Sign in to search the library request catalog and submit requests.{% elif login_mode == "setup_required" %}This request portal is locked until an administrator creates at least one request account.{% else %}Enter the shared access code to search the library request catalog.{% endif %}

+ {% if error %}{% endif %} + {% if login_mode == "setup_required" %} +
Account setup required. Open MyAnonaSuite locally, go to Configuration → Request login accounts, and create the first user.
+ {% else %} +
+ {% if login_mode == "credentials" %} + + + {% else %} + + {% endif %} + +
+ {% endif %} +
+
+ + diff --git a/python/src/mlm/templates/requests.html b/python/src/mlm/templates/requests.html new file mode 100644 index 00000000..7f8d6b63 --- /dev/null +++ b/python/src/mlm/templates/requests.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block content %} + + +{% if approved %}
Request approved! Added to your download queue.Approved
{% endif %} +{% if rejected %}
Request has been rejected.Closed
{% endif %} +{% if error %}
{{ error }}Not approved
{% endif %} + + + +
+ 💡 +
+ Safe Remote Sharing: Learn how to safely expose the request portal to family and friends with custom domains, named accounts, and rate limiting in the Setup Guide. +
+
+ +{% if rows %}
+ {% for row in rows %} +
+
{{ row.status }}

{{ row.release.title }}

{% if row.release.authors %}{{ row.release.authors | join(", ") }}{% else %}Unknown author{% endif %}

MaM #{{ row.mam_id }}
+
+
{{ row.release.filetypes | join(" / ") or "Unknown format" }}{{ row.release.category }}{{ row.release.size }}{{ row.release.seeders }} seeders
+ {% if row.release.series %}

Series: {% for series in row.release.series %}{{ series.name }}{% if series.entries %} #{{ series.entries | join(", ") }}{% endif %}{% endfor %}

{% endif %} +
{{ row.requester_name or row.requester_username or "Anonymous requester" }}{% if row.requester_username %}Account: {{ row.requester_username }}{% if "auto_approve" in row.requester_permissions %} · trusted for auto-approval{% endif %}{% endif %}{% if row.requester_contact %}{{ row.requester_contact }}{% endif %}{% if row.note %}

{{ row.note }}

{% endif %}
+ {% if row.source.kind == 'goodreads' %}

Open Goodreads source

{% endif %} + {% if row.decision_note %}
Decision{% if row.decision_by %} by {{ row.decision_by }}{% endif %}: {{ row.decision_note }}
{% endif %} +
+ {% if row.status == 'pending' %}
+
+
+
{% endif %} +
+ {% endfor %} +
{% else %}
No {{ selected_status }} requests

New portal submissions will appear here.

{% endif %} +{% endblock %} diff --git a/python/src/mlm/templates/tutorial.html b/python/src/mlm/templates/tutorial.html new file mode 100644 index 00000000..d046e7fb --- /dev/null +++ b/python/src/mlm/templates/tutorial.html @@ -0,0 +1,252 @@ +{% extends "base.html" %} +{% block content %} + + +
+ + +
+
+
+ 01 +

How Everything Fits Together

+

Three helpful tools working as one smooth team.

+
+
+
+

MyAnonaSuite combines three handy tools so you don't have to manage them separately:

+ +
+
+
H_
+

HeavyMLM

+

Finds & Organizes Books: Watches your bookmarks, wishlist, and Goodreads/Notion reading lists. Downloads books via qBittorrent while protecting your ratio, and organizes them into neat, clean folders for your library.

+
+ +
+
A_
+

ABSidekick

+

Matches & Polishes Metadata: Scans your Audiobookshelf library, finds accurate book descriptions and cover art across Audible, Open Library, and Google Books, fixes series numbering, and gives you an easy review desk for any tricky matches.

+
+ +
+
M$
+

MAM-Spender

+

Manages Bonus Points & VIP: Automatically uses your accumulated bonus points to keep your VIP status renewed and buy Freeleech Wedges, making sure you always have a safety reserve of points left over.

+
+
+ +
+ 💡 +
+ They work together automatically: When HeavyMLM finishes downloading and sorting a new book, it can tell ABSidekick right away to scan Audiobookshelf and match the metadata—no manual clicking required! +
+
+
+
+ + +
+
+
+ 02 +

Connecting Your MyAnonamouse Account

+

Grab your session cookie so the suite can talk to MaM.

+
+
+
+

Because MyAnonamouse doesn't use simple usernames/passwords for third-party apps, you just need your browser's session cookie:

+
    +
  1. Log into MyAnonamouse in your favorite web browser.
  2. +
  3. Press F12 (or right-click anywhere and pick Inspect) to open Developer Tools.
  4. +
  5. Click on the Application tab (Chrome/Edge) or Storage tab (Firefox).
  6. +
  7. In the sidebar, open Cookies and click https://www.myanonamouse.net.
  8. +
  9. Look for the row named mam_id and copy the long code in the Value column.
  10. +
  11. Paste it into the MaM ID Cookie box in Settings (or in your config.toml).
  12. +
+
+ 🔒 +
+ Your account is safe: Your cookie stays right on your machine. It is only ever used to communicate directly and securely with MyAnonamouse. +
+
+
+
+ + +
+
+
+ 03 +

Setting Up qBittorrent & Folders

+

How books get downloaded, kept seeding, and organized.

+
+
+
+

HeavyMLM talks to qBittorrent to download books and sort completed files into your library folder:

+ +
+
+

1. Enable qBittorrent Web UI

+

In qBittorrent, go to Options → Web UI and check "Web User Interface". Keep note of your port (usually 8080), username, and password.

+
+
+

2. Categories

+

Make sure you have categories like Audiobooks and Ebooks in qBittorrent so downloads stay tidy.

+
+
+

3. Smart Hardlinking (Zero Extra Space)

+

HeavyMLM uses hardlinks. That means your book appears neatly formatted in your Audiobookshelf folder (e.g. Author/Series/Book.m4b) without using double disk space and without interrupting your seeding in qBittorrent!

+
+
+

4. Ratio & Slot Protection

+

In Settings, you can set your minimum ratio and slot buffers. HeavyMLM will automatically pause downloading if your ratio or open slots get too low, protecting your good standing on the tracker.

+
+
+
+
+ + +
+
+
+ 04 +

Connecting Audiobookshelf & Metadata Matching

+

Get rich book descriptions, chapter lists, and crisp cover art.

+
+
+
+
    +
  1. In your Audiobookshelf web interface, head to Settings → Users → your user → API Token and copy your token.
  2. +
  3. Open ABSidekick Settings, type in your Audiobookshelf Server URL, paste the API Token, and click Test & Connect.
  4. +
  5. Pick the library you want to organize from the dropdown list.
  6. +
  7. + Pick your metadata helpers: +
      +
    • Audible: Great for audiobook summaries, narrators, and chapter information.
    • +
    • Open Library: Enter your email address to enable polite, fast lookups from the Open Library catalog. (Saved permanently!)
    • +
    • Google Books: Optional API key for extra ebook and author detail.
    • +
    +
  8. +
  9. Turn on Auto-Sync: In Settings, check Auto-sync with ABSidekick. Whenever HeavyMLM sorts a newly downloaded book, Audiobookshelf and ABSidekick will match it up automatically!
  10. +
+
+
+ + +
+
+
+ 05 +

Automating Your Bonus Points (MAM-Spender)

+

Never worry about VIP expiring or running out of Freeleech Wedges.

+
+
+
+

MAM-Spender checks your bonus point balance periodically and spends points smartly according to your preferences:

+
+
+

Keep VIP Active

+

Automatically buys a 4-week VIP extension whenever your VIP time gets low (e.g. less than 7 days left).

+
+
+

Stock Up on Freeleech Wedges

+

Buys wedges when you have extra points, so you can grab large audiobooks without affecting your upload ratio.

+
+
+

Safety Point Buffer

+

Set a reserve amount (e.g. 50,000 points) that the spender will never drop below, keeping an emergency cushion.

+
+
+

Visual Stats & History

+

Check out the Points Graph and Purchase History to see how many points you earn per hour and where they went.

+
+
+
+
+ + +
+
+
+ 06 +

Sharing a Request Portal with Friends & Family

+

How to safely let others request books without exposing your server.

+
+
+
+ +
+ ⚠️ +
+ IMPORTANT SAFETY ADVICE: KEEP PORT 3157 PRIVATE +

Your main MyAnonaSuite dashboard on port 3157 gives full control over your settings, cookies, and downloads. You should never expose port 3157 directly to the whole internet without a secure reverse proxy and domain setup.

+
+
+ +

How the Built-in Request Portal Keeps You Safe

+

If you want family or friends to search Goodreads and request books, MyAnonaSuite includes a special Safe Request Portal:

+
    +
  • Automatic Lock-Down: When visited via a dedicated domain name (like requests.yourdomain.com), the app strictly blocks all admin settings, configuration pages, and internal controls—showing only the friendly search and request box.
  • +
  • Individual User Accounts: Give each person their own username and password so you can see who requested what and keep things organized.
  • +
  • Rate Limits: Automatically prevents spam or runaway searches from overloading your connection.
  • +
+ +

Quick Reverse Proxy Recipes

+
+
+

Nginx Proxy Manager

+
    +
  1. Add a Proxy Host pointing requests.yourdomain.com to http://your-server-ip:3157.
  2. +
  3. Enable Websockets Support and Block Common Exploits.
  4. +
  5. In the SSL tab, enable Force SSL with a free Let's Encrypt certificate.
  6. +
+
+
+

Caddy

+
requests.yourdomain.com {
+    reverse_proxy localhost:3157 {
+        header_up X-Forwarded-Host {host}
+        header_up X-Forwarded-Proto {scheme}
+    }
+}
+
+
+

Cloudflare Tunnels

+

Add a Public Hostname pointing requests.yourdomain.com to http://localhost:3157 and make sure Host headers are forwarded.

+
+
+

Enable in Settings

+

In Settings → Request Portal, check Enable request portal, add your domain name, and create your requester accounts.

+
+
+
+
+ + +
+
+
+ 07 +

Using Only What You Need

+

Simplify your view anytime.

+
+
+
+

Only want to use MAM-Spender to manage bonus points, or only want ABSidekick for Audiobookshelf? No problem!

+

Head over to the Module Manager to turn off any tools you aren't using. The suite will streamline the sidebar and turn off unused background tasks right away.

+
+
+ +
+{% endblock %} diff --git a/python/src/mlm/torrent.py b/python/src/mlm/torrent.py new file mode 100644 index 00000000..6f979acf --- /dev/null +++ b/python/src/mlm/torrent.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import hashlib + + +class TorrentFormatError(ValueError): + pass + + +def _skip_value(data: bytes, offset: int) -> int: + if offset >= len(data): + raise TorrentFormatError("unexpected end of bencoded data") + marker = data[offset : offset + 1] + if marker == b"i": + end = data.find(b"e", offset + 1) + if end < 0: + raise TorrentFormatError("unterminated integer") + int(data[offset + 1 : end]) + return end + 1 + if marker == b"l": + offset += 1 + while data[offset : offset + 1] != b"e": + offset = _skip_value(data, offset) + return offset + 1 + if marker == b"d": + offset += 1 + while data[offset : offset + 1] != b"e": + offset = _skip_value(data, offset) + offset = _skip_value(data, offset) + return offset + 1 + if b"0" <= marker <= b"9": + colon = data.find(b":", offset) + if colon < 0: + raise TorrentFormatError("invalid byte string") + length = int(data[offset:colon]) + end = colon + 1 + length + if end > len(data): + raise TorrentFormatError("truncated byte string") + return end + raise TorrentFormatError(f"invalid bencode marker at byte {offset}") + + +def info_hash(torrent_file: bytes) -> str: + """Return the BitTorrent v1 SHA-1 info hash without re-encoding the payload.""" + if not torrent_file.startswith(b"d"): + raise TorrentFormatError("torrent root is not a dictionary") + offset = 1 + while torrent_file[offset : offset + 1] != b"e": + key_start = offset + key_end = _skip_value(torrent_file, key_start) + colon = torrent_file.find(b":", key_start) + key = torrent_file[colon + 1 : key_end] + value_start = key_end + value_end = _skip_value(torrent_file, value_start) + if key == b"info": + return hashlib.sha1(torrent_file[value_start:value_end]).hexdigest() + offset = value_end + raise TorrentFormatError("torrent has no info dictionary") diff --git a/python/src/mlm/web.py b/python/src/mlm/web.py new file mode 100644 index 00000000..d9b6764f --- /dev/null +++ b/python/src/mlm/web.py @@ -0,0 +1,2446 @@ +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import ipaddress +import json +import math +import time +from collections import defaultdict, deque +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager, suppress +from copy import deepcopy +from dataclasses import asdict +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlsplit + +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from . import __version__ +from .autograbber import select_row +from .config import ( + Config, + ConfigError, + RequestPortalUser, + load_config, + save_config_text, + save_root_config_values, +) +from .database import ensure_database +from .error_guidance import error_guidance +from .mam import authenticated_mam_client +from .modules.absidekick import SOURCE_VERSION, ABSidekickService +from .modules.absidekick.core import ABSAPIError +from .modules.heavymlm.abs_sync import sync_audiobookshelf_library +from .modules.heavymlm.search import ( + filter_search_results, + present_search_result, + search_seed, +) +from .modules.heavymlm.series import ( + detect_library_series, + resolve_series_selection, +) +from .modules.mam_spender import default_public_state +from .repository import Repository, RequestLimitReached +from .request_auth import hash_request_password, verify_request_password +from .request_portal import ( + GoodreadsLookupError, + goodreads_book_id, + lookup_goodreads_book, +) +from .scheduler import ServiceState +from .search import as_int + +PACKAGE_DIR = Path(__file__).resolve().parent +templates = Jinja2Templates(directory=PACKAGE_DIR / "templates") + +SUITE_MODULES = { + "heavymlm": { + "name": "HeavyMLM", + "icon": "H_", + "status": "Active", + "summary": "Automated MyAnonamouse library acquisition and organization.", + }, + "absidekick": { + "name": "ABSidekick", + "icon": "A_", + "status": "Active", + "summary": "Audiobookshelf metadata matching, review, and library repair.", + }, + "mam-spender": { + "name": "MAM-Spender", + "icon": "M$", + "status": "Active", + "summary": "Automated bonus-point spending, history, and account intelligence.", + }, +} + + +def _redacted_config(config: Config) -> dict: + value = asdict(config) + value["mam_id"] = "***" if config.mam_id else "" + if value.get("audiobookshelf"): + value["audiobookshelf"]["token"] = "***" + for row in value.get("qbittorrent", []): + if row.get("password"): + row["password"] = "***" + for row in value.get("notion_lists", []): + if row.get("token"): + row["token"] = "***" + if value.get("request_portal_access_code"): + value["request_portal_access_code"] = "***" + if value.get("request_portal_password_hash"): + value["request_portal_password_hash"] = "***" + for row in value.get("request_portal_users", []): + if row.get("password_hash"): + row["password_hash"] = "***" + return value + + +def create_app(config_path: Path, database_path: Path) -> FastAPI: + ensure_database(database_path) + config = load_config(config_path) + repository = Repository(database_path) + absidekick = ABSidekickService(database_path.parent / "absidekick") + snapshot_cache: dict[str, object] = {"expires": 0.0, "value": None} + snapshot_lock = asyncio.Lock() + request_rate_events: dict[str, deque[float]] = defaultdict(deque) + request_rate_lock = asyncio.Lock() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + mam = await authenticated_mam_client( + config.mam_id, + stored_mam_id=repository.config_value("mam_id"), + cookie_store=lambda value: repository.set_config_value("mam_id", value), + ) + state = ServiceState(config, repository, mam, absidekick=absidekick) + app.state.services = state + try: + state.start() + yield + finally: + await state.close() + + app = FastAPI(title="MyAnonaSuite", lifespan=lifespan) + app.mount("/static", StaticFiles(directory=PACKAGE_DIR / "static"), name="static") + + def active_config() -> Config: + if hasattr(app.state, "services"): + return app.state.services.config + return load_config(config_path) + + def spender_service(): + services = getattr(app.state, "services", None) + spender = getattr(services, "mam_spender", None) + if spender is None: + raise HTTPException(503, "MAM-Spender is still starting") + return spender + + def local_request(request: Request) -> bool: + client_host = request.client.host if request.client else "" + if client_host == "testclient": + return True + try: + return ipaddress.ip_address(client_host).is_loopback + except ValueError: + return False + + def request_portal_host(request: Request) -> bool: + configured_domains = { + domain.strip().casefold() + for domain in active_config().request_portal_domains + } + hostnames = {(request.url.hostname or "").casefold()} + # Nginx Proxy Manager normally preserves Host, but custom Advanced + # configurations sometimes retain the public name only here. Accepting + # a configured forwarded host can only reduce access to the request-only + # surface; it can never expose an administration route. + forwarded_host = request.headers.get("x-forwarded-host", "").split(",", 1)[0] + if forwarded_host.strip(): + try: + forwarded_hostname = urlsplit(f"//{forwarded_host.strip()}").hostname + except ValueError: + forwarded_hostname = None + if forwarded_hostname: + hostnames.add(forwarded_hostname.casefold()) + return bool(hostnames & configured_domains) + + def request_portal_root(request: Request) -> str: + return "/" if request_portal_host(request) else "/request" + + def configured_request_users(config: Config) -> tuple[RequestPortalUser, ...]: + users = list(config.request_portal_users) + known = {user.username.casefold() for user in users} + if ( + config.request_portal_username + and config.request_portal_password_hash + and config.request_portal_username.casefold() not in known + ): + users.append( + RequestPortalUser( + username=config.request_portal_username, + password_hash=config.request_portal_password_hash, + display_name=config.request_portal_username, + ) + ) + return tuple(users) + + def request_user(config: Config, username: str) -> RequestPortalUser | None: + normalized = username.strip().casefold() + return next( + ( + user + for user in configured_request_users(config) + if user.username.casefold() == normalized + ), + None, + ) + + def legacy_request_access_token(config: Config) -> str: + if config.request_portal_password_hash: + credential = "\0".join( + ( + "credentials", + config.request_portal_username, + config.request_portal_password_hash, + ) + ) + else: + # Preserve tokens issued by the legacy shared-code login. + credential = config.request_portal_access_code + return hmac.new( + config.mam_id.encode("utf-8"), + credential.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + def request_user_signature( + config: Config, + user: RequestPortalUser, + payload: str, + ) -> str: + return hmac.new( + config.mam_id.encode("utf-8"), + f"request-user\0{payload}\0{user.password_hash}".encode(), + hashlib.sha256, + ).hexdigest() + + def request_user_access_token(config: Config, user: RequestPortalUser) -> str: + payload = ( + base64.urlsafe_b64encode( + json.dumps( + {"username": user.username, "issued_at": int(time.time())}, + separators=(",", ":"), + ).encode("utf-8") + ) + .decode("ascii") + .rstrip("=") + ) + signature = request_user_signature(config, user, payload) + return f"user-v1.{payload}.{signature}" + + def request_portal_identity(request: Request) -> RequestPortalUser | None: + supplied = request.cookies.get("mysuite_request_access", "") + if not supplied.startswith("user-v1."): + return None + try: + _, payload, signature = supplied.split(".", 2) + padding = "=" * (-len(payload) % 4) + claims = json.loads( + base64.urlsafe_b64decode(payload + padding).decode("utf-8") + ) + username = str(claims["username"]) + issued_at = int(claims["issued_at"]) + except (KeyError, TypeError, UnicodeError, ValueError, json.JSONDecodeError): + return None + if issued_at > int(time.time()) + 60 or int(time.time()) - issued_at > 2678400: + return None + current = active_config() + user = request_user(current, username) + if user is None: + return None + expected = request_user_signature(current, user, payload) + return user if hmac.compare_digest(signature, expected) else None + + def request_login_mode(config: Config) -> str: + if configured_request_users(config): + return "credentials" + if config.request_portal_require_account_login: + return "setup_required" + if config.request_portal_access_code: + return "access_code" + return "public" + + def request_login_username_hint(config: Config) -> str: + return config.request_portal_username if not config.request_portal_users else "" + + def request_portal_authorized(request: Request) -> bool: + current = active_config() + login_mode = request_login_mode(current) + # Account-only mode deliberately has no localhost exception. Every + # submission must carry a named identity so its owner and quota can be + # audited. Anonymous local preview remains available only when the admin + # explicitly opts out of account-only mode. + if ( + local_request(request) + and not request_portal_host(request) + and not current.request_portal_require_account_login + ): + return True + if login_mode == "public": + return True + identity = request_portal_identity(request) + if login_mode == "credentials": + # Do not accept a legacy shared-access cookie once named credentials + # are active. That bypass lost the requester identity on submission. + return identity is not None + if login_mode != "access_code": + return False + supplied = request.cookies.get("mysuite_request_access", "") + return hmac.compare_digest( + supplied, + legacy_request_access_token(current), + ) + + def request_client_key(request: Request) -> str: + client_host = request.client.host if request.client else "unknown" + try: + proxy_is_local = ipaddress.ip_address(client_host).is_loopback + except ValueError: + proxy_is_local = False + forwarded = request.headers.get("x-forwarded-for", "") if proxy_is_local else "" + candidate = forwarded.split(",", 1)[0].strip() if forwarded else client_host + return candidate or "unknown" + + async def request_rate_allowed(request: Request, action: str) -> bool: + now = time.monotonic() + key = f"{action}:{request_client_key(request)}" + limit = active_config().request_portal_rate_limit + async with request_rate_lock: + events = request_rate_events[key] + while events and now - events[0] >= 60: + events.popleft() + if len(events) >= limit: + return False + events.append(now) + return True + + @app.middleware("http") + async def isolate_request_domain(request: Request, call_next): + if not request_portal_host(request): + return await call_next(request) + if not active_config().request_portal_enabled: + return HTMLResponse("Request portal is disabled", status_code=404) + path = request.url.path + allowed = path in { + "/", + "/request", + "/request/unlock", + "/request/logout", + "/request/submit", + "/request/series/submit", + } or path.startswith("/static/") + if not allowed: + return HTMLResponse("Not found", status_code=404) + return await call_next(request) + + async def ui_snapshot() -> dict: + now = time.monotonic() + cached = snapshot_cache["value"] + if cached is not None and now < float(snapshot_cache["expires"]): + return cached # type: ignore[return-value] + async with snapshot_lock: + now = time.monotonic() + cached = snapshot_cache["value"] + if cached is not None and now < float(snapshot_cache["expires"]): + return cached # type: ignore[return-value] + snapshot = await asyncio.to_thread(repository.ui_snapshot) + snapshot_cache.update(value=snapshot, expires=now + 0.75) + return snapshot + + async def context(request: Request, **values: object) -> dict: + snapshot = await ui_snapshot() + counts = snapshot["counts"] + suite_module = str(values.pop("suite_module", "heavymlm")) + current_cfg = active_config() + enabled_modules_set = { + m.strip().replace("-", "_").lower() for m in current_cfg.enabled_modules + } + return { + "request": request, + "counts": counts, + "pipeline": snapshot["pipeline"], + "list_tracking": snapshot["list_tracking"], + "request_tracking": snapshot["request_tracking"], + "record_total": sum(counts.values()), + "jobs": app.state.services.jobs if hasattr(app.state, "services") else {}, + "mam_stats": ( + app.state.services.mam_stats if hasattr(app.state, "services") else {} + ), + "version": __version__, + "suite_module": suite_module, + "suite_info": SUITE_MODULES.get(suite_module, SUITE_MODULES["heavymlm"]), + "suite_modules": SUITE_MODULES, + "enabled_modules": enabled_modules_set, + "config": _redacted_config(current_cfg), + "absidekick_auto_sync": current_cfg.absidekick_auto_sync, + **values, + } + + @app.get("/", response_class=HTMLResponse) + async def index(request: Request) -> Response: + if request_portal_host(request): + return await request_portal_page(request) + current_cfg = active_config() + enabled_modules_set = { + m.strip().replace("-", "_").lower() for m in current_cfg.enabled_modules + } + if "heavymlm" in enabled_modules_set: + return templates.TemplateResponse( + request, + "index.html", + await context( + request, + title="Home", + triggered=request.query_params.get("triggered"), + ), + ) + if "mam_spender" in enabled_modules_set: + return RedirectResponse("/suite/mam-spender/dashboard", status_code=303) + if "absidekick" in enabled_modules_set: + return RedirectResponse("/suite/absidekick/run", status_code=303) + return RedirectResponse("/modules", status_code=303) + + @app.get("/modules", response_class=HTMLResponse) + async def modules_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request, + "modules.html", + await context( + request, + title="Module Management", + saved=request.query_params.get("saved") == "1", + error=request.query_params.get("error"), + ), + ) + + @app.post("/modules", response_class=HTMLResponse) + async def save_modules_action( + request: Request, + module_heavymlm: str | None = Form(None), + module_absidekick: str | None = Form(None), + module_mam_spender: str | None = Form(None), + request_portal_enabled: str | None = Form(None), + ) -> Response: + modules = [] + if module_heavymlm is not None: + modules.append("heavymlm") + if module_absidekick is not None: + modules.append("absidekick") + if module_mam_spender is not None: + modules.append("mam_spender") + if request_portal_enabled is not None: + modules.append("request_portal") + try: + updated = save_root_config_values( + config_path, + { + "enabled_modules": tuple(modules), + "request_portal_enabled": request_portal_enabled is not None, + }, + ) + if hasattr(app.state, "services"): + await app.state.services.reconfigure(updated) + return RedirectResponse("/modules?saved=1", status_code=303) + except (ConfigError, ValueError) as error: + return templates.TemplateResponse( + request, + "modules.html", + await context( + request, + title="Module Management", + saved=False, + error=str(error), + ), + ) + + @app.get("/tutorial", response_class=HTMLResponse) + async def tutorial_page(request: Request) -> HTMLResponse: + return templates.TemplateResponse( + request, + "tutorial.html", + await context( + request, + title="Getting Started Guide", + ), + ) + + async def prepare_error_rows(rows: list[dict]) -> None: + pending_mam_ids = { + int(row["mam_id"]) + for row in await asyncio.to_thread(repository.pending_selected) + } + for row in rows: + identifier = row.get("id") + row["_error_id"] = json.dumps( + identifier, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + row["_mam_id"] = ( + identifier.get("Grabber") if isinstance(identifier, dict) else None + ) + row["_retryable"] = ( + row["_mam_id"] is not None and int(row["_mam_id"]) in pending_mam_ids + ) + row["_guidance"] = error_guidance(str(row.get("error", ""))) + + @app.get("/operations", response_class=HTMLResponse) + async def operations( + request: Request, + view: str = "diagnostics", + component: str = "", + page: int = 1, + live: str = "1", + ) -> HTMLResponse: + selected_view = ( + view if view in {"diagnostics", "events", "errors"} else "diagnostics" + ) + page = max(1, page) + page_size = 50 + activity: list[dict] = [] + rows: list[dict] = [] + total = 0 + if selected_view == "diagnostics": + activity = await asyncio.to_thread( + repository.recent_activity, + limit=300, + component=component or None, + ) + else: + table = "events" if selected_view == "events" else "errored_torrents" + rows = await asyncio.to_thread( + repository.table_rows, + table, + limit=page_size, + offset=(page - 1) * page_size, + ) + snapshot = await ui_snapshot() + total = int(snapshot["counts"].get(table, 0)) + if selected_view == "errors": + await prepare_error_rows(rows) + page_count = max(1, math.ceil(total / page_size)) + if selected_view != "diagnostics" and page > page_count: + return RedirectResponse( + f"/operations?{urlencode({'view': selected_view, 'page': page_count})}", + status_code=307, + ) + return templates.TemplateResponse( + request, + "operations.html", + await context( + request, + title="Operations", + operations_view=selected_view, + activity=activity, + component=component, + live=live != "0", + rows=rows, + page=page, + page_count=page_count, + total=total, + first_record=(page - 1) * page_size + 1 if rows else 0, + last_record=(page - 1) * page_size + len(rows), + retried=request.query_params.get("retried") == "1", + dismissed=request.query_params.get("dismissed") == "1", + dismissed_all=( + int(request.query_params["dismissed_all"]) + if request.query_params.get("dismissed_all", "").isdigit() + else None + ), + ), + ) + + @app.get("/lists", response_class=HTMLResponse) + async def combined_lists( + request: Request, + list_id: str = "", + page: int = 1, + ) -> HTMLResponse: + page = max(1, page) + page_size = 50 + sources = await asyncio.to_thread( + repository.table_rows, "lists", limit=500, offset=0 + ) + item_counts = await asyncio.to_thread(repository.list_item_counts_by_list) + rows = await asyncio.to_thread( + repository.list_item_rows, + list_id=list_id or None, + limit=page_size, + offset=(page - 1) * page_size, + ) + total = await asyncio.to_thread(repository.list_item_count, list_id or None) + page_count = max(1, math.ceil(total / page_size)) + if page > page_count: + query = {"page": page_count} + if list_id: + query["list_id"] = list_id + return RedirectResponse(f"/lists?{urlencode(query)}", status_code=307) + selected_source = next( + (source for source in sources if str(source.get("id")) == list_id), + None, + ) + return templates.TemplateResponse( + request, + "lists.html", + await context( + request, + title="Lists", + sources=sources, + item_counts=item_counts, + selected_list_id=list_id, + selected_source=selected_source, + rows=rows, + page=page, + page_count=page_count, + total=total, + first_record=(page - 1) * page_size + 1 if rows else 0, + last_record=(page - 1) * page_size + len(rows), + ), + ) + + @app.get("/library", response_class=HTMLResponse) + async def combined_library( + request: Request, + view: str = "processed", + series: str = "", + page: int = 1, + ) -> HTMLResponse: + selected_view = ( + view + if view in {"processed", "duplicates", "missing", "abs"} + else "processed" + ) + table = "torrents" if selected_view == "processed" else "duplicate_torrents" + page = max(1, page) + page_size = 50 + rows: list[dict] = [] + total = 0 + library_series: list[dict] = [] + series_scan_resolution: dict[str, Any] | None = None + cleaned_series = series.strip() + abs_books_count = await asyncio.to_thread(repository.abs_books_count) + + if selected_view == "missing": + library_series = await asyncio.to_thread(detect_library_series, repository) + total = len(library_series) + if cleaned_series: + try: + series_scan_resolution = await resolve_series_selection( + app.state.services.config, + repository, + app.state.services.mam, + cleaned_series, + ) + except Exception as ex: + series_scan_resolution = { + "series_name": cleaned_series, + "error": str(ex), + "missing_books": [], + "already_present": [], + "total_books": 0, + } + elif selected_view == "abs": + rows = await asyncio.to_thread( + repository.abs_book_rows, + limit=page_size, + offset=(page - 1) * page_size, + ) + total = abs_books_count + else: + rows = await asyncio.to_thread( + repository.table_rows, + table, + limit=page_size, + offset=(page - 1) * page_size, + ) + snapshot = await ui_snapshot() + total = int(snapshot["counts"].get(table, 0)) + + page_count = max(1, math.ceil(total / page_size)) + if selected_view != "missing" and page > page_count: + return RedirectResponse( + f"/library?{urlencode({'view': selected_view, 'page': page_count})}", + status_code=307, + ) + + abs_configured = bool( + ( + absidekick.token + or (config.audiobookshelf and config.audiobookshelf.token) + ) + and ( + absidekick.settings.get("connection", {}).get("baseUrl") + or (config.audiobookshelf and config.audiobookshelf.base_url) + ) + and ( + absidekick.settings.get("connection", {}).get("libraryId") + or ( + config.audiobookshelf + and getattr(config.audiobookshelf, "library_id", None) + ) + ) + ) + + return templates.TemplateResponse( + request, + "library.html", + await context( + request, + title="MLM Organized Library", + library_view=selected_view, + table=table, + rows=rows, + page=page, + page_count=page_count, + total=total, + first_record=(page - 1) * page_size + 1 if rows else 0, + last_record=(page - 1) * page_size + len(rows), + library_series=library_series, + active_series=cleaned_series, + series_resolution=series_scan_resolution, + abs_books_count=abs_books_count, + abs_configured=abs_configured, + abs_synced=( + as_int(request.query_params["abs_synced"]) + if "abs_synced" in request.query_params + else None + ), + abs_sync_error=request.query_params.get("abs_sync_error", ""), + series_queued=( + as_int(request.query_params["series_queued"]) + if "series_queued" in request.query_params + else None + ), + series_existing=as_int(request.query_params.get("series_existing", 0)), + series_total=as_int(request.query_params.get("series_total", 0)), + series_name=request.query_params.get("series_name", ""), + ), + ) + + @app.post("/library/sync-abs") + async def sync_abs_library() -> RedirectResponse: + settings = deepcopy(absidekick.settings) + token = absidekick.token + if ( + not token or not settings.get("connection", {}).get("baseUrl") + ) and config.audiobookshelf: + settings.setdefault("connection", {}) + if ( + not settings["connection"].get("baseUrl") + and config.audiobookshelf.base_url + ): + settings["connection"]["baseUrl"] = config.audiobookshelf.base_url + if not token and config.audiobookshelf.token: + token = config.audiobookshelf.token + + try: + result = await asyncio.to_thread( + sync_audiobookshelf_library, + repository, + settings, + token=token, + ) + snapshot_cache["expires"] = 0.0 + params = {"view": "missing", "abs_synced": result["total_synced"]} + return RedirectResponse(f"/library?{urlencode(params)}", status_code=303) + except Exception as error: + params = {"view": "missing", "abs_sync_error": str(error)} + return RedirectResponse(f"/library?{urlencode(params)}", status_code=303) + + @app.post("/library/series/select") + async def select_library_series( + series_name: str = Form(...), + ) -> RedirectResponse: + cleaned_series = series_name.strip() + if not cleaned_series: + raise HTTPException(400, "series_name is required") + + resolution = await resolve_series_selection( + app.state.services.config, + repository, + app.state.services.mam, + cleaned_series, + ) + + queued_count = 0 + for book in resolution["missing_books"]: + raw_row = book["raw_row"] + if not raw_row.get("dl"): + fetched = await app.state.services.mam.get_torrent_info_by_id( + book["mam_id"] + ) + if fetched: + raw_row = fetched + selected = await select_row( + app.state.services.config, + repository, + raw_row, + {"cost": "ratio", "name": f"series:{cleaned_series}"}, + ) + if selected: + queued_count += 1 + + if queued_count > 0: + asyncio.create_task(app.state.services.trigger("downloader")) + snapshot_cache["expires"] = 0.0 + repository.log_activity( + "organizer", + ( + f"Queued missing series books: {cleaned_series} ({queued_count} " + f"book(s) queued, {len(resolution['already_present'])} " + "already present)" + ), + level="success", + context={ + "series_name": cleaned_series, + "queued_count": queued_count, + "already_present": len(resolution["already_present"]), + "total_books": resolution["total_books"], + }, + ) + + params = { + "view": "missing", + "series": cleaned_series, + "series_name": cleaned_series, + "series_queued": queued_count, + "series_existing": len(resolution["already_present"]), + "series_total": resolution["total_books"], + } + return RedirectResponse(f"/library?{urlencode(params)}", status_code=303) + + @app.get("/records/{table}", response_class=HTMLResponse) + async def records(request: Request, table: str, page: int = 1) -> HTMLResponse: + consolidated = { + "events": ("/operations", "events"), + "errored_torrents": ("/operations", "errors"), + "lists": ("/lists", ""), + "list_items": ("/lists", ""), + "torrents": ("/library", "processed"), + "duplicate_torrents": ("/library", "duplicates"), + } + if table in consolidated: + target, view = consolidated[table] + query = dict(request.query_params) + if view: + query["view"] = view + suffix = f"?{urlencode(query)}" if query else "" + return RedirectResponse(target + suffix, status_code=307) + page = max(1, page) + page_size = 50 + try: + rows = await asyncio.to_thread( + repository.table_rows, + table, + limit=page_size, + offset=(page - 1) * page_size, + ) + except ValueError as error: + raise HTTPException(404, str(error)) from error + snapshot = await ui_snapshot() + total = int(snapshot["counts"].get(table, 0)) + page_count = max(1, math.ceil(total / page_size)) + if page > page_count: + return RedirectResponse( + f"/records/{table}?page={page_count}", status_code=307 + ) + return templates.TemplateResponse( + request, + "records.html", + await context( + request, + title=table.replace("_", " ").title(), + table=table, + rows=rows, + page=page, + page_count=page_count, + total=total, + first_record=(page - 1) * page_size + 1 if rows else 0, + last_record=(page - 1) * page_size + len(rows), + retried=request.query_params.get("retried") == "1", + dismissed=request.query_params.get("dismissed") == "1", + ), + ) + + @app.post("/errors/retry") + async def retry_error( + error_id: str = Form(...), mam_id: int | None = Form(None) + ) -> RedirectResponse: + try: + identifier = json.loads(error_id) + except json.JSONDecodeError as error: + raise HTTPException(400, "invalid error identifier") from error + if mam_id is None or not repository.has_pending_mam_id(mam_id): + raise HTTPException(409, "release is no longer pending for download") + repository.delete_error(identifier) + snapshot_cache["expires"] = 0.0 + asyncio.create_task(app.state.services.trigger("downloader")) + return RedirectResponse("/operations?view=errors&retried=1", status_code=303) + + @app.post("/errors/dismiss") + async def dismiss_error(error_id: str = Form(...)) -> RedirectResponse: + try: + identifier = json.loads(error_id) + except json.JSONDecodeError as error: + raise HTTPException(400, "invalid error identifier") from error + repository.delete_error(identifier) + snapshot_cache["expires"] = 0.0 + return RedirectResponse("/operations?view=errors&dismissed=1", status_code=303) + + @app.post("/errors/dismiss-all") + async def dismiss_all_errors() -> RedirectResponse: + dismissed = repository.dismiss_all_errors() + snapshot_cache["expires"] = 0.0 + return RedirectResponse( + f"/operations?view=errors&dismissed_all={dismissed}", status_code=303 + ) + + mam_spender_views = {"dashboard", "config", "history", "analytics", "mam-data"} + mam_spender_aliases = { + "settings": "config", + "graph": "analytics", + "mamdata": "mam-data", + } + + async def render_mam_spender(request: Request, view: str) -> HTMLResponse: + normalized = mam_spender_aliases.get(view.casefold(), view.casefold()) + if normalized not in mam_spender_views: + raise HTTPException(404, f"unknown MAM-Spender page: {view}") + services = getattr(app.state, "services", None) + spender = getattr(services, "mam_spender", None) + spender_state = ( + spender.public_state() + if spender is not None + else default_public_state(repository) + ) + current = active_config() + labels = { + "dashboard": "Dashboard", + "config": "Config", + "history": "History", + "analytics": "Graph", + "mam-data": "All MaM Data", + } + return templates.TemplateResponse( + request, + "mam_spender.html", + await context( + request, + title=f"MAM-Spender {labels[normalized]}", + suite_module="mam-spender", + spender=spender_state, + spender_view=normalized, + spender_runtime={ + "suite_version": __version__, + "source_version": "MAM-Spender Web Edition v1.4.0", + "host": current.web_host, + "port": current.web_port, + "config_path": str(config_path) if local_request(request) else None, + }, + ), + ) + + @app.get("/suite/mam-spender/{view}", response_class=HTMLResponse) + async def mam_spender_page(request: Request, view: str) -> HTMLResponse: + return await render_mam_spender(request, view) + + absidekick_views = { + "connection", + "targeting", + "matching", + "tags", + "run", + "review", + } + absidekick_aliases = {"config": "connection", "dashboard": "run"} + + async def render_absidekick(request: Request, view: str) -> HTMLResponse: + normalized = absidekick_aliases.get(view.casefold(), view.casefold()) + if normalized not in absidekick_views: + raise HTTPException(404, f"unknown ABSidekick page: {view}") + labels = { + "connection": "Config", + "targeting": "Targeting", + "matching": "Matching", + "tags": "Tags & Actions", + "run": "Run Center", + "review": "Review Desk", + } + return templates.TemplateResponse( + request, + "absidekick.html", + await context( + request, + title=f"ABSidekick {labels[normalized]}", + suite_module="absidekick", + absidekick_view=normalized, + absidekick_source_version=SOURCE_VERSION, + ), + ) + + @app.get("/suite/absidekick/{view}", response_class=HTMLResponse) + async def absidekick_page(request: Request, view: str) -> HTMLResponse: + return await render_absidekick(request, view) + + @app.get("/suite/{module}", response_class=HTMLResponse) + async def suite_module_page( + request: Request, module: str, view: str = "dashboard" + ) -> HTMLResponse: + if module not in {"absidekick", "mam-spender"}: + raise HTTPException(404, f"unknown suite module: {module}") + if module == "mam-spender": + return await render_mam_spender(request, view) + return await render_absidekick(request, view) + + async def absidekick_payload(request: Request) -> dict[str, object]: + try: + payload = await request.json() + except json.JSONDecodeError as error: + raise ValueError("request body must be valid JSON") from error + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + async def absidekick_call(request: Request, function, *args): + if not local_request(request): + return JSONResponse( + {"ok": False, "error": "ABSidekick controls are local-only"}, + status_code=403, + ) + try: + return await asyncio.to_thread(function, *args) + except ABSAPIError as error: + return JSONResponse( + {"ok": False, "error": str(error), "body": error.body}, + status_code=error.status or 502, + ) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + except RuntimeError as error: + return JSONResponse( + { + "ok": False, + "error": str(error), + "job": absidekick.job_snapshot(), + }, + status_code=409, + ) + except LookupError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=404) + except Exception as error: # noqa: BLE001 - return JSON to module UI + return JSONResponse({"ok": False, "error": str(error)}, status_code=500) + + @app.get("/api/absidekick/state") + async def absidekick_state(request: Request): + return await absidekick_call(request, absidekick.public_state) + + @app.get("/api/absidekick/libraries") + async def absidekick_libraries(request: Request): + return await absidekick_call(request, absidekick.libraries) + + @app.get("/api/absidekick/filter-data") + async def absidekick_filter_data(request: Request, libraryId: str = ""): + return await absidekick_call(request, absidekick.filter_data, libraryId) + + @app.get("/api/absidekick/job") + async def absidekick_job(request: Request): + return await absidekick_call( + request, lambda: {"ok": True, "job": absidekick.job_snapshot()} + ) + + @app.get("/api/absidekick/activity") + async def absidekick_activity(request: Request): + return await absidekick_call(request, absidekick.activity_snapshot) + + @app.get("/api/absidekick/item-cover/{item_id}") + async def absidekick_item_cover(request: Request, item_id: str): + result = await absidekick_call(request, absidekick.cover, item_id) + if isinstance(result, JSONResponse): + return result + content, content_type = result + return Response( + content, + media_type=content_type, + headers={"Cache-Control": "private, max-age=300"}, + ) + + @app.post("/api/absidekick/connect") + async def absidekick_connect(request: Request): + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + return await absidekick_call(request, absidekick.connect, payload) + + @app.post("/api/absidekick/settings") + async def absidekick_settings(request: Request): + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + return await absidekick_call(request, absidekick.save, payload) + + @app.post("/api/absidekick/provider/google/test") + async def absidekick_google_test(request: Request): + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + return await absidekick_call(request, absidekick.test_google_books, payload) + + @app.post("/api/absidekick/provider/google/clear") + async def absidekick_google_clear(request: Request): + return await absidekick_call(request, absidekick.clear_google_books) + + @app.post("/api/absidekick/preview") + async def absidekick_preview(request: Request): + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + return await absidekick_call(request, absidekick.preview, payload) + + @app.post("/api/absidekick/review/{action}") + async def absidekick_review(request: Request, action: str): + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + if action == "scan": + function = absidekick.scan_review + elif action == "search": + function = absidekick.search_review + elif action == "approve": + function = absidekick.approve_review + elif action == "reject": + function = absidekick.reject_review + else: + return JSONResponse( + {"ok": False, "error": "unknown review action"}, + status_code=404, + ) + return await absidekick_call(request, function, payload) + + @app.post("/api/absidekick/job/{action}") + async def absidekick_job_action(request: Request, action: str): + if action == "start": + try: + payload = await absidekick_payload(request) + except ValueError as error: + return JSONResponse({"ok": False, "error": str(error)}, status_code=400) + return await absidekick_call(request, absidekick.start, payload) + if action in {"pause", "resume", "cancel"}: + return await absidekick_call(request, absidekick.job_action, action) + return JSONResponse( + {"ok": False, "error": "unknown job action"}, status_code=404 + ) + + @app.get("/api/mam-spender/state") + async def mam_spender_state(request: Request) -> dict: + if not local_request(request): + raise HTTPException(403, "MAM-Spender controls are local-only") + return spender_service().public_state() + + @app.post("/api/mam-spender/settings") + async def save_mam_spender_settings(request: Request) -> dict: + if not local_request(request): + raise HTTPException(403, "MAM-Spender controls are local-only") + try: + payload = await request.json() + return spender_service().update_settings(payload) + except (TypeError, ValueError, json.JSONDecodeError) as error: + raise HTTPException(400, str(error)) from error + + @app.post("/api/mam-spender/session") + async def save_mam_spender_session(request: Request) -> dict: + if not local_request(request): + raise HTTPException(403, "MAM-Spender controls are local-only") + try: + payload = await request.json() + return spender_service().save_session_id(str(payload.get("value", ""))) + except (TypeError, ValueError, json.JSONDecodeError) as error: + raise HTTPException(400, str(error)) from error + + @app.post("/api/mam-spender/import") + async def import_mam_spender_config(request: Request) -> dict: + if not local_request(request): + raise HTTPException(403, "MAM-Spender controls are local-only") + try: + incoming = await request.json() + payload = incoming.get("config", incoming) + if isinstance(payload, str): + payload = json.loads(payload) + if not isinstance(payload, dict): + raise ValueError("legacy config must be a JSON object") + return spender_service().import_legacy(payload) + except (TypeError, ValueError, json.JSONDecodeError) as error: + raise HTTPException(400, str(error)) from error + + @app.post("/api/mam-spender/{action}") + async def mam_spender_action(request: Request, action: str) -> dict: + if not local_request(request): + raise HTTPException(403, "MAM-Spender controls are local-only") + spender = spender_service() + if action == "start": + return spender.start_scheduler() + if action == "pause": + return spender.pause_scheduler() + if action == "run": + payload = await request.json() + return spender.run_now( + fl_only_override=bool(payload.get("fl_only_override", False)) + ) + if action == "reset-totals": + return spender.reset_totals() + if action == "refresh-account": + return await spender.refresh_mam_user_data() + if action == "refresh-bonus-history": + return await spender.refresh_bonus_history() + raise HTTPException(404, "unknown MAM-Spender action") + + @app.get("/config", response_class=HTMLResponse) + async def show_config(request: Request) -> HTMLResponse: + editable = local_request(request) + return templates.TemplateResponse( + request, + "config.html", + await context( + request, + title="Configuration", + config=_redacted_config(active_config()), + saved=request.query_params.get("saved") == "1", + error=request.query_params.get("user_error") or None, + config_path=str(config_path), + config_toml=( + config_path.read_text(encoding="utf-8") if editable else None + ), + ), + ) + + @app.post("/config", response_class=HTMLResponse) + async def save_config( + request: Request, + min_ratio: str = Form(...), + unsat_buffer: str = Form(...), + max_unsat_slots: str = Form(""), + wedge_buffer: str = Form(...), + prefer_wedges: str | None = Form(None), + download_on_wedge_failure: str | None = Form(None), + grab_both_formats: str | None = Form(None), + absidekick_auto_sync: str | None = Form(None), + add_torrents_stopped: str | None = Form(None), + request_portal_enabled: str | None = Form(None), + request_portal_require_account_login: str | None = Form(None), + request_portal_domains: str = Form(""), + request_portal_title: str = Form("Library Requests"), + request_portal_rate_limit: str = Form("20"), + request_portal_username: str = Form(""), + request_portal_password: str = Form(""), + clear_request_portal_credentials: str | None = Form(None), + request_portal_access_code: str = Form(""), + clear_request_portal_access_code: str | None = Form(None), + search_interval: str = Form(...), + import_interval: str = Form(...), + link_interval: str = Form(...), + ) -> HTMLResponse: + try: + if not local_request(request): + raise ConfigError( + "configuration changes are only allowed from this computer" + ) + values: dict[str, object | None] = { + "min_ratio": float(min_ratio), + "unsat_buffer": int(unsat_buffer), + "max_unsat_slots": ( + int(max_unsat_slots) if max_unsat_slots.strip() else None + ), + "wedge_buffer": int(wedge_buffer), + "prefer_wedges": prefer_wedges is not None, + "download_on_wedge_failure": (download_on_wedge_failure is not None), + "grab_both_formats": grab_both_formats is not None, + "absidekick_auto_sync": absidekick_auto_sync is not None, + "add_torrents_stopped": add_torrents_stopped is not None, + "request_portal_enabled": request_portal_enabled is not None, + "request_portal_require_account_login": ( + request_portal_require_account_login is not None + ), + "request_portal_domains": tuple( + domain.strip() + for domain in request_portal_domains.replace("\n", ",").split(",") + if domain.strip() + ), + "request_portal_title": request_portal_title.strip() + or "Library Requests", + "request_portal_rate_limit": int(request_portal_rate_limit), + "search_interval": int(search_interval), + "import_interval": int(import_interval), + "link_interval": int(link_interval), + } + current = active_config() + if clear_request_portal_credentials is not None: + values["request_portal_username"] = None + values["request_portal_password_hash"] = None + else: + username = ( + request_portal_username.strip() or current.request_portal_username + ) + password_hash = current.request_portal_password_hash + if request_portal_password: + password_hash = await asyncio.to_thread( + hash_request_password, request_portal_password + ) + if username or password_hash: + if not username or not password_hash: + raise ConfigError( + "enter both a requester username and password" + ) + values["request_portal_username"] = username + values["request_portal_password_hash"] = password_hash + if request_portal_access_code: + values["request_portal_access_code"] = request_portal_access_code + elif clear_request_portal_access_code is not None: + values["request_portal_access_code"] = None + updated = save_root_config_values(config_path, values) + await app.state.services.reconfigure(updated) + except (ConfigError, ValueError) as error: + return templates.TemplateResponse( + request, + "config.html", + await context( + request, + title="Configuration", + config=_redacted_config(active_config()), + saved=False, + error=str(error), + config_path=str(config_path), + config_toml=( + config_path.read_text(encoding="utf-8") + if local_request(request) + else None + ), + ), + status_code=400, + ) + return RedirectResponse("/config?saved=1", status_code=303) + + def request_user_rows_for_save( + users: list[RequestPortalUser], + ) -> tuple[dict[str, object], ...]: + return tuple( + { + "username": user.username, + "password_hash": user.password_hash, + "display_name": user.display_name, + "permissions": tuple(user.permissions), + "weekly_request_limit": user.weekly_request_limit, + } + for user in users + ) + + @app.post("/config/request-users/save") + async def save_request_portal_user( + request: Request, + original_username: str = Form(""), + username: str = Form(...), + display_name: str = Form(""), + password: str = Form(""), + auto_approve: str | None = Form(None), + weekly_request_limit: int = Form(0), + ) -> RedirectResponse: + try: + if not local_request(request): + raise ConfigError( + "request account changes are only allowed from this computer" + ) + username = username.strip() + display_name = display_name.strip() + if not username: + raise ConfigError("request account username cannot be empty") + if weekly_request_limit < 0: + raise ConfigError("weekly request limit cannot be negative") + current = active_config() + users = list(current.request_portal_users) + lookup = (original_username or username).strip().casefold() + existing_index = next( + ( + index + for index, user in enumerate(users) + if user.username.casefold() == lookup + ), + None, + ) + duplicate = next( + ( + user + for index, user in enumerate(users) + if user.username.casefold() == username.casefold() + and index != existing_index + ), + None, + ) + if duplicate: + raise ConfigError( + f"request account username {username!r} already exists" + ) + password_hash = ( + users[existing_index].password_hash + if existing_index is not None + else "" + ) + if password: + password_hash = await asyncio.to_thread( + hash_request_password, + password, + ) + if not password_hash: + raise ConfigError("new request accounts require a password") + updated_user = RequestPortalUser( + username=username, + password_hash=password_hash, + display_name=display_name, + permissions=("auto_approve",) if auto_approve is not None else (), + weekly_request_limit=weekly_request_limit, + ) + if existing_index is None: + users.append(updated_user) + else: + users[existing_index] = updated_user + updated = save_root_config_values( + config_path, + {"request_portal_users": request_user_rows_for_save(users)}, + ) + await app.state.services.reconfigure(updated) + repository.log_activity( + "configuration", + f"Saved request account: {username}", + level="success", + context={ + "username": username, + "permissions": list(updated_user.permissions), + "weekly_request_limit": updated_user.weekly_request_limit, + }, + ) + except (ConfigError, ValueError) as error: + return RedirectResponse( + f"/config?{urlencode({'user_error': str(error)})}#request-accounts", + status_code=303, + ) + return RedirectResponse( + "/config?saved=1#request-accounts", + status_code=303, + ) + + @app.post("/config/request-users/delete") + async def delete_request_portal_user( + request: Request, + username: str = Form(...), + ) -> RedirectResponse: + if not local_request(request): + raise HTTPException( + 403, + "request account changes are only allowed from this computer", + ) + current = active_config() + normalized = username.strip().casefold() + users = [ + user + for user in current.request_portal_users + if user.username.casefold() != normalized + ] + if len(users) == len(current.request_portal_users): + raise HTTPException(404, "request account was not found") + updated = save_root_config_values( + config_path, + {"request_portal_users": request_user_rows_for_save(users)}, + ) + await app.state.services.reconfigure(updated) + repository.log_activity( + "configuration", + f"Deleted request account: {username.strip()}", + level="warning", + context={"username": username.strip()}, + ) + return RedirectResponse( + "/config?saved=1#request-accounts", + status_code=303, + ) + + @app.post("/config/full", response_class=HTMLResponse) + async def save_full_config( + request: Request, + config_toml: str = Form(...), + ) -> HTMLResponse: + try: + if not local_request(request): + raise ConfigError( + "configuration changes are only allowed from this computer" + ) + updated = save_config_text(config_path, config_toml) + await app.state.services.reconfigure(updated) + except (ConfigError, ValueError) as error: + return templates.TemplateResponse( + request, + "config.html", + await context( + request, + title="Configuration", + config=_redacted_config(active_config()), + saved=False, + error=str(error), + config_path=str(config_path), + config_toml=config_toml, + ), + status_code=400, + ) + return RedirectResponse("/config?saved=1", status_code=303) + + @app.get("/diagnostics") + async def diagnostics_redirect(request: Request) -> RedirectResponse: + query = dict(request.query_params) + query["view"] = "diagnostics" + return RedirectResponse(f"/operations?{urlencode(query)}", status_code=307) + + def manual_search_filters( + *, + q: str = "", + title: str = "", + author: str = "", + series: str = "", + narrator: str = "", + filetype: str = "", + category: str = "", + language: str = "", + availability: str = "", + min_seeders: int = 0, + sort: str = "relevance", + ) -> dict[str, object]: + return { + "q": q.strip(), + "title": title.strip(), + "author": author.strip(), + "series": series.strip(), + "narrator": narrator.strip(), + "filetype": filetype.strip().casefold(), + "category": category.strip().casefold(), + "language": language.strip(), + "availability": availability.strip().casefold(), + "min_seeders": max(0, min_seeders), + "sort": sort.strip() or "relevance", + } + + def search_was_requested(filters: dict[str, object]) -> bool: + return any( + value + for name, value in filters.items() + if name != "sort" and value not in {"", 0} + ) + + async def run_manual_search(filters: dict[str, object]) -> dict[str, object]: + rows: list[dict] = [] + found = 0 + scanned = 0 + error = None + if search_was_requested(filters): + try: + seed, search_in = search_seed(filters) + raw_by_id: dict[int, dict] = {} + start = 0 + for _ in range(5): + tor: dict[str, object] = {"startNumber": start} + if seed: + tor["text"] = seed + if search_in: + tor["srchIn"] = search_in + result = await app.state.services.mam.search( + { + "dlLink": True, + "mediaInfo": True, + "isbn": True, + "perpage": 100, + "tor": tor, + } + ) + raw_rows = [ + row for row in result.get("data", []) if isinstance(row, dict) + ] + found = max(found, as_int(result.get("found"), len(raw_rows))) + before = len(raw_by_id) + for row in raw_rows: + raw_by_id[as_int(row.get("id"))] = row + start += len(raw_rows) + if not raw_rows or len(raw_by_id) == before or start >= found: + break + scanned = len(raw_by_id) + presented = [ + present_search_result(row) + for torrent_id, row in raw_by_id.items() + if torrent_id + ] + rows = filter_search_results(presented, filters) + found = max(found, scanned) + except Exception as caught: # noqa: BLE001 - display API failure in UI + error = str(caught) + return { + "rows": rows, + "found": found, + "scanned": scanned, + "error": error, + } + + async def render_request_portal(request: Request) -> HTMLResponse: + current = active_config() + if not current.request_portal_enabled: + raise HTTPException(404, "request portal is disabled") + if not request_portal_authorized(request): + login_mode = request_login_mode(current) + return templates.TemplateResponse( + request, + "request_unlock.html", + { + "request": request, + "version": __version__, + "portal_title": current.request_portal_title, + "login_mode": login_mode, + "portal_username": request_login_username_hint(current), + "error": None, + }, + status_code=503 if login_mode == "setup_required" else 200, + ) + + identity = request_portal_identity(request) + portal_username = ( + (identity.display_name or identity.username) if identity else "" + ) + portal_auto_approve = bool(identity and "auto_approve" in identity.permissions) + portal_weekly_limit = identity.weekly_request_limit if identity else 0 + portal_weekly_used = ( + await asyncio.to_thread( + repository.request_count_since, + identity.username, + ) + if identity and portal_weekly_limit > 0 + else 0 + ) + portal_weekly_remaining = max( + 0, + portal_weekly_limit - portal_weekly_used, + ) + params = request.query_params + goodreads_url = params.get("goodreads_url", "").strip() + goodreads = None + goodreads_error = None + values = { + "q": params.get("q", ""), + "title": params.get("title", ""), + "author": params.get("author", ""), + "series": params.get("series", ""), + "narrator": params.get("narrator", ""), + "filetype": params.get("filetype", ""), + "category": params.get("category", ""), + "language": params.get("language", ""), + "availability": params.get("availability", ""), + "min_seeders": as_int(params.get("min_seeders")), + "sort": params.get("sort", "relevance"), + } + requested_lookup = bool( + goodreads_url + or any( + value + for name, value in values.items() + if name != "sort" and value not in {"", 0} + ) + ) + if requested_lookup and not await request_rate_allowed(request, "search"): + return templates.TemplateResponse( + request, + "request_portal.html", + { + "request": request, + "version": __version__, + "portal_title": current.request_portal_title, + "portal_root": request_portal_root(request), + "portal_username": portal_username, + "portal_auto_approve": portal_auto_approve, + "portal_weekly_limit": portal_weekly_limit, + "portal_weekly_used": portal_weekly_used, + "portal_weekly_remaining": portal_weekly_remaining, + "login_required": request_login_mode(current) != "public", + "filters": manual_search_filters(**values), + "searched": True, + "rows": [], + "found": 0, + "scanned": 0, + "error": "Too many searches. Wait one minute and try again.", + "goodreads_url": goodreads_url, + "goodreads": None, + "goodreads_error": None, + "requester_name": ( + params.get("requester_name", "").strip() or portal_username + )[:120], + "requester_contact": params.get("requester_contact", "")[:200], + "note": params.get("note", "")[:1000], + "submission_result": "", + "submission_error": params.get("request_error", ""), + }, + status_code=429, + ) + if goodreads_url: + try: + goodreads = await lookup_goodreads_book(goodreads_url) + values["title"] = values["title"] or goodreads["title"] + values["author"] = values["author"] or ( + goodreads["authors"][0] if goodreads["authors"] else "" + ) + values["series"] = values["series"] or goodreads["series"] + values["language"] = values["language"] or goodreads["language"] + repository.log_activity( + "requests", + f"Read Goodreads request link: {goodreads['title']}", + level="success", + context={ + "goodreads_id": goodreads["goodreads_id"], + "url": goodreads["url"], + }, + ) + except GoodreadsLookupError as caught: + goodreads_error = str(caught) + repository.log_activity( + "requests", + "Goodreads request lookup failed", + level="warning", + context={"url": goodreads_url, "error": goodreads_error}, + ) + filters = manual_search_filters(**values) + searched = search_was_requested(filters) + result = await run_manual_search(filters) + return templates.TemplateResponse( + request, + "request_portal.html", + { + "request": request, + "version": __version__, + "portal_title": current.request_portal_title, + "portal_root": request_portal_root(request), + "portal_username": portal_username, + "portal_auto_approve": portal_auto_approve, + "portal_weekly_limit": portal_weekly_limit, + "portal_weekly_used": portal_weekly_used, + "portal_weekly_remaining": portal_weekly_remaining, + "login_required": request_login_mode(current) != "public", + "filters": filters, + "searched": searched, + "rows": result["rows"], + "found": result["found"], + "scanned": result["scanned"], + "error": result["error"], + "goodreads_url": goodreads_url, + "goodreads": goodreads, + "goodreads_error": goodreads_error, + "requester_name": ( + params.get("requester_name", "").strip() or portal_username + )[:120], + "requester_contact": params.get("requester_contact", "")[:200], + "note": params.get("note", "")[:1000], + "submission_result": params.get("submitted", ""), + "submission_error": params.get("request_error", ""), + "series_name": params.get("series_name", ""), + "series_count": as_int(params.get("series_count")), + "series_existing": as_int(params.get("series_existing")), + "series_total": as_int(params.get("series_total")), + }, + ) + + @app.get("/request", response_class=HTMLResponse) + async def request_portal_page(request: Request) -> HTMLResponse: + return await render_request_portal(request) + + @app.post("/request/unlock", response_class=HTMLResponse) + async def unlock_request_portal( + request: Request, + username: str = Form(""), + password: str = Form(""), + access_code: str = Form(""), + ) -> HTMLResponse: + current = active_config() + if not current.request_portal_enabled: + raise HTTPException(404, "request portal is disabled") + allowed = await request_rate_allowed(request, "unlock") + login_mode = request_login_mode(current) + authenticated_user = None + if login_mode == "credentials": + authenticated_user = request_user(current, username) + credentials_valid = bool( + authenticated_user + and verify_request_password( + password, + authenticated_user.password_hash, + ) + ) + elif login_mode == "access_code": + credentials_valid = hmac.compare_digest( + access_code, current.request_portal_access_code + ) + elif login_mode == "setup_required": + credentials_valid = False + else: + credentials_valid = True + valid = allowed and credentials_valid + if not valid: + return templates.TemplateResponse( + request, + "request_unlock.html", + { + "request": request, + "version": __version__, + "portal_title": current.request_portal_title, + "login_mode": login_mode, + "portal_username": username, + "error": ( + "Too many attempts. Wait one minute." + if not allowed + else ( + "Create at least one request login account from the " + "local Configuration screen before using this portal." + if login_mode == "setup_required" + else ( + "That username or password is not valid." + if login_mode == "credentials" + else "That access code is not valid." + ) + ) + ), + }, + status_code=( + 429 + if not allowed + else 503 + if login_mode == "setup_required" + else 403 + ), + ) + response = RedirectResponse(request_portal_root(request), status_code=303) + forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",")[0] + response.set_cookie( + "mysuite_request_access", + ( + request_user_access_token(current, authenticated_user) + if authenticated_user is not None + else legacy_request_access_token(current) + ), + max_age=60 * 60 * 24 * 30, + httponly=True, + secure=request.url.scheme == "https" or forwarded_proto == "https", + samesite="strict", + path="/", + ) + return response + + @app.post("/request/logout") + async def logout_request_portal(request: Request) -> RedirectResponse: + response = RedirectResponse(request_portal_root(request), status_code=303) + response.delete_cookie( + "mysuite_request_access", + path="/", + httponly=True, + samesite="strict", + ) + return response + + async def approve_stored_request( + record: dict, + *, + decision_by: str, + automatic: bool, + ) -> tuple[str, str]: + request_id = str(record["id"]) + mam_id = int(record["mam_id"]) + if repository.has_mam_id(mam_id): + await asyncio.to_thread( + repository.update_request, + request_id, + "fulfilled", + decision_note="Already present in the library or pending for download", + decision_by="system", + ) + return "fulfilled", "" + row = await app.state.services.mam.get_torrent_info_by_id(mam_id) + if not row: + return "pending", "The selected MaM release no longer exists" + selected = await select_row( + app.state.services.config, + repository, + row, + { + "cost": "ratio", + "name": ( + f"request:auto:{decision_by}" + if automatic + else f"request:{request_id}" + ), + }, + ) + if not selected: + return "pending", "Release does not match configured formats" + decision_note = ( + f"Automatically approved for request account {decision_by}" + if automatic + else "Approved by administrator and scheduled for download" + ) + await asyncio.to_thread( + repository.update_request, + request_id, + "approved", + decision_note=decision_note, + decision_by=decision_by, + ) + asyncio.create_task(app.state.services.trigger("downloader")) + return "approved", "" + + @app.post("/request/submit") + async def submit_request( + request: Request, + mam_id: int = Form(...), + requester_name: str = Form(""), + requester_contact: str = Form(""), + note: str = Form(""), + goodreads_url: str = Form(""), + website: str = Form(""), + ) -> RedirectResponse: + current = active_config() + if not current.request_portal_enabled: + raise HTTPException(404, "request portal is disabled") + if not request_portal_authorized(request): + raise HTTPException(403, "request portal login required") + if not await request_rate_allowed(request, "submit"): + raise HTTPException(429, "too many requests; wait one minute") + root = request_portal_root(request) + if website.strip(): + return RedirectResponse(f"{root}?submitted=pending", status_code=303) + identity = request_portal_identity(request) + if current.request_portal_require_account_login and identity is None: + raise HTTPException(403, "named request account login required") + if identity and identity.weekly_request_limit > 0: + used = await asyncio.to_thread( + repository.request_count_since, + identity.username, + ) + if used >= identity.weekly_request_limit: + message = ( + f"Weekly request limit reached. {identity.username} has used " + f"all {identity.weekly_request_limit} requests in the rolling " + "seven-day window." + ) + repository.log_activity( + "requests", + f"Weekly request limit blocked {identity.username}", + level="warning", + context={ + "username": identity.username, + "used": used, + "weekly_request_limit": identity.weekly_request_limit, + }, + ) + return RedirectResponse( + f"{root}?{urlencode({'request_error': message})}", + status_code=303, + ) + row = await app.state.services.mam.get_torrent_info_by_id(mam_id) + if not row: + raise HTTPException(404, f"MaM torrent {mam_id} was not found") + release = present_search_result(row) + source: dict[str, object] = {"kind": "manual"} + if goodreads_url: + try: + source = { + "kind": "goodreads", + "url": goodreads_url, + "goodreads_id": goodreads_book_id(goodreads_url), + } + except GoodreadsLookupError: + source = {"kind": "manual"} + identity_name = identity.username if identity else "" + identity_permissions = identity.permissions if identity else () + try: + record = await asyncio.to_thread( + repository.create_request, + mam_id=mam_id, + release=release, + requester_name=requester_name[:120], + requester_contact=requester_contact[:200], + requester_username=identity_name, + requester_permissions=identity_permissions, + requester_weekly_limit=( + identity.weekly_request_limit if identity else 0 + ), + note=note[:1000], + source=source, + ) + except RequestLimitReached as error: + message = ( + f"Weekly request limit reached. {error.username} has used all " + f"{error.limit} requests in the rolling seven-day window." + ) + return RedirectResponse( + f"{root}?{urlencode({'request_error': message})}", + status_code=303, + ) + submission_result = "pending" + auto_approval_error = "" + if repository.has_mam_id(mam_id): + submission_result, _ = await approve_stored_request( + record, + decision_by="system", + automatic=False, + ) + elif identity and "auto_approve" in identity.permissions: + submission_result, auto_approval_error = await approve_stored_request( + record, + decision_by=identity.username, + automatic=True, + ) + if auto_approval_error: + await asyncio.to_thread( + repository.update_request, + record["id"], + "pending", + decision_note=("Automatic approval paused: " + auto_approval_error), + decision_by="system", + ) + repository.log_activity( + "requests", + ( + f"Auto-approved request: {release['title']}" + if submission_result == "approved" + else f"New request: {release['title']}" + ), + level=("success" if not auto_approval_error else "warning"), + context={ + "request_id": record["id"], + "mam_id": mam_id, + "requester_name": requester_name[:120], + "requester_username": identity_name, + "auto_approved": submission_result == "approved", + "auto_approval_error": auto_approval_error, + }, + ) + snapshot_cache["expires"] = 0.0 + return RedirectResponse( + f"{root}?submitted={submission_result}", + status_code=303, + ) + + @app.post("/request/series/submit") + async def submit_series_request( + request: Request, + series_name: str = Form(...), + requester_name: str = Form(""), + requester_contact: str = Form(""), + note: str = Form(""), + goodreads_url: str = Form(""), + website: str = Form(""), + ) -> RedirectResponse: + current = active_config() + if not current.request_portal_enabled: + raise HTTPException(404, "request portal is disabled") + if not request_portal_authorized(request): + raise HTTPException(403, "request portal login required") + if not await request_rate_allowed(request, "submit"): + raise HTTPException(429, "too many requests; wait one minute") + root = request_portal_root(request) + if website.strip(): + return RedirectResponse(f"{root}?submitted=pending", status_code=303) + identity = request_portal_identity(request) + if current.request_portal_require_account_login and identity is None: + raise HTTPException(403, "named request account login required") + + cleaned_series = series_name.strip() + if not cleaned_series: + raise HTTPException(400, "series name cannot be empty") + + resolution = await resolve_series_selection( + current, + repository, + app.state.services.mam, + cleaned_series, + ) + + missing = resolution["missing_books"] + already_present_count = len(resolution["already_present"]) + + if not missing and already_present_count > 0: + params = { + "submitted": "series_fulfilled", + "series_name": cleaned_series, + "series_total": resolution["total_books"], + } + return RedirectResponse(f"{root}?{urlencode(params)}", status_code=303) + + if not missing and already_present_count == 0: + err_params = { + "request_error": ( + f"No releases found on MaM for series: {cleaned_series}" + ) + } + return RedirectResponse(f"{root}?{urlencode(err_params)}", status_code=303) + + identity_name = identity.username if identity else "" + identity_permissions = identity.permissions if identity else () + is_auto_approve = bool(identity and "auto_approve" in identity.permissions) + + if identity and identity.weekly_request_limit > 0: + used = await asyncio.to_thread( + repository.request_count_since, + identity.username, + ) + remaining_quota = max(0, identity.weekly_request_limit - used) + if remaining_quota < len(missing): + message = ( + f"Weekly request limit reached. {identity.username} only has " + f"{remaining_quota} request(s) remaining, but series " + f"'{cleaned_series}' has {len(missing)} missing book(s)." + ) + return RedirectResponse( + f"{root}?{urlencode({'request_error': message})}", + status_code=303, + ) + + source: dict[str, object] = {"kind": "manual", "series": cleaned_series} + if goodreads_url: + with suppress(GoodreadsLookupError): + source = { + "kind": "goodreads", + "url": goodreads_url, + "goodreads_id": goodreads_book_id(goodreads_url), + "series": cleaned_series, + } + + submitted_count = 0 + approved_count = 0 + for book in missing: + mam_id = book["mam_id"] + release = book["release"] + book_note = f"Series: {cleaned_series} ({book['volume']}). {note}".strip() + record = await asyncio.to_thread( + repository.create_request, + mam_id=mam_id, + release=release, + requester_name=requester_name[:120], + requester_contact=requester_contact[:200], + requester_username=identity_name, + requester_permissions=identity_permissions, + requester_weekly_limit=( + identity.weekly_request_limit if identity else 0 + ), + note=book_note[:1000], + source=source, + ) + submitted_count += 1 + if is_auto_approve: + sub_res, _ = await approve_stored_request( + record, + decision_by=identity.username, + automatic=True, + ) + if sub_res == "approved": + approved_count += 1 + + repository.log_activity( + "requests", + ( + f"Series request for '{cleaned_series}': {submitted_count} book(s) " + f"submitted ({approved_count} auto-approved, {already_present_count} " + "already in library)" + ), + level="success", + context={ + "series_name": cleaned_series, + "submitted_count": submitted_count, + "approved_count": approved_count, + "already_present_count": already_present_count, + "requester_username": identity_name, + }, + ) + snapshot_cache["expires"] = 0.0 + + status_flag = ( + "series_approved" + if is_auto_approve and approved_count == submitted_count + else "series_pending" + ) + query_params = { + "submitted": status_flag, + "series_name": cleaned_series, + "series_count": submitted_count, + "series_existing": already_present_count, + "series_total": resolution["total_books"], + } + return RedirectResponse( + f"{root}?{urlencode(query_params)}", + status_code=303, + ) + + @app.get("/requests", response_class=HTMLResponse) + async def request_inbox( + request: Request, + status: str = "pending", + ) -> HTMLResponse: + selected_status = ( + status + if status + in { + "pending", + "approved", + "rejected", + "fulfilled", + "all", + } + else "pending" + ) + rows = await asyncio.to_thread( + repository.request_rows, + status=None if selected_status == "all" else selected_status, + ) + return templates.TemplateResponse( + request, + "requests.html", + await context( + request, + title="Request Inbox", + rows=rows, + selected_status=selected_status, + approved=request.query_params.get("approved") == "1", + rejected=request.query_params.get("rejected") == "1", + error=request.query_params.get("error", ""), + ), + ) + + @app.post("/requests/approve") + async def approve_request(request_id: str = Form(...)) -> RedirectResponse: + record = await asyncio.to_thread(repository.request_record, request_id) + if not record: + raise HTTPException(404, "request was not found") + if record["status"] != "pending": + raise HTTPException(409, "request has already been decided") + mam_id = int(record["mam_id"]) + _, approval_error = await approve_stored_request( + record, + decision_by="administrator", + automatic=False, + ) + if approval_error: + return RedirectResponse( + f"/requests?{urlencode({'error': approval_error})}", + status_code=303, + ) + repository.log_activity( + "requests", + f"Approved request: {record['release']['title']}", + level="success", + context={"request_id": request_id, "mam_id": mam_id}, + ) + snapshot_cache["expires"] = 0.0 + return RedirectResponse("/requests?approved=1", status_code=303) + + @app.post("/requests/reject") + async def reject_request( + request_id: str = Form(...), + decision_note: str = Form(""), + ) -> RedirectResponse: + record = await asyncio.to_thread(repository.request_record, request_id) + if not record: + raise HTTPException(404, "request was not found") + if record["status"] != "pending": + raise HTTPException(409, "request has already been decided") + await asyncio.to_thread( + repository.update_request, + request_id, + "rejected", + decision_note=decision_note[:500] or "Rejected by administrator", + ) + repository.log_activity( + "requests", + f"Rejected request: {record['release']['title']}", + level="warning", + context={"request_id": request_id, "mam_id": record["mam_id"]}, + ) + snapshot_cache["expires"] = 0.0 + return RedirectResponse("/requests?rejected=1", status_code=303) + + @app.get("/search", response_class=HTMLResponse) + async def search( + request: Request, + q: str = "", + title: str = "", + author: str = "", + series: str = "", + narrator: str = "", + filetype: str = "", + category: str = "", + language: str = "", + availability: str = "", + min_seeders: int = 0, + sort: str = "relevance", + ) -> HTMLResponse: + filters = manual_search_filters( + q=q, + title=title, + author=author, + series=series, + narrator=narrator, + filetype=filetype, + category=category, + language=language, + availability=availability, + min_seeders=min_seeders, + sort=sort, + ) + searched = search_was_requested(filters) + result = await run_manual_search(filters) + return templates.TemplateResponse( + request, + "heavymlm/search.html", + await context( + request, + title="Search MaM", + query=q, + filters=filters, + searched=searched, + rows=result["rows"], + found=result["found"], + scanned=result["scanned"], + error=result["error"], + series_queued=( + as_int(request.query_params["series_queued"]) + if "series_queued" in request.query_params + else None + ), + series_existing=as_int(request.query_params.get("series_existing", 0)), + series_total=as_int(request.query_params.get("series_total", 0)), + series_name=request.query_params.get("series_name", ""), + ), + ) + + @app.post("/search/select") + async def select_search_result(mam_id: int = Form(...)) -> RedirectResponse: + row = await app.state.services.mam.get_torrent_info_by_id(mam_id) + if not row: + raise HTTPException(404, f"MaM torrent {mam_id} was not found") + selected = await select_row( + app.state.services.config, + repository, + row, + {"cost": "ratio", "name": "manual"}, + ) + if not selected and not repository.has_mam_id(mam_id): + raise HTTPException( + 409, "torrent did not match a configured preferred format" + ) + return RedirectResponse("/records/selected_torrents", status_code=303) + + @app.post("/search/series/select") + async def select_series( + series_name: str = Form(...), + media_type: str = Form(""), + filetype: str = Form(""), + ) -> RedirectResponse: + cleaned_series = series_name.strip() + if not cleaned_series: + raise HTTPException(400, "Series name cannot be empty") + + resolution = await resolve_series_selection( + app.state.services.config, + repository, + app.state.services.mam, + cleaned_series, + preferred_media=media_type.strip() or None, + preferred_filetype=filetype.strip() or None, + ) + + queued_count = 0 + for book in resolution["missing_books"]: + selected = await select_row( + app.state.services.config, + repository, + book["raw_row"], + {"cost": "ratio", "name": f"series:{cleaned_series}"}, + ) + if selected: + queued_count += 1 + + if queued_count > 0: + asyncio.create_task(app.state.services.trigger("downloader")) + snapshot_cache["expires"] = 0.0 + repository.log_activity( + "series", + ( + f"Queued entire series: {cleaned_series} ({queued_count} " + f"book(s) queued, {len(resolution['already_present'])} " + "already present)" + ), + level="success", + context={ + "series_name": cleaned_series, + "queued_count": queued_count, + "already_present": len(resolution["already_present"]), + "total_books": resolution["total_books"], + }, + ) + + params = { + "series": cleaned_series, + "series_name": cleaned_series, + "series_queued": queued_count, + "series_existing": len(resolution["already_present"]), + "series_total": resolution["total_books"], + } + return RedirectResponse(f"/search?{urlencode(params)}", status_code=303) + + @app.post("/actions/{name}") + async def action(name: str) -> RedirectResponse: + if name not in {"autograb", "lists", "downloader", "organizer", "cleaner"}: + raise HTTPException(404, f"unknown job: {name}") + asyncio.create_task(app.state.services.trigger(name)) + return RedirectResponse(f"/?triggered={name}", status_code=303) + + @app.post("/selected/remove") + async def remove_selected(mam_id: int = Form(...)) -> RedirectResponse: + repository.delete_selected(mam_id) + return RedirectResponse("/records/selected_torrents", status_code=303) + + @app.get("/health") + async def health() -> dict: + snapshot = await ui_snapshot() + return {"status": "ok", "counts": snapshot["counts"]} + + @app.get("/api/jobs") + async def jobs() -> dict: + services = getattr(app.state, "services", None) + return { + "jobs": ( + {name: asdict(status) for name, status in services.jobs.items()} + if services + else {} + ) + } + + return app diff --git a/python/tests/test_abs_sync.py b/python/tests/test_abs_sync.py new file mode 100644 index 00000000..d822e4ff --- /dev/null +++ b/python/tests/test_abs_sync.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from mlm.database import ensure_database +from mlm.modules.heavymlm.abs_sync import ( + fetch_all_abs_library_books, + sync_audiobookshelf_library, +) +from mlm.repository import Repository + + +def test_fetch_all_abs_library_books_validation(): + # Missing libraryId + with pytest.raises(ValueError, match="Audiobookshelf library ID is not configured"): + fetch_all_abs_library_books({"connection": {}}) + + # Missing baseUrl or token + with pytest.raises(ValueError, match="Audiobookshelf URL or API token is missing"): + fetch_all_abs_library_books({"connection": {"libraryId": "lib-1"}}) + + +def test_sync_audiobookshelf_library(tmp_path: Path, monkeypatch): + db_file = tmp_path / "test.db" + ensure_database(db_file) + repo = Repository(db_file) + + fake_items = [ + { + "id": "abs-1", + "media": { + "metadata": { + "title": "Unsouled", + "authors": [{"name": "Will Wight"}], + "series": [{"name": "Cradle", "sequence": "1"}], + "asin": "B01LW8PT7M", + "isbn": "", + } + }, + "path": "/audiobooks/Will Wight/Cradle/01 - Unsouled", + }, + { + "id": "abs-2", + "media": { + "metadata": { + "title": "Soulsmith", + "authorName": "Will Wight", + "seriesName": "Cradle", + "seriesSequence": "2", + "asin": "B01LYP0528", + "isbn": "", + } + }, + "relPath": "Will Wight/Cradle/02 - Soulsmith", + }, + ] + + class FakeClient: + def __init__(self, **kwargs): + pass + + def get( + self, path: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + if params and params.get("page", 0) == 0: + return {"results": fake_items, "total": 2} + return {"results": [], "total": 2} + + monkeypatch.setattr( + "mlm.modules.heavymlm.abs_sync.create_client", + lambda *args, **kwargs: FakeClient(), + ) + + settings = { + "connection": { + "baseUrl": "http://abs.local:13378", + "token": "secret-token", + "libraryId": "library-main", + } + } + + result = sync_audiobookshelf_library(repo, settings) + assert result["ok"] is True + assert result["total_synced"] == 2 + + assert repo.abs_books_count() == 2 + books = repo.abs_book_rows() + assert len(books) == 2 + assert books[0]["title"] in {"Unsouled", "Soulsmith"} + assert books[1]["title"] in {"Unsouled", "Soulsmith"} diff --git a/python/tests/test_absidekick_core.py b/python/tests/test_absidekick_core.py new file mode 100644 index 00000000..d1812f0f --- /dev/null +++ b/python/tests/test_absidekick_core.py @@ -0,0 +1,2210 @@ +import json +import unittest +import urllib.parse +from copy import deepcopy +from unittest.mock import patch + +from mlm.modules.absidekick.core import ( + DEFAULT_SETTINGS, + ABSAPIError, + ABSClient, + MatchJob, + add_remove_tags, + apply_match, + build_review_row, + candidate_metadata_payload, + clean_search_title, + enrich_candidate_series, + extract_embedded_file_metadata, + google_books_key_fingerprint, + match_decision, + normalize_title, + prepare_item_for_matching, + public_settings, + rank_candidates, + scan_review_items, + score_candidate, + search_candidates, + search_open_library, + search_review_candidates, + series_payload, + should_process_item, + summarize_item, + title_search_variants, +) +from mlm.modules.absidekick.core import ( + test_google_books_api_key as validate_google_books_api_key, +) + + +def sample_item(tags=None): + return { + "id": "li_test", + "mediaType": "book", + "isMissing": False, + "isInvalid": False, + "path": "/audiobooks/Terry Goodkind/Sword of Truth/Wizards First Rule", + "media": { + "duration": 12000, + "coverPath": None, + "tags": tags or [], + "metadata": { + "title": "Wizard's First Rule", + "authorName": "Terry Goodkind", + "seriesName": "Sword of Truth", + "narratorName": "Sam Tsoutsouvas", + "publishedYear": "1994", + "asin": None, + "isbn": None, + }, + }, + } + + +class GoogleBooksProviderTests(unittest.TestCase): + def test_public_settings_hide_google_key_and_require_matching_validation(self): + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "private-google-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "private-google-key" + ), + } + ) + + public = public_settings(settings, has_token=False) + + self.assertNotIn("googleBooksApiKey", public["providers"]) + self.assertNotIn("googleBooksApiKeyFingerprint", public["providers"]) + self.assertTrue(public["providers"]["hasGoogleBooksApiKey"]) + self.assertTrue(public["providers"]["googleBooksReady"]) + + settings["providers"]["googleBooksApiKey"] = "replaced-without-test" + self.assertFalse( + public_settings(settings, has_token=False)["providers"]["googleBooksReady"] + ) + + @patch("mlm.modules.absidekick.core.urllib.request.urlopen") + def test_google_search_never_contacts_google_without_validated_key(self, urlopen): + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="saved-but-untested", + google_books_ready=False, + ) + + with self.assertRaisesRegex(ABSAPIError, "No Google request was sent"): + client.search_books( + {"provider": "google", "title": "The Hobbit", "limit": 5}, + timeout_seconds=12, + ) + + urlopen.assert_not_called() + + @patch("mlm.modules.absidekick.core.urllib.request.urlopen") + def test_validated_google_search_calls_native_api_and_maps_book(self, urlopen): + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return json.dumps( + { + "items": [ + { + "id": "google-volume-1", + "volumeInfo": { + "title": "The Hobbit", + "subtitle": "There and Back Again", + "authors": ["J. R. R. Tolkien"], + "publishedDate": "1937-09-21", + "publisher": "George Allen & Unwin", + "industryIdentifiers": [ + { + "type": "ISBN_13", + "identifier": "9780547928227", + } + ], + "imageLinks": { + "thumbnail": "http://books.google.com/cover.jpg" + }, + }, + } + ] + } + ).encode("utf-8") + + urlopen.return_value = Response() + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="validated-key", + google_books_ready=True, + ) + + results = client.search_books( + { + "provider": "google", + "title": "The Hobbit", + "author": "Tolkien", + "limit": 5, + }, + timeout_seconds=12, + ) + + request = urlopen.call_args.args[0] + query = urllib.parse.parse_qs(urllib.parse.urlparse(request.full_url).query) + self.assertEqual(query["key"], ["validated-key"]) + self.assertEqual(query["maxResults"], ["5"]) + self.assertIn("intitle:The Hobbit", query["q"][0]) + self.assertEqual(results[0]["title"], "The Hobbit") + self.assertEqual(results[0]["author"], "J. R. R. Tolkien") + self.assertEqual(results[0]["publishedYear"], "1937") + self.assertEqual(results[0]["isbn"], "9780547928227") + self.assertEqual(results[0]["cover"], "https://books.google.com/cover.jpg") + + @patch("mlm.modules.absidekick.core.search_google_books") + def test_transient_google_error_retries_once_without_disabling(self, search): + search.side_effect = [ + ABSAPIError("Google Books returned HTTP 503.", status=503), + [{"title": "The Hobbit", "author": "J. R. R. Tolkien"}], + ] + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="validated-key", + google_books_ready=True, + ) + + with patch("mlm.modules.absidekick.core.time.sleep") as sleep: + results = client.search_books( + {"provider": "google", "title": "The Hobbit", "limit": 5}, + timeout_seconds=12, + ) + + self.assertEqual(results[0]["title"], "The Hobbit") + self.assertEqual(search.call_count, 2) + sleep.assert_called_once_with(0.5) + self.assertNotIn("google", client.disabled_search_providers) + self.assertNotIn("google", client.transient_search_failures) + + @patch("mlm.modules.absidekick.core.search_google_books") + def test_single_exhausted_transient_google_search_retries_next_item(self, search): + search.side_effect = ABSAPIError("Google Books returned HTTP 503.", status=503) + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="validated-key", + google_books_ready=True, + ) + params = {"provider": "google", "title": "The Hobbit", "limit": 5} + + with ( + patch("mlm.modules.absidekick.core.time.sleep"), + self.assertRaises(ABSAPIError), + ): + client.search_books(params, timeout_seconds=12) + + self.assertNotIn("google", client.disabled_search_providers) + self.assertEqual(client.transient_search_failures["google"], 1) + + search.side_effect = None + search.return_value = [{"title": "The Hobbit"}] + results = client.search_books(params, timeout_seconds=12) + + self.assertEqual(results[0]["title"], "The Hobbit") + self.assertNotIn("google", client.transient_search_failures) + + @patch("mlm.modules.absidekick.core.search_google_books") + def test_three_exhausted_transient_google_searches_open_circuit(self, search): + search.side_effect = ABSAPIError("Google Books returned HTTP 503.", status=503) + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="validated-key", + google_books_ready=True, + ) + params = {"provider": "google", "title": "The Hobbit", "limit": 5} + + with patch("mlm.modules.absidekick.core.time.sleep"): + for _ in range(3): + with self.assertRaises(ABSAPIError): + client.search_books(params, timeout_seconds=12) + + self.assertIn("google", client.disabled_search_providers) + self.assertIn( + "3 consecutive transient failures", + client.disabled_search_providers["google"], + ) + self.assertEqual(search.call_count, 6) + + @patch("mlm.modules.absidekick.core.search_google_books") + def test_google_auth_error_disables_immediately_without_retry(self, search): + search.side_effect = ABSAPIError("Google rejected the API key.", status=403) + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="validated-key", + google_books_ready=True, + ) + + with self.assertRaises(ABSAPIError): + client.search_books( + {"provider": "google", "title": "The Hobbit", "limit": 5}, + timeout_seconds=12, + ) + + self.assertEqual(search.call_count, 1) + self.assertIn("google", client.disabled_search_providers) + + @patch("mlm.modules.absidekick.core._google_books_payload") + def test_live_key_test_retries_one_transient_google_failure(self, payload): + payload.side_effect = [ + ABSAPIError("Google Books returned HTTP 503.", status=503), + {"items": [{"id": "sample"}]}, + ] + + with patch("mlm.modules.absidekick.core.time.sleep") as sleep: + result = validate_google_books_api_key("validated-key", timeout_seconds=12) + + self.assertTrue(result["valid"]) + self.assertEqual(payload.call_count, 2) + sleep.assert_called_once_with(0.5) + + +class OpenLibraryProviderTests(unittest.TestCase): + @patch("mlm.modules.absidekick.core.urllib.request.urlopen") + def test_native_search_uses_identified_json_api_and_maps_work(self, urlopen): + class Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self): + return json.dumps( + { + "docs": [ + { + "key": "/works/OL27448W", + "title": "The Lord of the Rings", + "author_name": ["J. R. R. Tolkien"], + "first_publish_year": 1954, + "cover_i": 258027, + "isbn": ["0618640150", "9780618640157"], + "publisher": ["George Allen & Unwin"], + "language": ["eng"], + "edition_count": 200, + } + ] + } + ).encode("utf-8") + + urlopen.return_value = Response() + + results = search_open_library( + "The Lord of the Rings", + "Tolkien", + limit=5, + timeout_seconds=12, + contact_email="owner@example.com", + ) + + request = urlopen.call_args.args[0] + parsed = urllib.parse.urlparse(request.full_url) + query = urllib.parse.parse_qs(parsed.query) + self.assertEqual(parsed.path, "/search.json") + self.assertEqual(query["title"], ["The Lord of the Rings"]) + self.assertEqual(query["author"], ["Tolkien"]) + self.assertEqual(query["limit"], ["5"]) + self.assertIn("cover_i", query["fields"][0]) + self.assertIn("MyAnonaSuite", request.get_header("User-agent")) + self.assertIn("owner@example.com", request.get_header("User-agent")) + self.assertEqual(request.get_header("From"), "owner@example.com") + self.assertEqual(results[0]["id"], "/works/OL27448W") + self.assertEqual(results[0]["author"], "J. R. R. Tolkien") + self.assertEqual(results[0]["publishedYear"], "1954") + self.assertEqual(results[0]["isbn"], "9780618640157") + self.assertEqual( + results[0]["cover"], + "https://covers.openlibrary.org/b/id/258027-L.jpg", + ) + + @patch("mlm.modules.absidekick.core.search_open_library") + def test_repeated_search_uses_per_run_cache(self, search): + search.return_value = [{"title": "The Hobbit", "author": "J. R. R. Tolkien"}] + client = ABSClient( + "http://localhost:13378", + "abs-token", + open_library_contact_email="owner@example.com", + ) + params = { + "provider": "openlibrary", + "title": "The Hobbit", + "author": "Tolkien", + "limit": 5, + } + + first = client.search_books(params, timeout_seconds=12) + second = client.search_books(params, timeout_seconds=12) + + self.assertEqual(first, second) + self.assertEqual(search.call_count, 1) + + @patch("mlm.modules.absidekick.core.search_open_library") + def test_transient_error_retries_once_without_disabling(self, search): + search.side_effect = [ + ABSAPIError("Open Library returned HTTP 503.", status=503), + [{"title": "The Hobbit", "author": "J. R. R. Tolkien"}], + ] + client = ABSClient("http://localhost:13378", "abs-token") + + with patch("mlm.modules.absidekick.core.time.sleep") as sleep: + results = client.search_books( + {"provider": "openlibrary", "title": "The Hobbit", "limit": 5}, + timeout_seconds=12, + ) + + self.assertEqual(results[0]["title"], "The Hobbit") + self.assertEqual(search.call_count, 2) + self.assertNotIn("openlibrary", client.disabled_search_providers) + self.assertTrue(any(call.args == (0.5,) for call in sleep.call_args_list)) + + +class ScoringTests(unittest.TestCase): + def test_embedded_file_metadata_extracts_consensus_audio_tags(self): + item = sample_item() + item["media"]["audioFiles"] = [ + { + "metadata": {"filename": "Part 01.mp3"}, + "metaTags": { + "tagAlbum": "The Masterharper of Pern", + "tagTitle": "Chapter 1", + "tagArtist": "Anne McCaffrey", + "tagComposer": "Dick Hill", + "tagSeries": "Pern", + "tagSeriesPart": "17", + "tagYear": "1998", + "tagAsin": "B000TEST", + }, + }, + { + "metadata": {"filename": "Part 02.mp3"}, + "metaTags": { + "tagAlbum": "The Masterharper of Pern", + "tagTitle": "Chapter 2", + "tagArtist": "Anne McCaffrey", + "tagComposer": "Dick Hill", + "tagSeries": "Pern", + "tagSeriesPart": "17", + "tagYear": "1998", + "tagAsin": "B000TEST", + }, + }, + ] + + embedded = extract_embedded_file_metadata(item) + + self.assertEqual(embedded["status"], "found") + self.assertEqual(embedded["title"], "The Masterharper of Pern") + self.assertEqual(embedded["author"], "Anne McCaffrey") + self.assertEqual(embedded["narrator"], "Dick Hill") + self.assertEqual(embedded["series"], "Pern") + self.assertEqual(embedded["seriesSequence"], "17") + self.assertEqual(embedded["publishedYear"], "1998") + self.assertEqual(embedded["asin"], "B000TEST") + self.assertEqual(embedded["taggedFileCount"], 2) + self.assertEqual(embedded["sourceFiles"], ["Part 01.mp3", "Part 02.mp3"]) + + def test_multitrack_chapter_titles_are_not_mistaken_for_book_title(self): + item = sample_item() + item["media"]["audioFiles"] = [ + {"metaTags": {"tagTitle": "Chapter 1"}}, + {"metaTags": {"tagTitle": "Chapter 2"}}, + {"metaTags": {"tagTitle": "Chapter 3"}}, + ] + + embedded = extract_embedded_file_metadata(item) + + self.assertNotIn("title", embedded) + self.assertEqual(embedded["status"], "empty") + + def test_chapter_only_filenames_are_not_used_as_book_titles(self): + item = sample_item() + item["media"]["audioFiles"] = [ + {"metadata": {"filename": "01 Chapter 1.mp3"}}, + {"metadata": {"filename": "02 Chapter 2.mp3"}}, + {"metadata": {"filename": "03 Chapter 3.mp3"}}, + ] + + embedded = extract_embedded_file_metadata(item) + + self.assertEqual(embedded["fileTitleCandidates"], []) + self.assertEqual(embedded["status"], "empty") + + def test_repeated_track_filenames_extract_a_real_title_candidate(self): + item = sample_item() + item["media"]["metadata"].update( + {"title": "Star Wars Book 58", "authorName": "Michael A. Stackpole"} + ) + item["media"]["audioFiles"] = [ + { + "metadata": { + "filename": ("Star Wars - X-Wing 02 - The Krytos Trap - 01.mp3") + } + }, + { + "metadata": { + "filename": ("Star Wars - X-Wing 02 - The Krytos Trap - 02.mp3") + } + }, + { + "metadata": { + "filename": ("Star Wars - X-Wing 02 - The Krytos Trap - 03.mp3") + } + }, + ] + + embedded = extract_embedded_file_metadata(item) + + self.assertEqual( + embedded["fileTitleCandidates"], + [ + { + "title": "The Krytos Trap", + "source": "repeated audio filename consensus", + "supportingFileCount": 3, + } + ], + ) + self.assertIn("fileTitleCandidates", embedded["fields"]) + + def test_generic_abs_title_searches_repeated_filename_title_first(self): + class FilenameEvidenceClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params.get("title") == "The Krytos Trap": + return [ + { + "title": "The Krytos Trap", + "author": "Michael A. Stackpole", + "asin": "B000KRYTOS", + } + ] + return [] + + item = sample_item() + item["path"] = "/audiobooks/Star Wars Book 58" + item["media"]["metadata"].update( + {"title": "Star Wars Book 58", "authorName": "Michael A. Stackpole"} + ) + item["media"]["audioFiles"] = [ + { + "metadata": { + "filename": ("Star Wars - X-Wing 02 - The Krytos Trap - 01.mp3") + } + }, + { + "metadata": { + "filename": ("Star Wars - X-Wing 02 - The Krytos Trap - 02.mp3") + } + }, + ] + settings = deepcopy(DEFAULT_SETTINGS) + settings["matching"]["useEmbeddedFileMetadata"] = True + settings["providers"]["openLibraryEnabled"] = False + client = FilenameEvidenceClient() + prepared = prepare_item_for_matching(client, item, settings) + + candidates = search_candidates(client, prepared, settings) + decision = match_decision( + rank_candidates(prepared, candidates, settings), settings + ) + + self.assertEqual(client.queries[0]["title"], "The Krytos Trap") + self.assertIn( + "repeated audio filename consensus", + candidates[0]["_absidekickSearch"]["strategy"], + ) + self.assertEqual(decision["action"], "auto") + + def test_generic_abs_title_searches_folder_title_first(self): + class FolderEvidenceClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params.get("title") == "The Krytos Trap": + return [ + { + "title": "The Krytos Trap", + "author": "Michael A. Stackpole", + } + ] + return [] + + item = sample_item() + item["path"] = "/audiobooks/Michael A. Stackpole/The Krytos Trap" + item["media"]["metadata"].update( + {"title": "Star Wars Book 58", "authorName": "Michael A. Stackpole"} + ) + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"]["openLibraryEnabled"] = False + client = FolderEvidenceClient() + + candidates = search_candidates(client, item, settings) + + self.assertEqual(client.queries[0]["title"], "The Krytos Trap") + self.assertIn( + "Audiobookshelf folder name", + candidates[0]["_absidekickSearch"]["strategy"], + ) + + def test_embedded_file_metadata_is_opt_in_and_hydrates_full_abs_item(self): + class ItemClient: + def __init__(self): + self.calls = [] + + def get(self, path, params=None): + self.calls.append(path) + full = sample_item() + full["media"]["audioFiles"] = [ + { + "metaTags": { + "tagAlbum": "The Masterharper of Pern", + "tagArtist": "Anne McCaffrey", + } + } + ] + return full + + client = ItemClient() + settings = deepcopy(DEFAULT_SETTINGS) + original = sample_item() + + self.assertIs(prepare_item_for_matching(client, original, settings), original) + self.assertEqual(client.calls, []) + + settings["matching"]["useEmbeddedFileMetadata"] = True + prepared = prepare_item_for_matching(client, original, settings) + + self.assertEqual(client.calls, ["/api/items/li_test"]) + self.assertEqual( + prepared["_absidekickEmbeddedMetadata"]["title"], + "The Masterharper of Pern", + ) + + def test_embedded_file_metadata_drives_search_and_scoring_when_enabled(self): + class SearchClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params.get("title") == "The Masterharper of Pern": + return [ + { + "title": "The Masterharper of Pern", + "author": "Anne McCaffrey", + "series": [{"name": "Pern", "sequence": "17"}], + } + ] + return [] + + item = sample_item() + item["media"]["metadata"].update( + {"title": "Pern 17 - The Masterharper of Pern", "authorName": ""} + ) + item["media"]["audioFiles"] = [ + { + "metaTags": { + "tagAlbum": "The Masterharper of Pern", + "tagArtist": "Anne McCaffrey", + "tagSeries": "Pern", + "tagSeriesPart": "17", + } + } + ] + settings = deepcopy(DEFAULT_SETTINGS) + settings["matching"]["useEmbeddedFileMetadata"] = True + settings["providers"]["openLibraryEnabled"] = False + client = SearchClient() + prepared = prepare_item_for_matching(client, item, settings) + + candidates = search_candidates(client, prepared, settings) + ranked = rank_candidates(prepared, candidates, settings) + decision = match_decision(ranked, settings) + + self.assertEqual(client.queries[0]["title"], "The Masterharper of Pern") + self.assertEqual(client.queries[0]["author"], "Anne McCaffrey") + self.assertEqual(candidates.attempts[0]["provider"], "embedded") + self.assertEqual(candidates.attempts[0]["status"], "evidence") + self.assertEqual(ranked[0]["parts"]["author"], 100) + self.assertEqual(decision["action"], "auto") + + def test_embedded_series_sequence_can_resolve_stale_abs_sequence(self): + item = sample_item() + item["media"]["metadata"]["series"] = [{"name": "Pern", "sequence": "16"}] + item["_absidekickEmbeddedMetadata"] = { + "status": "found", + "series": "Pern", + "seriesSequence": "17", + } + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Pern", "sequence": "17"}], + } + ], + DEFAULT_SETTINGS, + ) + + self.assertNotIn("series sequence differs", ranked[0]["conflicts"]) + + def test_title_normalization_handles_edition_noise_unicode_and_roman_numbers(self): + self.assertEqual( + normalize_title("The Café—Book IV (Unabridged Audiobook Edition)"), + "cafe 4", + ) + + def test_best_candidate_wins(self): + candidates = [ + { + "title": "Stone of Tears", + "author": "Terry Goodkind", + "publishedYear": "1995", + }, + { + "title": "Wizards First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Sword of Truth", "sequence": "1"}], + "narrator": "Sam Tsoutsouvas", + "publishedYear": "1994", + "duration": 200, + }, + ] + ranked = rank_candidates(sample_item(), candidates, DEFAULT_SETTINGS) + self.assertEqual(ranked[0]["candidate"]["title"], "Wizards First Rule") + self.assertGreaterEqual(ranked[0]["score"], 90) + + def test_unprocessed_skips_processed_tags(self): + ok, reason = should_process_item( + sample_item(["ABSidekick: AutoMatched"]), DEFAULT_SETTINGS + ) + self.assertFalse(ok) + self.assertEqual(reason, "already processed") + + def test_review_mode_requires_review_tag(self): + settings = { + **DEFAULT_SETTINGS, + "targeting": {**DEFAULT_SETTINGS["targeting"], "mode": "review"}, + } + ok, reason = should_process_item( + sample_item(["ABSidekick: Needs Review"]), settings + ) + self.assertTrue(ok) + ok, reason = should_process_item( + sample_item(["ABSidekick: AutoMatch Unmatched"]), settings + ) + self.assertFalse(ok) + self.assertEqual(reason, "not tagged review") + + def test_review_row_keeps_candidate_options(self): + ranked = rank_candidates( + sample_item(), + [ + {"title": "Wrong Book", "author": "Someone Else"}, + {"title": "Wizards First Rule", "author": "Terry Goodkind"}, + ], + DEFAULT_SETTINGS, + ) + row = build_review_row(sample_item(), ranked, DEFAULT_SETTINGS) + self.assertEqual(row["item"]["title"], "Wizard's First Rule") + self.assertEqual(len(row["candidates"]), 2) + self.assertEqual(row["item"]["series"], "Sword of Truth") + self.assertEqual(row["item"]["narrator"], "Sam Tsoutsouvas") + + def test_summarize_item_exposes_compare_card_fields(self): + item = summarize_item(sample_item(["ABSidekick: Needs Review"])) + self.assertEqual(item["title"], "Wizard's First Rule") + self.assertEqual(item["author"], "Terry Goodkind") + self.assertEqual(item["year"], "1994") + self.assertEqual(item["tags"], ["ABSidekick: Needs Review"]) + self.assertEqual(item["coverUrl"], "/api/absidekick/item-cover/li_test") + + def test_add_remove_tags_preserves_other_tags(self): + tags = add_remove_tags( + ["Favorite", "ABSidekick: AutoMatch Unmatched"], + add=["ABSidekick: AutoMatched"], + remove=["ABSidekick: AutoMatch Unmatched"], + ) + self.assertEqual(tags, ["Favorite", "ABSidekick: AutoMatched"]) + + def test_metadata_payload_does_not_overwrite_by_default(self): + payload = candidate_metadata_payload( + {"title": "Existing Title", "authorName": "Existing Author", "asin": None}, + {"title": "New Title", "author": "New Author", "asin": "B000123"}, + overwrite=False, + ) + self.assertNotIn("title", payload) + self.assertNotIn("authors", payload) + self.assertEqual(payload["asin"], "B000123") + + def test_audible_series_shape_is_written_as_abs_series_sequence(self): + payload = series_payload( + { + "series": [ + {"series": "Sword of Truth", "sequence": "01"}, + {"series": "Chronicles", "sequence": "2.50"}, + ] + } + ) + + self.assertEqual( + payload, + [ + {"name": "Sword of Truth", "sequence": "1"}, + {"name": "Chronicles", "sequence": "2.5"}, + ], + ) + + def test_series_repair_updates_missing_number_without_metadata_overwrite(self): + payload = candidate_metadata_payload( + {"seriesName": "Sword of Truth"}, + { + "series": [ + {"series": "Sword of Truth", "sequence": "1"}, + {"series": "The First Law World", "sequence": "2"}, + ] + }, + overwrite=False, + ) + + self.assertEqual( + payload["series"], + [ + {"name": "Sword of Truth", "sequence": "1"}, + {"name": "The First Law World", "sequence": "2"}, + ], + ) + + def test_series_repair_preserves_a_known_number_when_provider_omits_it(self): + payload = candidate_metadata_payload( + {"series": [{"name": "Sword of Truth", "sequence": "1"}]}, + {"series": [{"series": "Sword of Truth", "sequence": None}]}, + overwrite=False, + ) + + self.assertNotIn("series", payload) + + def test_series_repair_can_be_disabled(self): + payload = candidate_metadata_payload( + {"seriesName": "Sword of Truth"}, + {"series": [{"series": "Sword of Truth", "sequence": "1"}]}, + overwrite=False, + repair_series=False, + ) + + self.assertNotIn("series", payload) + + def test_explicit_provider_subtitle_can_supply_series_and_number(self): + candidate = { + "title": "Wizard's First Rule", + "subtitle": "Sword of Truth, Book I", + } + + enrich_candidate_series(candidate, sample_item(), DEFAULT_SETTINGS) + + self.assertEqual( + candidate["series"], + [{"name": "Sword of Truth", "sequence": "1"}], + ) + self.assertEqual(candidate["_absidekickSeriesSource"], "provider subtitle") + + def test_metadata_payload_deduplicates_case_variant_authors(self): + payload = candidate_metadata_payload( + {}, + { + "authors": [ + {"name": "Stephen King"}, + {"name": "stephen king"}, + {"name": " Stephen King "}, + ] + }, + overwrite=True, + ) + + self.assertEqual(payload["authors"], [{"name": "Stephen King"}]) + + def test_metadata_payload_skips_unchanged_author_relationships(self): + payload = candidate_metadata_payload( + { + "authors": [{"name": "Stephen King"}], + "authorName": "Stephen King", + }, + {"authors": ["stephen king", "Stephen King"]}, + overwrite=True, + ) + + self.assertNotIn("authors", payload) + + def test_sparse_provider_metadata_does_not_penalize_obvious_match(self): + ranked = rank_candidates( + sample_item(), + [{"title": "Wizard's First Rule", "author": "Terry Goodkind"}], + DEFAULT_SETTINGS, + ) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(ranked[0]["parts"]["year"], None) + self.assertEqual(ranked[0]["parts"]["duration"], None) + self.assertGreaterEqual(ranked[0]["score"], 99) + self.assertEqual(decision["action"], "auto") + + def test_title_subtitle_and_unabridged_noise_still_match(self): + candidate = { + "title": "Wizard's First Rule: Sword of Truth, Book I", + "author": "Terry Goodkind", + "narrator": "Sam Tsoutsouvas", + } + scored = score_candidate(sample_item(), candidate, DEFAULT_SETTINGS) + + self.assertGreaterEqual(scored["parts"]["title"], 92) + self.assertEqual(match_decision([scored], DEFAULT_SETTINGS)["action"], "auto") + + def test_matching_folder_title_can_correct_a_short_abs_title(self): + item = sample_item() + item["path"] = "/audiobooks/Arthur C. Clarke/2061 Odyssey Three" + item["media"]["metadata"].update( + { + "title": "2061", + "authorName": "Arthur C. Clarke", + "seriesName": "Space Odyssey", + } + ) + ranked = rank_candidates( + item, + [ + { + "title": "2061: Odyssey Three", + "author": "Arthur C. Clarke", + "series": [{"name": "Space Odyssey", "sequence": "3"}], + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertGreaterEqual(ranked[0]["score"], 98) + self.assertEqual(ranked[0]["conflicts"], []) + self.assertIn("current ABS title differs", ranked[0]["advisories"][0]) + self.assertEqual(decision["action"], "auto") + self.assertTrue(decision["safetyPassed"]) + self.assertEqual(decision["advisories"], ranked[0]["advisories"]) + + def test_passing_score_explains_which_safety_gate_requires_review(self): + ranked = rank_candidates( + sample_item(), + [{"title": "Wizard's First Rule"}], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["score"], 100) + self.assertTrue(decision["scorePassed"]) + self.assertFalse(decision["safetyPassed"]) + self.assertEqual(decision["action"], "review") + self.assertEqual(decision["strongSignalCount"], 1) + self.assertEqual(decision["policy"]["autoThreshold"], 80) + self.assertTrue( + any("only 1 strong signal" in reason for reason in decision["reasons"]) + ) + + def test_below_threshold_always_names_the_score_gate(self): + ranked = [ + { + "score": 73.23, + "parts": {"title": 100, "author": 100}, + "conflicts": [], + "strongSignals": ["title", "author"], + "exactIdentifiers": [], + "source": {"author": "Terry Goodkind"}, + "candidate": { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + }, + "search": {}, + } + ] + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertFalse(decision["scorePassed"]) + self.assertIn( + "similarity score 73.23 is below the auto-match threshold 80", + decision["reasons"], + ) + + def test_contained_but_different_title_is_not_automatched(self): + item = sample_item() + item["media"]["metadata"]["title"] = "Dune Messiah" + item["media"]["metadata"]["authorName"] = "Frank Herbert" + ranked = rank_candidates( + item, + [{"title": "Dune", "author": "Frank Herbert"}], + DEFAULT_SETTINGS, + ) + + self.assertLess(ranked[0]["parts"]["title"], 86) + self.assertEqual(match_decision(ranked, DEFAULT_SETTINGS)["action"], "review") + + def test_author_contradiction_forces_review_even_with_exact_title(self): + ranked = rank_candidates( + sample_item(), + [{"title": "Wizard's First Rule", "author": "A Different Author"}], + DEFAULT_SETTINGS, + ) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertTrue( + any("author evidence" in reason for reason in decision["reasons"]) + ) + + def test_close_runner_up_blocks_automatic_write(self): + candidates = [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "publishedYear": "1994", + }, + { + "title": "Wizards First Rule: Special Edition", + "author": "Terry Goodkind", + "publishedYear": "1994", + }, + ] + ranked = rank_candidates(sample_item(), candidates, DEFAULT_SETTINGS) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertTrue( + any("winner margin" in reason for reason in decision["reasons"]) + ) + + def test_duplicate_listings_of_same_work_do_not_force_review(self): + candidates = [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "asin": "ABS-EDITION", + }, + { + "title": "Wizards First Rule", + "author": "Terry Goodkind", + "id": "google-edition", + }, + ] + + ranked = rank_candidates(sample_item(), candidates, DEFAULT_SETTINGS) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "auto") + self.assertEqual(decision["equivalentCandidateCount"], 1) + self.assertEqual(decision["competingCandidateCount"], 0) + self.assertEqual(decision["margin"], 100) + + def test_richer_duplicate_wins_when_other_provider_omits_author(self): + candidates = [ + {"title": "Wizard's First Rule", "id": "sparse-result"}, + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "id": "verified-result", + }, + ] + + ranked = rank_candidates(sample_item(), candidates, DEFAULT_SETTINGS) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(ranked[0]["candidate"]["id"], "verified-result") + self.assertEqual(decision["action"], "auto") + self.assertEqual(decision["equivalentCandidateCount"], 1) + + def test_same_title_and_author_with_conflicting_series_numbers_stays_review(self): + candidates = [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Sword of Truth", "sequence": "1"}], + }, + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Sword of Truth", "sequence": "2"}], + }, + ] + + ranked = rank_candidates(sample_item(), candidates, DEFAULT_SETTINGS) + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertEqual(decision["equivalentCandidateCount"], 0) + self.assertEqual(decision["competingCandidateCount"], 1) + self.assertIn("meaningfully different", " ".join(decision["reasons"])) + + def test_exact_identifier_can_confirm_sparse_candidate(self): + item = sample_item() + item["media"]["metadata"]["asin"] = "B00-ABC-123" + ranked = rank_candidates( + item, + [{"title": "Wizards First Rule", "asin": "B00ABC123"}], + DEFAULT_SETTINGS, + ) + + self.assertEqual(ranked[0]["exactIdentifiers"], ["ASIN"]) + self.assertEqual(match_decision(ranked, DEFAULT_SETTINGS)["action"], "auto") + + def test_exact_identifier_overrides_only_edition_level_conflicts(self): + item = sample_item() + item["media"]["metadata"].update( + {"asin": "B00-ABC-123", "isbn": "978-old-edition"} + ) + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "asin": "B00ABC123", + "isbn": "978-new-edition", + "duration": 100, + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(ranked[0]["score"], 99) + self.assertEqual( + ranked[0]["conflicts"], ["ISBN differs", "duration differs substantially"] + ) + self.assertEqual(decision["action"], "auto") + self.assertTrue(decision["editionConflictOverride"]) + self.assertTrue(decision["safetyPassed"]) + self.assertEqual(decision["reasons"], []) + self.assertTrue( + all("non-blocking" in advisory for advisory in decision["advisories"]) + ) + + def test_exact_identifier_does_not_override_series_identity_conflict(self): + item = sample_item() + item["media"]["metadata"].update( + { + "asin": "B00ABC123", + "series": [{"name": "Sword of Truth", "sequence": "1"}], + } + ) + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "asin": "B00ABC123", + "series": [{"name": "Sword of Truth", "sequence": "2"}], + "duration": 100, + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertIn("series sequence differs", decision["reasons"]) + self.assertNotIn("duration differs substantially", decision["reasons"]) + + def test_strong_title_and_author_make_isbn_difference_informational(self): + item = sample_item() + item["media"]["metadata"].update( + { + "title": "Saint Peter's Fair", + "authorName": "Ellis Peters", + "isbn": "978-old-edition", + } + ) + item["_absidekickEmbeddedMetadata"] = { + "title": "St. Peter's Fair", + "author": "Ellis Peters", + } + ranked = rank_candidates( + item, + [ + { + "title": "St. Peter's Fair", + "author": "Ellis Peters", + "isbn": "978-new-edition", + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(ranked[0]["conflicts"], ["ISBN differs"]) + self.assertEqual(decision["action"], "auto") + self.assertEqual(decision["reasons"], []) + self.assertEqual(decision["overriddenEditionConflicts"], ["ISBN differs"]) + self.assertIn("title and author strongly match", decision["advisories"][0]) + + def test_isbn_difference_stays_blocking_without_candidate_author(self): + item = sample_item() + item["media"]["metadata"]["isbn"] = "978-old-edition" + ranked = rank_candidates( + item, + [{"title": "Wizard's First Rule", "isbn": "978-new-edition"}], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertIn("ISBN differs", decision["reasons"]) + self.assertEqual(decision["overriddenEditionConflicts"], []) + + def test_isbn_advisory_does_not_override_series_sequence_conflict(self): + item = sample_item() + item["media"]["metadata"].update( + { + "isbn": "978-old-edition", + "series": [{"name": "Sword of Truth", "sequence": "1"}], + } + ) + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "isbn": "978-new-edition", + "series": [{"name": "Sword of Truth", "sequence": "2"}], + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + + self.assertEqual(decision["action"], "review") + self.assertNotIn("ISBN differs", decision["reasons"]) + self.assertIn("series sequence differs", decision["reasons"]) + self.assertEqual(decision["overriddenEditionConflicts"], ["ISBN differs"]) + + def test_isbn_advisory_does_not_override_tied_competing_volumes(self): + item = sample_item() + item["media"]["metadata"]["isbn"] = "978-old-edition" + candidates = [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "isbn": "978-new-edition", + "series": [{"name": "Sword of Truth", "sequence": "1"}], + }, + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Sword of Truth", "sequence": "2"}], + }, + ] + + decision = match_decision( + rank_candidates(item, candidates, DEFAULT_SETTINGS), DEFAULT_SETTINGS + ) + + self.assertEqual(decision["score"], 100.0) + self.assertEqual(decision["action"], "review") + self.assertIn("meaningfully different", " ".join(decision["reasons"])) + self.assertEqual(decision["overriddenEditionConflicts"], ["ISBN differs"]) + + def test_identifier_conflict_blocks_automatic_write(self): + item = sample_item() + item["media"]["metadata"]["asin"] = "B00ABC123" + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "asin": "B00WRONG", + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + self.assertEqual(decision["action"], "review") + self.assertIn("ASIN differs", decision["reasons"]) + + def test_series_sequence_conflict_blocks_automatic_write(self): + item = sample_item() + item["media"]["metadata"]["series"] = [ + {"name": "Sword of Truth", "sequence": "1"} + ] + ranked = rank_candidates( + item, + [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "series": [{"name": "Sword of Truth", "sequence": "2"}], + } + ], + DEFAULT_SETTINGS, + ) + + decision = match_decision(ranked, DEFAULT_SETTINGS) + self.assertEqual(decision["action"], "review") + self.assertIn("series sequence differs", decision["reasons"]) + + def test_adaptive_search_broadens_and_uses_fallback_provider(self): + class AdaptiveClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["provider"] == "google": + return [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + } + ] + return [] + + client = AdaptiveClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["connection"]["provider"] = "audible" + settings["matching"]["automaticFallbackProviders"] = True + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + candidates = search_candidates(client, sample_item(), settings) + + self.assertEqual(candidates[0]["title"], "Wizard's First Rule") + self.assertTrue(any(query["provider"] == "audible" for query in client.queries)) + self.assertTrue(any(query["provider"] == "google" for query in client.queries)) + + def test_tested_google_key_falls_back_after_weak_abs_result(self): + class FallbackClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["provider"] == "google": + return [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "id": "google-correct", + } + ] + return [ + { + "title": "A Different Wizard", + "author": "Someone Else", + "asin": "abs-wrong", + } + ] + + client = FallbackClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + settings["matching"]["automaticFallbackProviders"] = False + + candidates = search_candidates(client, sample_item(), settings) + ranked = rank_candidates(sample_item(), candidates, settings) + + self.assertEqual( + [query["provider"] for query in client.queries], ["audible", "google"] + ) + self.assertEqual(candidates[0]["id"], "google-correct") + self.assertEqual(candidates[0]["_absidekickSearch"]["provider"], "google") + self.assertIn( + "after no confident Audiobookshelf match", + candidates[0]["_absidekickSearch"]["strategy"], + ) + self.assertEqual( + [attempt["provider"] for attempt in candidates.attempts], + ["audible", "google"], + ) + self.assertEqual(candidates.attempts[0]["stage"], "ABS primary search") + self.assertEqual(candidates.attempts[1]["stage"], "Google second pass") + self.assertEqual(candidates.attempts[1]["status"], "results") + self.assertEqual(candidates.attempts[1]["resultCount"], 1) + self.assertEqual(match_decision(ranked, settings)["action"], "auto") + + def test_tested_google_key_is_not_used_after_confident_abs_result(self): + class ConfidentClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + } + ] + + client = ConfidentClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + + candidates = search_candidates(client, sample_item(), settings) + + self.assertEqual(len(candidates), 1) + self.assertEqual([query["provider"] for query in client.queries], ["audible"]) + self.assertEqual( + [attempt["provider"] for attempt in candidates.attempts], ["audible"] + ) + + def test_google_second_pass_is_visible_when_it_returns_no_results(self): + class WeakThenEmptyClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["provider"] in {"google", "openlibrary"}: + return [] + return [{"title": "A Different Wizard", "author": "Someone Else"}] + + client = WeakThenEmptyClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + + candidates = search_candidates(client, sample_item(), settings) + + self.assertEqual( + [query["provider"] for query in client.queries], + ["audible", "google", "openlibrary"], + ) + google_attempt = next( + attempt + for attempt in candidates.attempts + if attempt["provider"] == "google" + ) + self.assertEqual(google_attempt["status"], "no_results") + self.assertEqual(google_attempt["resultCount"], 0) + self.assertEqual(candidates.attempts[-1]["provider"], "openlibrary") + self.assertEqual(candidates.attempts[-1]["status"], "no_results") + + def test_open_library_third_stage_can_auto_match_after_abs_and_google(self): + class ThirdStageClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["provider"] == "openlibrary": + return [ + { + "id": "/works/OL-test", + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + } + ] + return [] + + client = ThirdStageClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + + candidates = search_candidates(client, sample_item(), settings) + decision = match_decision( + rank_candidates(sample_item(), candidates, settings), settings + ) + + self.assertEqual( + [query["provider"] for query in client.queries], + ["audible", "audible", "google", "openlibrary"], + ) + self.assertEqual(candidates[0]["id"], "/works/OL-test") + self.assertEqual(candidates[0]["_absidekickSearch"]["provider"], "openlibrary") + self.assertEqual(candidates.attempts[-1]["stage"], "Open Library third stage") + self.assertEqual(candidates.attempts[-1]["status"], "results") + self.assertEqual(decision["action"], "auto") + + def test_untested_google_key_is_never_used_automatically(self): + class EmptyClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [] + + client = EmptyClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"]["googleBooksApiKey"] = "not-tested" + + candidates = search_candidates(client, sample_item(), settings) + + self.assertTrue(client.queries) + self.assertEqual( + {query["provider"] for query in client.queries}, + {"audible", "openlibrary"}, + ) + google_attempt = next( + attempt + for attempt in candidates.attempts + if attempt["provider"] == "google" + ) + self.assertEqual(google_attempt["status"], "skipped") + self.assertIn("add and test an API key", google_attempt["message"]) + + def test_google_fallback_cannot_auto_apply_through_abs_quick_match(self): + settings = deepcopy(DEFAULT_SETTINGS) + settings["matching"]["applyMode"] = "quick_match" + settings["matching"]["strictAutoMatch"] = False + candidate = { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "_absidekickSearch": { + "provider": "google", + "strategy": "native Google fallback", + "quickMatchEligible": False, + }, + } + + decision = match_decision( + rank_candidates(sample_item(), [candidate], settings), settings + ) + + self.assertEqual(decision["action"], "review") + self.assertIn("metadata patch mode", " ".join(decision["reasons"])) + + @patch("mlm.modules.absidekick.core.search_google_books") + def test_transient_google_fallback_log_says_it_will_retry(self, search): + search.side_effect = ABSAPIError("Google Books returned HTTP 503.", status=503) + client = ABSClient( + "http://localhost:13378", + "abs-token", + google_books_api_key="tested-key", + google_books_ready=True, + ) + client.request = lambda *args, **kwargs: [ + { + "title": "A Different Wizard", + "author": "Someone Else", + "asin": "abs-wrong", + } + ] + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + "openLibraryEnabled": False, + } + ) + + with patch("mlm.modules.absidekick.core.time.sleep"): + candidates = search_candidates(client, sample_item(), settings) + + self.assertEqual(len(candidates.diagnostics), 1) + self.assertIn( + "will retry on the next item", candidates.diagnostics[0]["message"] + ) + self.assertNotIn("disabled for the rest", candidates.diagnostics[0]["message"]) + self.assertEqual(candidates.diagnostics[0]["error"], str(search.side_effect)) + + def test_only_native_open_library_fallback_is_enabled_by_default(self): + class EmptyClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [] + + client = EmptyClient() + search_candidates(client, sample_item(), DEFAULT_SETTINGS) + + self.assertTrue(client.queries) + self.assertEqual( + {query["provider"] for query in client.queries}, + {"audible", "openlibrary"}, + ) + + def test_candidate_stops_broader_searches(self): + class PreciseClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [{"title": "Wizard's First Rule", "author": "Terry Goodkind"}] + + client = PreciseClient() + candidates = search_candidates(client, sample_item(), DEFAULT_SETTINGS) + + self.assertEqual(len(client.queries), 1) + self.assertEqual(candidates[0]["title"], "Wizard's First Rule") + + def test_track_number_is_removed_after_empty_precise_search(self): + class NumberedClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["title"] == "Northern Lights": + return [{"title": "Northern Lights", "author": "Philip Pullman"}] + return [] + + item = sample_item() + item["media"]["metadata"]["title"] = "01 Northern Lights" + item["media"]["metadata"]["authorName"] = "Philip Pullman" + client = NumberedClient() + candidates = search_candidates(client, item, DEFAULT_SETTINGS) + + self.assertEqual( + [query["title"] for query in client.queries], + ["Northern Lights"], + ) + self.assertEqual(candidates[0]["title"], "Northern Lights") + + def test_parenthesized_track_numbers_are_removed_from_search_title(self): + self.assertEqual(clean_search_title("01(3) Octopussy"), "Octopussy") + self.assertEqual(clean_search_title("02 (7) Thunderball"), "Thunderball") + self.assertEqual(clean_search_title("11(22)63"), "11(22)63") + + def test_common_release_numbering_and_packaging_are_removed(self): + examples = { + "00.5 The Bone Farm": "The Bone Farm", + "01.7 The Prisoner of Limnos": "The Prisoner of Limnos", + "14b A Fire Within the Ways": "A Fire Within the Ways", + "05-06 Heir of Novron": "Heir of Novron", + "1-Chinna Achebe": "Chinna Achebe", + "01The Notebook": "The Notebook", + "A Big Little Life CD 1": "A Big Little Life", + "[HH Anthology] Echoes of Revelation": "Echoes of Revelation", + } + + for original, expected in examples.items(): + with self.subTest(original=original): + variants = title_search_variants(original) + self.assertEqual(variants[0]["title"], expected) + self.assertEqual(variants[-1]["title"], original) + + for real_title in ("1984", "11(22)63", "1Q84", "Catch 22 - A Novel"): + with self.subTest(real_title=real_title): + self.assertEqual( + title_search_variants(real_title)[0]["title"], real_title + ) + + def test_cleaned_google_result_can_auto_match_parenthesized_track_title(self): + class GoogleSecondPassClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["provider"] == "google": + return [{"title": "Octopussy", "author": "Ian Fleming"}] + return [{"title": "A Different Book", "author": "Ian Fleming"}] + + item = sample_item() + item["media"]["metadata"]["title"] = "01(3) Octopussy" + item["media"]["metadata"]["authorName"] = "Ian Fleming" + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"].update( + { + "googleBooksApiKey": "tested-key", + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": google_books_key_fingerprint( + "tested-key" + ), + } + ) + client = GoogleSecondPassClient() + + candidates = search_candidates(client, item, settings) + ranked = rank_candidates(item, candidates, settings) + decision = match_decision(ranked, settings) + + self.assertEqual( + [query["provider"] for query in client.queries], ["audible", "google"] + ) + self.assertEqual(client.queries[-1]["title"], "Octopussy") + self.assertEqual(ranked[0]["parts"]["title"], 100) + self.assertEqual(ranked[0]["parts"]["author"], 100) + self.assertEqual(decision["action"], "auto") + + def test_series_sequence_prefix_is_removed_before_searching(self): + class PernClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["title"] == "The Masterharper of Pern": + return [ + { + "title": "The Masterharper of Pern", + "author": "Anne McCaffrey", + } + ] + return [] + + item = sample_item() + item["media"]["metadata"]["title"] = "Pern 17 - The Masterharper of Pern" + item["media"]["metadata"]["authorName"] = "Anne McCaffrey" + client = PernClient() + + candidates = search_candidates(client, item, DEFAULT_SETTINGS) + ranked = rank_candidates(item, candidates, DEFAULT_SETTINGS) + + self.assertEqual( + [query["title"] for query in client.queries], + ["The Masterharper of Pern"], + ) + self.assertEqual(candidates[0]["title"], "The Masterharper of Pern") + self.assertEqual( + candidates[0]["_absidekickSearch"]["strategy"], + "parsed title + author", + ) + self.assertTrue(candidates[0]["_absidekickSearch"]["quickMatchEligible"]) + self.assertEqual(ranked[0]["parts"]["title"], 100.0) + self.assertEqual(match_decision(ranked, DEFAULT_SETTINGS)["action"], "auto") + + def test_nested_series_release_prefixes_produce_ranked_search_variants(self): + title = "Dragonriders of Pern #3 - Pern08-Nerilka's Story" + + variants = title_search_variants(title) + + self.assertEqual( + [variant["title"] for variant in variants], + [ + "Nerilka's Story", + "Pern08-Nerilka's Story", + title, + ], + ) + self.assertEqual(variants[0]["strategy"], "parsed nested series title + author") + self.assertTrue(variants[0]["quickMatchEligible"]) + self.assertEqual(clean_search_title(title), "Nerilka's Story") + + def test_nested_series_title_is_searched_and_auto_matched(self): + class NerilkaClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["title"] == "Nerilka's Story": + return [ + { + "title": "Nerilka's Story", + "author": "Anne McCaffrey", + } + ] + return [] + + item = sample_item() + item["media"]["metadata"]["title"] = ( + "Dragonriders of Pern #3 - Pern08-Nerilka's Story" + ) + item["media"]["metadata"]["authorName"] = "Anne McCaffrey" + client = NerilkaClient() + + candidates = search_candidates(client, item, DEFAULT_SETTINGS) + ranked = rank_candidates(item, candidates, DEFAULT_SETTINGS) + + self.assertEqual( + [query["title"] for query in client.queries], ["Nerilka's Story"] + ) + self.assertEqual( + candidates[0]["_absidekickSearch"]["strategy"], + "parsed nested series title + author", + ) + self.assertEqual(match_decision(ranked, DEFAULT_SETTINGS)["action"], "auto") + + def test_nested_series_search_retains_intermediate_and_original_fallbacks(self): + class VariantClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["title"].startswith("Dragonriders of Pern"): + return [ + { + "title": "Nerilka's Story", + "author": "Anne McCaffrey", + } + ] + return [] + + item = sample_item() + original = "Dragonriders of Pern #3 - Pern08-Nerilka's Story" + item["media"]["metadata"]["title"] = original + item["media"]["metadata"]["authorName"] = "Anne McCaffrey" + client = VariantClient() + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"]["openLibraryEnabled"] = False + + candidates = search_candidates(client, item, settings) + + self.assertEqual( + [query["title"] for query in client.queries], + ["Nerilka's Story", "Pern08-Nerilka's Story", original], + ) + self.assertEqual(candidates[0]["title"], "Nerilka's Story") + + def test_matching_accepts_list_shaped_abs_narrators(self): + item = sample_item() + item["media"]["metadata"]["narratorName"] = [ + {"name": "Neil Dickson"}, + "Second Narrator", + ] + candidate = { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + "narrators": ["Neil Dickson"], + } + + scored = score_candidate(item, candidate, DEFAULT_SETTINGS) + + self.assertEqual(scored["parts"]["narrator"], 100.0) + + def test_same_embedded_title_prefers_paired_file_author_without_duplicate_query( + self, + ): + class EmbeddedAuthorClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [ + { + "title": "A Cavern of Black Ice", + "author": "J. V. Jones", + "narrators": ["Neil Dickson"], + } + ] + + item = sample_item() + item["media"]["metadata"].update( + { + "title": "A Cavern of Black Ice", + "authorName": "Incorrect ABS Author", + "narratorName": [{"name": "Neil Dickson"}], + } + ) + item["media"]["audioFiles"] = [ + { + "metaTags": { + "tagAlbum": "A Cavern of Black Ice", + "tagArtist": "J. V. Jones", + } + } + ] + settings = deepcopy(DEFAULT_SETTINGS) + settings["matching"]["useEmbeddedFileMetadata"] = True + settings["providers"]["openLibraryEnabled"] = False + client = EmbeddedAuthorClient() + prepared = prepare_item_for_matching(client, item, settings) + + candidates = search_candidates(client, prepared, settings) + decision = match_decision( + rank_candidates(prepared, candidates, settings), settings + ) + + self.assertEqual(len(client.queries), 1) + self.assertEqual(client.queries[0]["title"], "A Cavern of Black Ice") + self.assertEqual(client.queries[0]["author"], "J. V. Jones") + self.assertEqual(decision["action"], "auto") + + def test_quick_match_uses_the_evidence_backed_parsed_title(self): + class RecordingClient: + def __init__(self): + self.posts = [] + self.patches = [] + + def post(self, path, params=None, body=None): + self.posts.append((path, params, body)) + return {"ok": True} + + def patch(self, path, body): + self.patches.append((path, body)) + return {"ok": True, "path": path, "body": body} + + item = sample_item() + item["media"]["metadata"]["title"] = "Pern 17 - The Masterharper of Pern" + item["media"]["metadata"]["authorName"] = "Anne McCaffrey" + candidate = { + "title": "The Masterharper of Pern", + "author": "Anne McCaffrey", + "series": [{"series": "Pern", "sequence": "17"}], + "_absidekickSearch": { + "strategy": "parsed title + author", + "queryTitle": "The Masterharper of Pern", + "quickMatchEligible": True, + }, + } + scored = score_candidate(item, candidate, DEFAULT_SETTINGS) + settings = deepcopy(DEFAULT_SETTINGS) + settings["run"]["dryRun"] = False + settings["matching"]["applyMode"] = "quick_match" + client = RecordingClient() + + apply_match(client, item, scored, settings) + + self.assertEqual(client.posts[0][1]["title"], "The Masterharper of Pern") + self.assertEqual( + client.patches[0][1]["metadata"]["series"], + [{"name": "Pern", "sequence": "17"}], + ) + + def test_abs_series_metadata_can_confirm_a_non_repeating_prefix(self): + entries = [("Discworld", "4")] + + self.assertEqual( + clean_search_title("Discworld 04 - Mort", entries), + "Mort", + ) + + def test_numbered_real_title_is_tried_before_weak_prefix_fallback(self): + class CatchClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + return [{"title": "Catch 22 - A Novel", "author": "Joseph Heller"}] + + item = sample_item() + item["media"]["metadata"]["title"] = "Catch 22 - A Novel" + item["media"]["metadata"]["authorName"] = "Joseph Heller" + client = CatchClient() + + candidates = search_candidates(client, item, DEFAULT_SETTINGS) + + self.assertEqual(len(client.queries), 1) + self.assertEqual(client.queries[0]["title"], "Catch 22 - A Novel") + self.assertEqual(candidates[0]["title"], "Catch 22 - A Novel") + + def test_unconfirmed_series_prefix_is_used_only_after_original_is_empty(self): + class FallbackClient: + def __init__(self): + self.queries = [] + + def get(self, path, params=None): + self.queries.append(dict(params or {})) + if params["title"] == "The Hidden Star": + return [{"title": "The Hidden Star", "author": "A. Writer"}] + return [] + + item = sample_item() + item["media"]["metadata"]["title"] = "Galaxy 07 - The Hidden Star" + item["media"]["metadata"]["authorName"] = "A. Writer" + client = FallbackClient() + + candidates = search_candidates(client, item, DEFAULT_SETTINGS) + + self.assertEqual( + [query["title"] for query in client.queries], + ["Galaxy 07 - The Hidden Star", "The Hidden Star"], + ) + self.assertEqual( + candidates[0]["_absidekickSearch"]["strategy"], + "possible series-prefix title + author", + ) + self.assertFalse(candidates[0]["_absidekickSearch"]["quickMatchEligible"]) + + def test_timed_out_provider_is_disabled_without_retries(self): + client = ABSClient("http://localhost:13378", "token", max_retries=5) + calls = [] + + def fail_request(*args, **kwargs): + calls.append(kwargs) + raise ABSAPIError("timed out") + + client.request = fail_request + settings = deepcopy(DEFAULT_SETTINGS) + settings["providers"]["openLibraryEnabled"] = False + first = search_candidates(client, sample_item(), settings) + second = search_candidates(client, sample_item(), settings) + + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]["max_retries"], 0) + self.assertEqual(calls[0]["timeout_seconds"], 12) + self.assertEqual(first.diagnostics[0]["provider"], "audible") + self.assertEqual(second.diagnostics, []) + + def test_primary_provider_timeout_stops_job_without_tagging_remaining_items(self): + class DownClient(ABSClient): + def __init__(self): + super().__init__("http://localhost:13378", "token") + self.search_calls = 0 + + def get(self, path, params=None): + if path.endswith("/items"): + first = sample_item() + second = sample_item() + second["id"] = "li_second" + return {"results": [first, second], "total": 2} + raise AssertionError(path) + + def request(self, *args, **kwargs): + self.search_calls += 1 + raise ABSAPIError("timed out") + + settings = deepcopy(DEFAULT_SETTINGS) + settings["connection"]["libraryId"] = "library" + settings["providers"]["openLibraryEnabled"] = False + client = DownClient() + job = MatchJob("test", client, settings) + + job.run() + + snapshot = job.snapshot() + self.assertEqual(snapshot["status"], "failed") + self.assertEqual(snapshot["stats"]["processed"], 1) + self.assertEqual(snapshot["stats"]["errors"], 1) + self.assertEqual(client.search_calls, 1) + self.assertIn( + "No remaining items were changed", snapshot["logs"][-1]["message"] + ) + + def test_manual_review_search_uses_custom_terms_and_rescores_results(self): + class SearchClient: + def __init__(self): + self.search_params = None + + def get(self, path, params=None): + if path == "/api/items/li_test": + return sample_item(["ABSidekick: Needs Review"]) + if path == "/api/search/books": + self.search_params = params + return [ + {"title": "Stone of Tears", "author": "Terry Goodkind"}, + { + "title": "Wizards First Rule", + "author": "Terry Goodkind", + "publishedYear": "1994", + }, + ] + raise AssertionError(path) + + client = SearchClient() + result = search_review_candidates( + client, + "li_test", + { + "title": "wizards first", + "author": "", + "provider": "openlibrary", + "limit": 20, + }, + DEFAULT_SETTINGS, + ) + + self.assertEqual( + client.search_params, + { + "title": "wizards first", + "author": "", + "provider": "openlibrary", + "limit": 20, + }, + ) + self.assertEqual(result["resultCount"], 2) + self.assertEqual( + result["candidates"][0]["candidate"]["title"], + "Wizards First Rule", + ) + self.assertEqual(result["candidates"][0]["searchSource"], "manual") + self.assertEqual(result["candidates"][0]["searchProvider"], "openlibrary") + self.assertEqual(result["item"]["id"], "li_test") + self.assertEqual(result["manualMatch"]["status"], result["decision"]["action"]) + self.assertIs( + result["manualMatch"]["isConfidentMatch"], + result["decision"]["action"] == "auto", + ) + self.assertEqual( + result["manualMatch"]["bestCandidate"]["candidate"]["title"], + "Wizards First Rule", + ) + + def test_manual_review_search_explicitly_reports_no_match(self): + class EmptySearchClient: + def get(self, path, params=None): + if path == "/api/items/li_test": + return sample_item(["ABSidekick: Needs Review"]) + if path == "/api/search/books": + return [] + raise AssertionError(path) + + result = search_review_candidates( + EmptySearchClient(), + "li_test", + {"title": "missing book", "provider": "google"}, + DEFAULT_SETTINGS, + ) + + self.assertEqual(result["resultCount"], 0) + self.assertEqual(result["manualMatch"]["status"], "unmatched") + self.assertFalse(result["manualMatch"]["isConfidentMatch"]) + self.assertTrue(result["manualMatch"]["requiresReview"]) + self.assertIsNone(result["manualMatch"]["bestCandidate"]) + self.assertIn("No metadata candidates", result["manualMatch"]["message"]) + + def test_manual_review_search_scores_against_edited_fields(self): + class EditedSearchClient: + def get(self, path, params=None): + if path == "/api/items/li_test": + return sample_item(["ABSidekick: Needs Review"]) + if path == "/api/search/books": + return [ + { + "title": "A Completely Different Book", + "author": "New Author", + }, + {"title": "Wizard's First Rule", "author": "Terry Goodkind"}, + ] + raise AssertionError(path) + + result = search_review_candidates( + EditedSearchClient(), + "li_test", + { + "title": "A Completely Different Book", + "author": "New Author", + "provider": "google", + }, + DEFAULT_SETTINGS, + ) + + self.assertEqual( + result["candidates"][0]["candidate"]["title"], + "A Completely Different Book", + ) + self.assertEqual( + result["manualMatch"]["scoredAgainst"], + {"title": "A Completely Different Book", "author": "New Author"}, + ) + + def test_manual_review_search_rejects_unknown_provider(self): + with self.assertRaisesRegex(ValueError, "unknown Audiobookshelf"): + search_review_candidates( + object(), + "li_test", + {"title": "Wizard", "provider": "not-real"}, + DEFAULT_SETTINGS, + ) + + def test_review_scan_reports_loading_and_per_item_progress(self): + class ReviewScanClient: + def get(self, path, params=None): + if path == "/api/libraries/library-1/items": + return { + "results": [sample_item(["ABSidekick: Needs Review"])], + "total": 1, + } + if path == "/api/search/books": + return [ + { + "title": "Wizard's First Rule", + "author": "Terry Goodkind", + } + ] + raise AssertionError(path) + + settings = deepcopy(DEFAULT_SETTINGS) + settings["connection"]["libraryId"] = "library-1" + updates = [] + + result = scan_review_items( + ReviewScanClient(), + settings, + limit=25, + progress=lambda update: updates.append(dict(update)), + ) + + self.assertEqual(len(result["rows"]), 1) + self.assertEqual(updates[0]["phase"], "loading") + self.assertEqual(updates[1]["total"], 1) + self.assertEqual(updates[2]["current"], 0) + self.assertEqual(updates[2]["currentTitle"], "Wizard's First Rule") + self.assertEqual(updates[-1]["current"], 1) + self.assertIn("Finished Wizard's First Rule", updates[-1]["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tests/test_absidekick_service.py b/python/tests/test_absidekick_service.py new file mode 100644 index 00000000..223e0f4d --- /dev/null +++ b/python/tests/test_absidekick_service.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import mlm.modules.absidekick.service as absidekick_service +from mlm.modules.absidekick.service import ABSidekickService + + +def test_absidekick_settings_round_trip_without_exposing_token( + tmp_path: Path, +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + + result = service.save( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "audiobooks", + "provider": "audible", + "rememberConnection": True, + }, + "matching": {"threshold": 91}, + }, + "token": "secret-token", + } + ) + + assert result["settings"]["connection"]["hasToken"] is True + assert "token" not in result["settings"]["connection"] + assert result["settings"]["matching"]["threshold"] == 91 + saved = json.loads(service.settings_path.read_text(encoding="utf-8")) + assert saved["connection"]["token"] == "secret-token" + + restored = ABSidekickService(tmp_path / "absidekick") + assert restored.token == "secret-token" + assert restored.public_state()["settings"]["matching"]["threshold"] == 91 + + +def test_absidekick_token_is_remembered_by_default_after_restart( + tmp_path: Path, +) -> None: + data_dir = tmp_path / "absidekick" + service = ABSidekickService(data_dir) + + result = service.save( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "audiobooks", + } + }, + "token": "saved-by-default-token", + } + ) + + assert result["settings"]["connection"]["rememberConnection"] is True + assert result["settings"]["connection"]["hasToken"] is True + restored = ABSidekickService(data_dir) + assert restored.token == "saved-by-default-token" + assert restored.public_state()["settings"]["connection"]["hasToken"] is True + + +def test_absidekick_does_not_persist_token_when_remember_is_disabled( + tmp_path: Path, +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + + service.save( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "rememberConnection": False, + } + }, + "token": "session-only", + } + ) + + saved = json.loads(service.settings_path.read_text(encoding="utf-8")) + assert "token" not in saved["connection"] + assert service.public_state()["settings"]["connection"]["hasToken"] is True + + +def test_google_books_key_must_pass_live_test_and_is_never_returned( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + + saved_result = service.save( + { + "settings": { + "providers": { + "googleBooksApiKeyValidated": True, + "googleBooksApiKeyFingerprint": "browser-cannot-enable-this", + } + }, + "googleBooksApiKey": "private-google-key", + } + ) + + assert saved_result["settings"]["providers"]["hasGoogleBooksApiKey"] is True + assert saved_result["settings"]["providers"]["googleBooksReady"] is False + assert "googleBooksApiKey" not in saved_result["settings"]["providers"] + assert "private-google-key" in service.settings_path.read_text(encoding="utf-8") + + monkeypatch.setattr( + absidekick_service, + "test_google_books_api_key", + lambda api_key, timeout_seconds: { + "valid": True, + "sampleResults": 1, + "message": f"Live test accepted {len(api_key)} key characters.", + }, + ) + restored = ABSidekickService(tmp_path / "absidekick") + assert restored.public_state()["settings"]["providers"]["hasGoogleBooksApiKey"] + tested_result = restored.test_google_books({}) + + assert tested_result["valid"] is True + assert tested_result["settings"]["providers"]["googleBooksReady"] is True + assert "googleBooksApiKey" not in tested_result["settings"]["providers"] + assert "googleBooksApiKeyFingerprint" not in tested_result["settings"]["providers"] + + cleared_result = restored.clear_google_books() + assert cleared_result["settings"]["providers"]["hasGoogleBooksApiKey"] is False + assert cleared_result["settings"]["providers"]["googleBooksReady"] is False + assert "private-google-key" not in restored.settings_path.read_text( + encoding="utf-8" + ) + + +def test_transient_retest_keeps_previously_validated_google_key_enabled( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + service.save({"googleBooksApiKey": "private-google-key"}) + monkeypatch.setattr( + absidekick_service, + "test_google_books_api_key", + lambda api_key, timeout_seconds: { + "valid": True, + "sampleResults": 1, + "message": "Google accepted the key.", + }, + ) + service.test_google_books({}) + + def transient_failure(api_key, timeout_seconds): + raise absidekick_service.ABSAPIError( + "Google Books returned HTTP 503.", status=503 + ) + + monkeypatch.setattr( + absidekick_service, "test_google_books_api_key", transient_failure + ) + + with pytest.raises(absidekick_service.ABSAPIError, match="HTTP 503"): + service.test_google_books({}) + + providers = service.public_state()["settings"]["providers"] + assert providers["googleBooksReady"] is True + assert providers["googleBooksLastError"] == "Google Books returned HTTP 503." + + +def test_permanent_retest_error_revokes_google_key_validation( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + service.save({"googleBooksApiKey": "private-google-key"}) + monkeypatch.setattr( + absidekick_service, + "test_google_books_api_key", + lambda api_key, timeout_seconds: { + "valid": True, + "sampleResults": 1, + "message": "Google accepted the key.", + }, + ) + service.test_google_books({}) + + def permanent_failure(api_key, timeout_seconds): + raise absidekick_service.ABSAPIError("Google rejected the API key.", status=403) + + monkeypatch.setattr( + absidekick_service, "test_google_books_api_key", permanent_failure + ) + + with pytest.raises(absidekick_service.ABSAPIError, match="rejected"): + service.test_google_books({}) + + providers = service.public_state()["settings"]["providers"] + assert providers["googleBooksReady"] is False + assert providers["googleBooksLastError"] == "Google rejected the API key." + + +def test_absidekick_review_search_uses_normal_service_connection( + tmp_path: Path, monkeypatch +) -> None: + class SearchClient: + def get(self, path, params=None): + if path == "/api/items/item-1": + return { + "id": "item-1", + "path": "/books/The Book", + "media": { + "duration": 3600, + "metadata": {"title": "The Book", "authorName": "An Author"}, + }, + } + if path == "/api/search/books": + assert params["provider"] == "google" + return [{"title": "The Book", "author": "An Author"}] + raise AssertionError(path) + + service = ABSidekickService(tmp_path / "absidekick") + monkeypatch.setattr(service, "client", lambda settings, token: SearchClient()) + + result = service.search_review( + { + "itemId": "item-1", + "query": { + "title": "Book", + "author": "", + "provider": "google", + "limit": 12, + }, + } + ) + + assert result["ok"] is True + assert result["resultCount"] == 1 + assert result["query"]["limit"] == 12 + assert result["candidates"][0]["candidate"]["title"] == "The Book" + assert result["manualMatch"]["bestCandidate"]["candidate"]["title"] == "The Book" + assert result["manualMatch"]["status"] == result["decision"]["action"] + + +def test_review_scan_publishes_live_activity_and_completion( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + observed_running = [] + + def fake_scan(_client, _settings, limit, excluded_ids, progress): + assert limit == 3 + assert excluded_ids == set() + progress( + { + "phase": "searching", + "detail": "Searching providers for The Book", + "current": 0, + "total": 1, + "currentTitle": "The Book", + } + ) + observed_running.append(service.activity_snapshot()["activity"]) + progress( + { + "phase": "searching", + "detail": "Finished The Book", + "current": 1, + "total": 1, + "currentTitle": "The Book", + } + ) + return {"totalReviewItems": 1, "rows": [{"item": {"id": "item-1"}}]} + + monkeypatch.setattr(service, "client", lambda settings, token: object()) + monkeypatch.setattr(absidekick_service, "scan_review_items", fake_scan) + + result = service.scan_review({"limit": 3}) + activity = service.public_state()["activity"] + + assert result["ok"] is True + assert observed_running[0]["status"] == "running" + assert observed_running[0]["currentTitle"] == "The Book" + assert activity["status"] == "success" + assert activity["current"] == 1 + assert activity["total"] == 1 + assert "loaded 1 item" in activity["detail"] + + +def test_open_library_email_persists_across_connect_and_save( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + + class FakeClient: + def get(self, path): + if path == "/api/libraries": + return [{"id": "lib-1", "name": "Audiobooks"}] + return {} + + def post(self, path): + return {"ok": True} + + monkeypatch.setattr(service, "client", lambda *args, **kwargs: FakeClient()) + + connect_result = service.connect( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "lib-1", + }, + "providers": { + "openLibraryContactEmail": "user@example.com", + "openLibraryEnabled": True, + }, + }, + "token": "test-token", + } + ) + assert connect_result["ok"] is True + assert ( + service.settings["providers"]["openLibraryContactEmail"] == "user@example.com" + ) + assert ( + service.public_state()["settings"]["providers"]["openLibraryContactEmail"] + == "user@example.com" + ) + + save_result = service.save( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "lib-1", + }, + }, + "openLibraryContactEmail": "updated@example.com", + "token": "test-token", + } + ) + assert save_result["ok"] is True + assert ( + service.settings["providers"]["openLibraryContactEmail"] + == "updated@example.com" + ) + + +def test_auto_sync_library_triggers_scan_and_starts_job( + tmp_path: Path, monkeypatch +) -> None: + service = ABSidekickService(tmp_path / "absidekick") + scans_called = [] + + class FakeClient: + def post(self, path): + scans_called.append(path) + return {"ok": True} + + monkeypatch.setattr(service, "client", lambda *args, **kwargs: FakeClient()) + + service.save( + { + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "lib-1", + }, + }, + "token": "test-token", + } + ) + + monkeypatch.setattr( + absidekick_service.MatchJob, + "start", + lambda self: setattr(self, "status", "running"), + ) + + sync_res = service.auto_sync_library() + assert sync_res["ok"] is True + assert "/api/libraries/lib-1/scan" in scans_called + assert service.job is not None + assert service.job.status in {"queued", "running"} diff --git a/python/tests/test_cleaner.py b/python/tests/test_cleaner.py new file mode 100644 index 00000000..ac9b6cf4 --- /dev/null +++ b/python/tests/test_cleaner.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from mlm.cleaner import clean_superseded +from mlm.config import Config +from mlm.database import ensure_database +from mlm.repository import Repository + + +def test_clean_superseded_preserves_series_books_with_same_title_search( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + + lib_dir1 = tmp_path / "lib" / "Author" / "Series #1 - Title" + lib_dir1.mkdir(parents=True) + file1 = lib_dir1 / "book1.m4b" + file1.write_bytes(b"audio 1") + + lib_dir2 = tmp_path / "lib" / "Author" / "Series #2 - Title" + lib_dir2.mkdir(parents=True) + file2 = lib_dir2 / "book2.m4b" + file2.write_bytes(b"audio 2") + + book1 = { + "id": "hash1", + "id_is_hash": True, + "mam_id": 101, + "title_search": "my eldritch horror", + "created_at": "2026-08-01T00:00:00Z", + "library_path": str(lib_dir1), + "library_files": ["book1.m4b"], + "meta": { + "title": "My Eldritch Horror", + "media_type": "Audiobook", + "language": "English", + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["1"]}], + "filetypes": ["m4b"], + }, + } + + book2 = { + "id": "hash2", + "id_is_hash": True, + "mam_id": 102, + "title_search": "my eldritch horror", + "created_at": "2026-08-01T00:00:00Z", + "library_path": str(lib_dir2), + "library_files": ["book2.m4b"], + "meta": { + "title": "My Eldritch Horror", + "media_type": "Audiobook", + "language": "English", + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["2"]}], + "filetypes": ["m4b"], + }, + } + + repository.record_linked(book1, 101) + repository.record_linked(book2, 102) + + config = Config( + mam_id="cookie", + audio_types=("m4b", "mp3"), + ) + + cleaned = asyncio.run(clean_superseded(config, repository, [])) + assert cleaned == 0 + assert file1.exists() + assert file2.exists() + t1 = repository.torrent("hash1") + t2 = repository.torrent("hash2") + assert t1 is not None and t1["library_path"] == str(lib_dir1) + assert t2 is not None and t2["library_path"] == str(lib_dir2) + + +def test_clean_superseded_cleans_only_true_duplicate_formats(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + + lib_dir_mp3 = tmp_path / "lib" / "Author" / "Series #1 - Title mp3" + lib_dir_mp3.mkdir(parents=True) + file_mp3 = lib_dir_mp3 / "book1.mp3" + file_mp3.write_bytes(b"audio mp3") + + lib_dir_m4b = tmp_path / "lib" / "Author" / "Series #1 - Title" + lib_dir_m4b.mkdir(parents=True) + file_m4b = lib_dir_m4b / "book1.m4b" + file_m4b.write_bytes(b"audio m4b") + + book1_mp3 = { + "id": "hash_mp3", + "id_is_hash": True, + "mam_id": 101, + "title_search": "my eldritch horror", + "created_at": "2026-08-01T00:00:00Z", + "library_path": str(lib_dir_mp3), + "library_files": ["book1.mp3"], + "meta": { + "title": "My Eldritch Horror", + "media_type": "Audiobook", + "language": "English", + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["1"]}], + "filetypes": ["mp3"], + }, + } + + book1_m4b = { + "id": "hash_m4b", + "id_is_hash": True, + "mam_id": 102, + "title_search": "my eldritch horror", + "created_at": "2026-08-02T00:00:00Z", + "library_path": str(lib_dir_m4b), + "library_files": ["book1.m4b"], + "meta": { + "title": "My Eldritch Horror", + "media_type": "Audiobook", + "language": "English", + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["1"]}], + "filetypes": ["m4b"], + }, + } + + repository.record_linked(book1_mp3, 101) + repository.record_linked(book1_m4b, 102) + + config = Config( + mam_id="cookie", + audio_types=("m4b", "mp3"), + ) + + cleaned = asyncio.run(clean_superseded(config, repository, [])) + assert cleaned == 1 + assert file_m4b.exists() + assert not file_mp3.exists() + t_mp3 = repository.torrent("hash_mp3") + t_m4b = repository.torrent("hash_m4b") + assert t_mp3 is not None and t_mp3["library_path"] is None + assert t_m4b is not None and t_m4b["library_path"] == str(lib_dir_m4b) diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py new file mode 100644 index 00000000..a17318e8 --- /dev/null +++ b/python/tests/test_cli.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import pytest + +from mlm.cli import build_parser + + +def test_cli_reports_installed_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exit_info: + build_parser().parse_args(["--version"]) + + assert exit_info.value.code == 0 + assert capsys.readouterr().out.strip() == "mlm-python 0.5.0b65" diff --git a/python/tests/test_clients.py b/python/tests/test_clients.py new file mode 100644 index 00000000..0402af5f --- /dev/null +++ b/python/tests/test_clients.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +import mlm.mam as mam_module +from mlm.mam import USER_AGENT, MamClient, MamError, authenticated_mam_client + + +def test_mam_uses_approved_heavy_mlm_identity() -> None: + mam = MamClient("cookie") + try: + assert mam.client.headers["User-Agent"] == USER_AGENT + assert USER_AGENT.startswith("HeavyMLM/") + assert "github.com/Plungis/MLM" in USER_AGENT + finally: + asyncio.run(mam.close()) + + +def test_torrent_download_always_includes_tid() -> None: + observed: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + observed.append(request) + return httpx.Response(200, content=b"d4:infode") + + async def exercise() -> bytes: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport(handler), + ) as http: + mam = MamClient("cookie", client=http) + return await mam.get_torrent_file(123456) + + assert asyncio.run(exercise()) == b"d4:infode" + assert observed[0].url.path == "/tor/download.php" + assert observed[0].url.params["tid"] == "123456" + + +def test_rejected_cookie_is_not_persisted() -> None: + stored: list[str] = [] + + async def exercise() -> None: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport( + lambda _: httpx.Response(200, json={"Success": False}) + ), + ) as http: + mam = MamClient("rejected", client=http, cookie_store=stored.append) + with pytest.raises(MamError, match="did not report success"): + await mam.check_mam_id() + + asyncio.run(exercise()) + assert stored == [] + + +def test_non_json_success_response_is_accepted() -> None: + stored: list[str] = [] + + async def exercise() -> None: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + text='PHP notice\n{"Success" : true}', + headers={"content-type": "text/html"}, + ) + ), + ) as http: + mam = MamClient("accepted", client=http, cookie_store=stored.append) + await mam.check_mam_id() + + asyncio.run(exercise()) + assert stored == ["accepted"] + + +def test_ip_locked_api_session_response_is_accepted() -> None: + stored: list[str] = [] + + async def exercise() -> None: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport( + lambda _: httpx.Response( + 200, + text="Session type - API session with IP locking", + headers={"content-type": "text/html; charset=UTF-8"}, + ) + ), + ) as http: + mam = MamClient("accepted", client=http, cookie_store=stored.append) + await mam.check_mam_id() + + asyncio.run(exercise()) + assert stored == ["accepted"] + + +def test_authentication_falls_back_to_config_cookie( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts: list[str] = [] + + class FakeMamClient: + def __init__(self, mam_id: str, **_: object) -> None: + self.mam_id = mam_id + + async def check_mam_id(self) -> None: + attempts.append(self.mam_id) + if self.mam_id == "stale": + raise MamError("stale") + + async def close(self) -> None: + return None + + monkeypatch.setattr(mam_module, "MamClient", FakeMamClient) + result = asyncio.run(authenticated_mam_client("configured", stored_mam_id="stale")) + assert result.mam_id == "configured" + assert attempts == ["stale", "configured"] diff --git a/python/tests/test_config.py b/python/tests/test_config.py new file mode 100644 index 00000000..a2e51684 --- /dev/null +++ b/python/tests/test_config.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mlm.config import ( + ConfigError, + load_config, + save_config_text, + save_root_config_values, +) +from mlm.request_auth import hash_request_password + + +def test_loads_legacy_names_and_defaults(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text( + """ +mam_id = "secret" +goodreads_interval = 90 + +[[qbittorrent]] +url = "http://localhost:8080" + +[[autograb]] +type = "freeleech" +dry_run = true +""", + encoding="utf-8", + ) + + config = load_config(path) + + assert config.import_interval == 90 + assert config.download_on_wedge_failure is False + assert config.web_port == 3157 + assert config.qbittorrent[0].url == "http://localhost:8080" + assert config.autograbs[0]["dry_run"] is True + + +def test_repository_example_config_is_valid() -> None: + example = Path(__file__).parents[2] / "config.example.toml" + config = load_config(example) + + assert config.autograbs[0]["type"] == "bookmarks" + assert config.download_on_wedge_failure is False + assert len(config.libraries) == 2 + + +def test_canonical_interval_wins_when_legacy_name_is_also_present( + tmp_path: Path, +) -> None: + path = tmp_path / "config.toml" + path.write_text( + """ +mam_id = "secret" +goodreads_interval = 1 +import_interval = 60 +""", + encoding="utf-8", + ) + + config = load_config(path) + + assert config.import_interval == 60 + + +def test_beta_29_collection_setting_is_accepted_but_disabled(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text( + 'mam_id = "secret"\nsplit_collections = true\n', + encoding="utf-8", + ) + + config = load_config(path) + + assert not hasattr(config, "split_collections") + + +def test_saves_editable_root_values_without_touching_nested_secrets( + tmp_path: Path, +) -> None: + path = tmp_path / "config.toml" + path.write_text( + """ +mam_id = "keep-this-secret" +min_ratio = 2 # preserve the rest of the document +goodreads_interval = 1 + +[[qbittorrent]] +url = "http://localhost:8080" +password = "also-keep-this" + +[[autograb]] +type = "freeleech" +wedge_buffer = 99 +""", + encoding="utf-8", + ) + + config = save_root_config_values( + path, + { + "min_ratio": 3.5, + "max_unsat_slots": 140, + "wedge_buffer": 4, + "prefer_wedges": True, + "download_on_wedge_failure": True, + "import_interval": 45, + }, + ) + + text = path.read_text(encoding="utf-8") + assert config.min_ratio == 3.5 + assert config.max_unsat_slots == 140 + assert config.wedge_buffer == 4 + assert config.prefer_wedges is True + assert config.download_on_wedge_failure is True + assert config.import_interval == 45 + assert 'mam_id = "keep-this-secret"' in text + assert 'password = "also-keep-this"' in text + assert "wedge_buffer = 99" in text + assert "goodreads_interval" not in text + assert "import_interval = 45" in text + assert text.index("max_unsat_slots") < text.index("[[qbittorrent]]") + + +def test_full_config_editor_validates_and_atomically_replaces_every_section( + tmp_path: Path, +) -> None: + path = tmp_path / "config.toml" + path.write_text('mam_id = "old"\n', encoding="utf-8") + replacement = r""" +mam_id = "new-secret" +prefer_wedges = true +download_on_wedge_failure = true +wedge_buffer = 100 +audio_types = ["m4b"] + +[[qbittorrent]] +url = "http://localhost:8090" +password = "new-password" + +[[library]] +category = "Audiobooks" +library_dir = 'E:\MLM Audio' +method = "copy" +""" + + config = save_config_text(path, replacement) + + assert config.mam_id == "new-secret" + assert config.prefer_wedges is True + assert config.download_on_wedge_failure is True + assert config.wedge_buffer == 100 + assert config.audio_types == ("m4b",) + assert config.qbittorrent[0].password == "new-password" + assert config.libraries[0]["method"] == "copy" + + with pytest.raises(ConfigError): + save_config_text(path, 'mam_id = "broken"\nnot_a_setting = true\n') + assert load_config(path).mam_id == "new-secret" + + +def test_request_portal_config_requires_clean_custom_domains(tmp_path: Path) -> None: + password_hash = hash_request_password("correct horse") + path = tmp_path / "config.toml" + path.write_text( + f""" +mam_id = "secret" +request_portal_enabled = true +request_portal_domains = ["requests.example.com"] +request_portal_title = "Family Requests" +request_portal_access_code = "shared-secret" +request_portal_username = "family" +request_portal_password_hash = "{password_hash}" +request_portal_rate_limit = 12 +""", + encoding="utf-8", + ) + + config = load_config(path) + + assert config.request_portal_enabled is True + assert config.request_portal_require_account_login is True + assert config.request_portal_domains == ("requests.example.com",) + assert config.request_portal_title == "Family Requests" + assert config.request_portal_username == "family" + assert config.request_portal_password_hash == password_hash + assert config.request_portal_rate_limit == 12 + + path.write_text( + 'mam_id = "secret"\nrequest_portal_enabled = true\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="request_portal_domains"): + load_config(path) + + path.write_text( + 'mam_id = "secret"\nrequest_portal_username = "family"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="request_portal_username"): + load_config(path) + + +def test_request_portal_named_accounts_validate_permissions_and_round_trip( + tmp_path: Path, +) -> None: + admin_hash = hash_request_password("admin password") + reader_hash = hash_request_password("reader password") + path = tmp_path / "config.toml" + path.write_text('mam_id = "secret"\n', encoding="utf-8") + + config = save_root_config_values( + path, + { + "request_portal_users": ( + { + "username": "admin", + "password_hash": admin_hash, + "display_name": "Library Admin", + "permissions": ("auto_approve",), + "weekly_request_limit": 10, + }, + { + "username": "reader", + "password_hash": reader_hash, + "display_name": "Reader", + "permissions": (), + "weekly_request_limit": 20, + }, + ) + }, + ) + + assert [user.username for user in config.request_portal_users] == [ + "admin", + "reader", + ] + assert config.request_portal_users[0].permissions == ("auto_approve",) + assert config.request_portal_users[1].permissions == () + assert config.request_portal_users[0].weekly_request_limit == 10 + assert config.request_portal_users[1].weekly_request_limit == 20 + text = path.read_text(encoding="utf-8") + assert "request_portal_users = [" in text + assert 'permissions = ["auto_approve"]' in text + assert "weekly_request_limit = 10" in text + assert "admin password" not in text + + duplicate = f''' +mam_id = "secret" +request_portal_users = [ + {{ username = "Reader", password_hash = "{reader_hash}" }}, + {{ username = "reader", password_hash = "{reader_hash}" }} +] +''' + path.write_text(duplicate, encoding="utf-8") + with pytest.raises(ConfigError, match="duplicate request portal account"): + load_config(path) + + unknown_permission = ( + 'mam_id = "secret"\n' + 'request_portal_users = [{ username = "reader", ' + f'password_hash = "{reader_hash}", ' + 'permissions = ["admin_everything"] }]\n' + ) + path.write_text(unknown_permission, encoding="utf-8") + with pytest.raises(ConfigError, match="unknown request portal permissions"): + load_config(path) + + +def test_enabled_modules_and_absidekick_auto_sync_defaults_and_save( + tmp_path: Path, +) -> None: + path = tmp_path / "config.toml" + path.write_text('mam_id = "secret"\n', encoding="utf-8") + + config = load_config(path) + assert config.enabled_modules == ("heavymlm", "absidekick", "mam_spender") + assert config.absidekick_auto_sync is True + + updated = save_root_config_values( + path, + { + "enabled_modules": ("mam_spender",), + "absidekick_auto_sync": False, + }, + ) + assert updated.enabled_modules == ("mam_spender",) + assert updated.absidekick_auto_sync is False + + reloaded = load_config(path) + assert reloaded.enabled_modules == ("mam_spender",) + assert reloaded.absidekick_auto_sync is False diff --git a/python/tests/test_downloader.py b/python/tests/test_downloader.py new file mode 100644 index 00000000..34b410b5 --- /dev/null +++ b/python/tests/test_downloader.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from mlm.config import Config +from mlm.database import ensure_database +from mlm.downloader import grab_selected_torrents +from mlm.mam import MamWedgeError +from mlm.repository import Repository + + +class FakeMam: + requested: list[tuple[int, bool]] + + def __init__(self) -> None: + self.requested = [] + self.wedged: list[int] = [] + + async def user_info(self) -> dict: + return { + "uploaded_bytes": 10_000, + "downloaded_bytes": 0, + "wedges": 2 - len(self.wedged), + "unsat": {"limit": 10, "count": 0}, + } + + async def get_torrent_file( + self, torrent_id: int, *, use_wedge: bool = False + ) -> bytes: + self.requested.append((torrent_id, use_wedge)) + if use_wedge: + self.wedged.append(torrent_id) + return b"d4:infod6:lengthi12e4:name4:bookee" + + async def get_torrent_info(self, _: str) -> dict: + return {"free": False} + + async def get_torrent_info_by_id(self, torrent_id: int) -> dict: + return {"personal_freeleech": torrent_id in self.wedged} + + +class RejectedWedgeMam(FakeMam): + async def get_torrent_file( + self, torrent_id: int, *, use_wedge: bool = False + ) -> bytes: + if not use_wedge: + return await super().get_torrent_file(torrent_id) + self.requested.append((torrent_id, True)) + raise MamWedgeError( + "MaM did not return a torrent file after the wedge request", + reason="download_rejected", + context={ + "stage": "wedge_download_response", + "http_status": 200, + "response_preview": "API session cannot use this request", + }, + ) + + +class FakeQbit: + added: list[bytes] + + def __init__(self) -> None: + self.added = [] + + async def torrents(self, *, hashes=()) -> list[dict]: + return [] + + async def add_torrent(self, torrent_file: bytes, **_: object) -> None: + self.added.append(torrent_file) + + +class StringFlagMam(FakeMam): + def __init__(self, flag: str) -> None: + super().__init__() + self.flag = flag + + async def get_torrent_info(self, _: str) -> dict: + return { + "free": self.flag, + "personal_freeleech": "0", + "fl_vip": "0", + "vip": "0", + } + + +def add_selected(repository: Repository, *, size: int = 12) -> None: + repository.add_selected( + { + "mam_id": 42, + "goodreads_id": None, + "hash": None, + "dl_link": "secret-hash", + "unsat_buffer": 0, + "wedge_buffer": None, + "cost": "Ratio", + "category": None, + "tags": [], + "title_search": "book", + "meta": {"mam_id": 42, "title": "Book", "size": size}, + "grabber": "test", + "created_at": "2025-01-01T00:00:00Z", + "started_at": None, + "removed_at": None, + } + ) + + +def test_download_job_moves_selected_record_into_library_catalog( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + selected = repository.pending_selected()[0] + repository.record_grab_error(selected, RuntimeError("temporary failure")) + assert len(repository.table_rows("errored_torrents")) == 1 + mam = FakeMam() + qbit = FakeQbit() + + result = asyncio.run( + grab_selected_torrents(Config(mam_id="cookie"), repository, mam, qbit) + ) + + assert result.downloaded == 1 + assert mam.requested == [(42, False)] + assert len(qbit.added) == 1 + assert repository.pending_selected() == [] + assert repository.selected_pipeline_status() == { + "awaiting": 0, + "downloading": 1, + "downloading_bytes": 12, + } + assert repository.table_rows("torrents")[0]["mam_id"] == 42 + assert repository.table_rows("errored_torrents") == [] + + +def test_download_job_reports_unsatisfied_slot_deferral(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = FakeMam() + + async def no_slots() -> dict: + user = await FakeMam.user_info(mam) + user["unsat"] = {"limit": 1, "count": 1} + return user + + mam.user_info = no_slots # type: ignore[method-assign] + + result = asyncio.run( + grab_selected_torrents(Config(mam_id="cookie"), repository, mam, FakeQbit()) + ) + + assert result.downloaded == 0 + assert result.skipped == 1 + assert result.skip_reasons == {"unsat_slots": 1} + assert result.available_slots == 0 + + +def test_download_job_reports_ratio_reserve_deferral(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository, size=6_000) + + result = asyncio.run( + grab_selected_torrents( + Config(mam_id="cookie", min_ratio=2), + repository, + FakeMam(), + FakeQbit(), + ) + ) + + assert result.downloaded == 0 + assert result.skipped == 1 + assert result.skip_reasons == {"ratio_buffer": 1} + assert result.ratio_buffer_bytes == 5_000 + + +def test_download_job_obeys_direct_slot_cap_before_requesting_file( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = FakeMam() + + async def at_cap() -> dict: + user = await FakeMam.user_info(mam) + user["unsat"] = {"limit": 150, "count": 140} + return user + + mam.user_info = at_cap # type: ignore[method-assign] + result = asyncio.run( + grab_selected_torrents( + Config(mam_id="cookie", unsat_buffer=0, max_unsat_slots=140), + repository, + mam, + FakeQbit(), + ) + ) + + assert result.downloaded == 0 + assert result.skipped == 1 + assert result.slot_cap == 140 + assert result.slots_used == 140 + assert mam.requested == [] + + +def test_wedge_first_spends_only_above_reserve_and_bypasses_ratio( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository, size=6_000) + mam = FakeMam() + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + min_ratio=2, + unsat_buffer=0, + prefer_wedges=True, + wedge_buffer=1, + ), + repository, + mam, + FakeQbit(), + ) + ) + + assert result.downloaded == 1 + assert result.skipped == 0 + assert result.wedges_remaining == 1 + assert result.wedge_buffer == 1 + assert mam.wedged == [42] + assert mam.requested == [(42, False), (42, True)] + + +def test_wedge_first_falls_back_to_ratio_at_reserve(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = FakeMam() + + async def at_reserve() -> dict: + user = await FakeMam.user_info(mam) + user["wedges"] = 1 + return user + + mam.user_info = at_reserve # type: ignore[method-assign] + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + unsat_buffer=0, + prefer_wedges=True, + wedge_buffer=1, + ), + repository, + mam, + FakeQbit(), + ) + ) + + assert result.downloaded == 1 + assert result.wedges_remaining == 1 + assert mam.wedged == [] + + +def test_wedge_first_treats_mam_string_zero_flags_as_false(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = StringFlagMam("0") + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + unsat_buffer=0, + prefer_wedges=True, + wedge_buffer=1, + ), + repository, + mam, + FakeQbit(), + ) + ) + + assert result.downloaded == 1 + assert result.wedges_remaining == 1 + assert mam.wedged == [42] + activity = repository.recent_activity(component="downloader") + assert any( + entry["message"] == "Applied freeleech wedge to MaM #42" for entry in activity + ) + + +def test_wedge_first_does_not_spend_on_string_one_freeleech(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = StringFlagMam("1") + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + unsat_buffer=0, + prefer_wedges=True, + wedge_buffer=1, + ), + repository, + mam, + FakeQbit(), + ) + ) + + assert result.downloaded == 1 + assert result.wedges_remaining == 2 + assert mam.wedged == [] + + +def test_prefer_wedges_does_not_silently_fall_back_after_rejection( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = RejectedWedgeMam() + qbit = FakeQbit() + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + unsat_buffer=0, + prefer_wedges=True, + wedge_buffer=1, + ), + repository, + mam, + qbit, + ) + ) + + assert result.downloaded == 0 + assert result.failed == 1 + assert qbit.added == [] + assert repository.pending_selected()[0]["mam_id"] == 42 + error = repository.table_rows("errored_torrents")[0] + assert error["context"]["wedge_attempted"] is True + assert error["context"]["wedges_before"] == 2 + assert error["context"]["wedge_buffer"] == 1 + assert error["context"]["http_status"] == 200 + assert error["context"]["response_preview"] == ( + "API session cannot use this request" + ) + activity = repository.recent_activity(component="downloader") + assert any( + entry["message"] == "Freeleech wedge failed for MaM #42" for entry in activity + ) + + +def test_wedge_failure_can_fall_back_to_ratio_when_enabled(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository) + mam = RejectedWedgeMam() + qbit = FakeQbit() + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + unsat_buffer=0, + prefer_wedges=True, + download_on_wedge_failure=True, + wedge_buffer=1, + ), + repository, + mam, + qbit, + ) + ) + + assert result.downloaded == 1 + assert result.failed == 0 + assert len(qbit.added) == 1 + assert repository.pending_selected() == [] + assert repository.table_rows("errored_torrents") == [] + activity = repository.recent_activity(component="downloader") + fallback = next( + entry for entry in activity if "downloading normally" in entry["message"] + ) + assert fallback["context"]["fallback_used"] is True + added = next( + entry + for entry in activity + if entry["message"] == "Added MaM #42 to qBittorrent" + ) + assert added["context"]["wedged"] is False + assert added["context"]["wedge_fallback_used"] is True + + +def test_wedge_failure_fallback_still_obeys_ratio_reserve(tmp_path: Path) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + add_selected(repository, size=6_000) + qbit = FakeQbit() + + result = asyncio.run( + grab_selected_torrents( + Config( + mam_id="cookie", + min_ratio=2, + unsat_buffer=0, + prefer_wedges=True, + download_on_wedge_failure=True, + wedge_buffer=1, + ), + repository, + RejectedWedgeMam(), + qbit, + ) + ) + + assert result.downloaded == 0 + assert result.failed == 0 + assert result.skipped == 1 + assert result.skip_reasons == {"ratio_buffer": 1} + assert qbit.added == [] + assert repository.pending_selected()[0]["mam_id"] == 42 diff --git a/python/tests/test_error_guidance.py b/python/tests/test_error_guidance.py new file mode 100644 index 00000000..d4228fa2 --- /dev/null +++ b/python/tests/test_error_guidance.py @@ -0,0 +1,16 @@ +from mlm.error_guidance import error_guidance + + +def test_error_guidance_classifies_known_services() -> None: + wedge = error_guidance("wedge reserve reached (3 available, 3 reserved)") + auth = error_guidance("Client error '403 Forbidden'") + qbit = error_guidance("qBittorrent rejected the request") + path = error_guidance("check the path_mapping configuration") + unknown = error_guidance("something novel exploded") + + assert wedge["title"] == "Freeleech wedge reserve reached" + assert auth["title"] == "MyAnonamouse session is not authorized" + assert qbit["title"] == "qBittorrent could not accept the release" + assert path["component"] == "organizer" + assert unknown["title"] == "Download processing failed" + assert len(unknown["steps"]) == 3 diff --git a/python/tests/test_library.py b/python/tests/test_library.py new file mode 100644 index 00000000..a9d7b4ab --- /dev/null +++ b/python/tests/test_library.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from mlm.library import library_directory, map_path, safe_torrent_path, select_format + + +def test_longest_path_mapping_wins() -> None: + mapped = map_path( + {"/downloads": "/books", "/downloads/audio": "/audiobooks"}, + "/downloads/audio/new", + ) + assert mapped == Path("/audiobooks/new") + + +def test_library_directory_series_and_narrator() -> None: + result = library_directory( + False, + {"library_dir": "/library"}, + { + "authors": ["An Author"], + "title": "The Book", + "series": [{"name": "Saga", "entries": ["2"]}], + "narrators": ["A Narrator"], + "edition": None, + }, + ) + assert result == Path("/library/An Author/Saga/Saga #2 - The Book {A Narrator}") + + +def test_format_preference_and_path_traversal() -> None: + files = [{"name": "book/book.mp3"}, {"name": "book/book.m4b"}] + assert select_format(None, ("m4b", "mp3"), files) == ".m4b" + with pytest.raises(ValueError): + safe_torrent_path("../escape.mp3") + with pytest.raises(ValueError): + safe_torrent_path("C:/escape.mp3") diff --git a/python/tests/test_lists.py b/python/tests/test_lists.py new file mode 100644 index 00000000..f5351fb9 --- /dev/null +++ b/python/tests/test_lists.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import httpx + +from mlm.config import Config +from mlm.database import ensure_database +from mlm.lists import goodreads_list_id, run_goodreads_import +from mlm.repository import Repository + + +def test_goodreads_list_id_legacy_shape() -> None: + assert ( + goodreads_list_id( + "https://www.goodreads.com/review/list_rss/12345?shelf=want-to-read" + ) + == "12345:want-to-read" + ) + + +class FakeFeedClient: + async def get(self, url: str) -> httpx.Response: + request = httpx.Request("GET", url) + return httpx.Response( + 200, + request=request, + content=b""" + Reading List + book-123123 + Example BookExample Author + 9780000000000 + + """, + ) + + +class FakeMam: + searches = 0 + + async def search(self, _: dict) -> dict: + self.searches += 1 + return { + "found": 1, + "data": [ + { + "id": 987, + "dl": "download-hash", + "mediatype": 2, + "main_cat": 14, + "language": 1, + "filetype": "epub", + "title": "Example Book", + "author_info": {"1": "Example Author"}, + "size": 1024, + } + ], + } + + +class BothFormatMam: + def __init__(self) -> None: + self.searches = 0 + + async def search(self, _: dict) -> dict: + self.searches += 1 + common = { + "dl": "download-hash", + "language": 1, + "title": "Example Book", + "author_info": {"1": "Example Author"}, + "size": 1024, + } + return { + "found": 2, + "data": [ + { + **common, + "id": 987, + "dl": "audio-hash", + "mediatype": 1, + "main_cat": 13, + "filetype": "m4b", + }, + { + **common, + "id": 988, + "dl": "ebook-hash", + "mediatype": 2, + "main_cat": 14, + "filetype": "epub", + }, + ], + } + + +def test_goodreads_refresh_remembers_previously_selected_items( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + mam = FakeMam() + definition = { + "url": "https://www.goodreads.com/review/list_rss/123?shelf=read", + "grab": [{"cost": "all"}], + } + + first = asyncio.run( + run_goodreads_import( + Config(mam_id="cookie"), + repository, + mam, + definition, + client=FakeFeedClient(), + ) + ) + second = asyncio.run( + run_goodreads_import( + Config(mam_id="cookie"), + repository, + mam, + definition, + client=FakeFeedClient(), + ) + ) + + assert first.selected == 1 + assert second.selected == 0 + assert second.already_grabbed == 1 + assert mam.searches == 1 + item = repository.table_rows("list_items")[0] + assert item["status"] == "already_grabbed" + assert item["selected_mam_ids"] == [987] + assert item["check_count"] == 1 + assert any( + entry["message"] == "Already grabbed: Example Book" + for entry in repository.recent_activity() + ) + + +def test_goodreads_can_track_and_select_audio_and_ebook_independently( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + mam = BothFormatMam() + definition = { + "url": "https://www.goodreads.com/review/list_rss/123?shelf=read", + "grab": [{"cost": "all"}], + } + config = Config(mam_id="cookie", grab_both_formats=True) + + first = asyncio.run( + run_goodreads_import( + config, + repository, + mam, + definition, + client=FakeFeedClient(), + ) + ) + second = asyncio.run( + run_goodreads_import( + config, + repository, + mam, + definition, + client=FakeFeedClient(), + ) + ) + + assert first.selected == 2 + assert second.selected == 0 + assert second.already_grabbed == 1 + assert mam.searches == 2 + item = repository.table_rows("list_items")[0] + assert item["selected_formats"] == ["audio", "ebook"] + assert item["selected_mam_ids"] == [987, 988] + assert item["audio_torrent"] == 987 + assert item["ebook_torrent"] == 988 + assert {row["mam_id"] for row in repository.pending_selected()} == {987, 988} diff --git a/python/tests/test_mam.py b/python/tests/test_mam.py new file mode 100644 index 00000000..b2501b7f --- /dev/null +++ b/python/tests/test_mam.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import asyncio + +import httpx +import pytest + +from mlm.mam import MamClient, MamWedgeError + +TORRENT = b"d4:infod6:lengthi12e4:name4:bookee" + + +def test_wedge_uses_documented_torrent_download_api() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + content=TORRENT, + headers={"content-type": "application/x-bittorrent"}, + ) + + async def run() -> bytes: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport(handler), + ) as http: + mam = MamClient("cookie", client=http) + return await mam.get_torrent_file(42, use_wedge=True) + + assert asyncio.run(run()) == TORRENT + assert len(requests) == 1 + request = requests[0] + assert request.method == "GET" + assert request.url.path == "/tor/download.php" + assert request.url.params["tid"] == "42" + assert "fl" in request.url.params + assert request.url.query == b"tid=42&fl" + + +def test_wedge_error_page_keeps_safe_http_diagnostics() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + text="API session cannot use this request", + headers={"content-type": "text/html"}, + ) + + async def run() -> None: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport(handler), + ) as http: + mam = MamClient("cookie", client=http) + with pytest.raises(MamWedgeError) as caught: + await mam.get_torrent_file(42, use_wedge=True) + assert caught.value.reason == "download_rejected" + assert caught.value.context["stage"] == "wedge_download_response" + assert caught.value.context["http_status"] == 200 + assert caught.value.context["content_type"] == "text/html" + assert caught.value.context["response_preview"] == ( + "API session cannot use this request" + ) + + asyncio.run(run()) + + +def test_wedge_http_failure_keeps_status_and_endpoint() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(403, text="Forbidden") + + async def run() -> None: + async with httpx.AsyncClient( + base_url="https://www.myanonamouse.net", + transport=httpx.MockTransport(handler), + ) as http: + mam = MamClient("cookie", client=http) + with pytest.raises(MamWedgeError) as caught: + await mam.get_torrent_file(42, use_wedge=True) + assert caught.value.reason == "http_error" + assert caught.value.context["http_status"] == 403 + assert caught.value.context["endpoint"].endswith("?tid=42&fl") + + asyncio.run(run()) diff --git a/python/tests/test_mam_spender.py b/python/tests/test_mam_spender.py new file mode 100644 index 00000000..b6ac6290 --- /dev/null +++ b/python/tests/test_mam_spender.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +from mlm.database import ensure_database +from mlm.modules.mam_spender import MamSpenderService, extract_mam_id +from mlm.repository import Repository + + +class FakeMam: + def __init__(self, points_after: int = 50_000) -> None: + self.points_after = points_after + self.requests: list[tuple[str, Any]] = [] + self.session_id = "" + + async def user_info(self) -> dict[str, Any]: + return { + "uid": "42", + "username": "HeavyHarlow", + "vip_until": "2030-01-01 00:00:00", + "ratio": "12.0", + } + + async def request_json(self, path: str, *, params: Any = None) -> Any: + self.requests.append((path, params)) + if path == "/jsonLoad.php": + return {"seedbonus": self.points_after} + return {"success": True} + + def set_mam_id(self, value: str) -> None: + self.session_id = value + + +def service(tmp_path: Path, mam: FakeMam) -> MamSpenderService: + database = tmp_path / "data.sqlite3" + ensure_database(database) + return MamSpenderService(Repository(database), mam) # type: ignore[arg-type] + + +def test_session_id_import_accepts_common_exports() -> None: + value = "a" * 32 + assert extract_mam_id(value) == value + assert extract_mam_id(f"Cookie: foo=1; mam_id={value}; theme=dark") == value + browser_export = json.dumps([{"name": "mam_id", "value": value}]) + assert extract_mam_id(browser_export) == value + assert extract_mam_id(f".myanonamouse.net TRUE / TRUE 0 mam_id {value}") == value + + +def test_wedge_purchase_uses_bonus_store_and_verifies_points( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + mam = FakeMam(points_after=50_000) + spender = service(tmp_path, mam) + + async def no_sleep(_: float) -> None: + return None + + monkeypatch.setattr("mlm.modules.mam_spender.service.asyncio.sleep", no_sleep) + after, purchased = asyncio.run(spender._maybe_buy_wedge("42", 100_000)) + + assert purchased is True + assert after == 50_000 + assert mam.requests[0] == ( + "/json/bonusBuy.php/", + { + "spendtype": "wedges", + "source": "points", + "_": mam.requests[0][1]["_"], + }, + ) + assert spender.public_state()["spend_events"][0]["category"] == ("freeleech_wedge") + + +def test_wedge_purchase_respects_points_buffer(tmp_path: Path) -> None: + mam = FakeMam() + spender = service(tmp_path, mam) + spender.settings.points_buffer = 25_000 + + after, purchased = asyncio.run(spender._maybe_buy_wedge("42", 74_999)) + + assert purchased is False + assert after == 74_999 + assert mam.requests == [] + + +def test_wedge_success_is_not_lost_to_points_earned_during_verification( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + mam = FakeMam(points_after=50_002) + spender = service(tmp_path, mam) + + async def no_sleep(_: float) -> None: + return None + + monkeypatch.setattr("mlm.modules.mam_spender.service.asyncio.sleep", no_sleep) + after, purchased = asyncio.run(spender._maybe_buy_wedge("42", 100_000)) + + assert purchased is True + assert after == 50_002 + event = spender.public_state()["spend_events"][0] + assert event["points_spent"] == 50_000 + + +def test_legacy_web_config_import_preserves_module_state(tmp_path: Path) -> None: + mam = FakeMam() + spender = service(tmp_path, mam) + state = spender.import_legacy( + { + "settings": { + "fl_only": True, + "points_buffer": 12_000, + "theme": "mouse", + }, + "totals": { + "cumulative_upload_gb": 350, + "cumulative_freeleech_wedges": 4, + }, + "history": [{"created_at": "2026-01-01T00:00:00Z", "result": "Old run"}], + "spend_events": [ + { + "created_at": "2026-01-01T00:00:00Z", + "category": "upload_credit", + "points_spent": 25_000, + } + ], + } + ) + + assert state["settings"]["fl_only"] is True + assert state["settings"]["buy_upload_credit"] is False + assert "theme" not in state["settings"] + assert state["totals"]["cumulative_upload_gb"] == 350 + assert state["history"][0]["result"] == "Old run" + assert state["spend_events"][0]["points_spent"] == 25_000 diff --git a/python/tests/test_migration.py b/python/tests/test_migration.py new file mode 100644 index 00000000..df6c9169 --- /dev/null +++ b/python/tests/test_migration.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +import pytest + +from mlm.migration import MigrationError, canonical_json, migrate + + +def sample_export() -> dict: + collections = { + "config": [{"key": "last-run", "value": "now"}], + "torrents": [ + { + "id": "abc", + "mam_id": 101, + "title_search": "a book", + "created_at": [1700000000, 0], + "meta": {"title": "A Book", "authors": ["Writer"]}, + } + ], + "selected_torrents": [ + { + "mam_id": 102, + "hash": None, + "title_search": "queued", + "created_at": [1700000001, 0], + "dl_link": "https://example.invalid/download?tid=102", + } + ], + "duplicate_torrents": [], + "errored_torrents": [ + { + "id": {"Grabber": 103}, + "created_at": [1700000002, 0], + "title": "Failed", + "error": "example", + "meta": None, + } + ], + "events": [ + { + "id": "event-id", + "torrent_id": "abc", + "mam_id": 101, + "created_at": [1700000003, 0], + "event": {"RemovedFromMam": None}, + } + ], + "lists": [{"id": "list-1", "title": "Reading"}], + "list_items": [ + { + "guid": ["list-1", "item-1"], + "list_id": "list-1", + "title": "Wanted", + "created_at": [1700000004, 0], + } + ], + } + return { + "format": "mlm-native-db-export", + "version": 1, + "counts": {key: len(value) for key, value in collections.items()}, + **collections, + } + + +def write_export(path: Path, document: dict) -> None: + path.write_text(json.dumps(document), encoding="utf-8") + + +def test_migration_preserves_payloads_and_counts(tmp_path: Path) -> None: + source = tmp_path / "data.db" + source.write_bytes(b"legacy database") + export_path = tmp_path / "export.json" + document = sample_export() + write_export(export_path, document) + destination = tmp_path / "data.sqlite3" + + result = migrate(source, destination, export_json=export_path) + + assert result.source_backup.read_bytes() == b"legacy database" + assert result.counts == document["counts"] + with sqlite3.connect(destination) as connection: + payload = connection.execute( + "SELECT payload_json FROM torrents WHERE id = 'abc'" + ).fetchone()[0] + assert payload == canonical_json(document["torrents"][0]) + assert connection.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + + +def test_count_mismatch_does_not_create_destination(tmp_path: Path) -> None: + source = tmp_path / "data.db" + source.write_bytes(b"legacy database") + export_path = tmp_path / "export.json" + document = sample_export() + document["counts"]["events"] = 99 + write_export(export_path, document) + destination = tmp_path / "data.sqlite3" + + with pytest.raises(MigrationError, match="export count mismatch"): + migrate(source, destination, export_json=export_path) + + assert not destination.exists() + + +def test_existing_destination_is_never_overwritten(tmp_path: Path) -> None: + source = tmp_path / "data.db" + source.write_bytes(b"legacy database") + destination = tmp_path / "data.sqlite3" + destination.write_bytes(b"keep me") + export_path = tmp_path / "export.json" + write_export(export_path, sample_export()) + + with pytest.raises(MigrationError, match="destination already exists"): + migrate(source, destination, export_json=export_path) + + assert destination.read_bytes() == b"keep me" diff --git a/python/tests/test_organizer.py b/python/tests/test_organizer.py new file mode 100644 index 00000000..14eb30aa --- /dev/null +++ b/python/tests/test_organizer.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +import pytest + +from mlm.config import Config, QbitConfig +from mlm.database import ensure_database +from mlm.library import organize_completed +from mlm.repository import Repository + + +class FakeQbit: + def __init__(self, save_path: Path) -> None: + self.save_path = save_path + self.requested_categories: list[str | None] = [] + + async def torrents(self, *, category: str | None = None) -> list[dict]: + self.requested_categories.append(category) + rows = [ + { + "hash": "abc123", + "name": "Book", + "progress": 1, + "save_path": str(self.save_path), + "category": "Audiobooks", + "tags": "", + } + ] + if category is not None: + return [row for row in rows if row["category"] == category] + return rows + + async def files(self, _: str) -> list[dict]: + return [{"name": "download/book.m4b"}] + + async def trackers(self, _: str) -> list[dict]: + return [] + + +class FakeMam: + async def get_torrent_info(self, _: str) -> dict: + return { + "id": 88, + "added": "2025-01-01 00:00:00", + "author_info": {"1": "An Author"}, + "narrator_info": {"1": "A Narrator"}, + "series_info": {}, + "browseflags": 0, + "main_cat": 13, + "category": 13, + "mediatype": 1, + "maincat": 1, + "categories": [], + "catname": "Audiobook", + "filetype": "m4b", + "language": 1, + "numfiles": 1, + "size": "12 B", + "title": "Book", + "vip": False, + } + + +class NestedJsonMam(FakeMam): + async def get_torrent_info(self, torrent_hash: str) -> dict: + row = await super().get_torrent_info(torrent_hash) + row["author_info"] = '{"1":"An Author"}' + row["narrator_info"] = '{"1":"A Narrator"}' + row["series_info"] = "{}" + row["categories"] = "[]" + return row + + +class MixedQbit(FakeQbit): + async def torrents(self, *, category: str | None = None) -> list[dict]: + self.requested_categories.append(category) + rows = [ + { + "hash": "missing123", + "name": "Missing Book", + "progress": 1, + "save_path": str(self.save_path), + "category": "Audiobooks", + "tags": "", + }, + { + "hash": "abc123", + "name": "Book", + "progress": 1, + "save_path": str(self.save_path), + "category": "Audiobooks", + "tags": "", + }, + ] + if category is not None: + return [row for row in rows if row["category"] == category] + return rows + + async def files(self, torrent_hash: str) -> list[dict]: + if torrent_hash == "missing123": + return [{"name": "missing/book.m4b"}] + return [{"name": "download/book.m4b"}] + + +class ScopedQbit(FakeQbit): + async def torrents(self, *, category: str | None = None) -> list[dict]: + self.requested_categories.append(category) + rows = [ + { + "hash": "audio123", + "name": "Audio", + "progress": 0.5, + "save_path": str(self.save_path), + "category": "Audiobooks", + "tags": "", + }, + { + "hash": "ebook123", + "name": "Ebook", + "progress": 0.5, + "save_path": str(self.save_path), + "category": "Ebooks", + "tags": "", + }, + { + "hash": "video123", + "name": "Video", + "progress": 1, + "save_path": "Z:\\Videos", + "category": "Movies", + "tags": "", + }, + ] + if category is not None: + return [row for row in rows if row["category"] == category] + return rows + + +def test_organizer_hardlinks_completed_torrent(tmp_path: Path) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"audio") + library_root = tmp_path / "library" + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(library_root), + "method": "hardlink", + }, + ), + ) + + progress: list[tuple[str, str, dict | None]] = [] + qbit = FakeQbit(downloads) + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + qbit, + FakeMam(), + progress=lambda message, level, context: progress.append( + (message, level, context) + ), + ) + ) + + destination = library_root / "An Author" / "Book {A Narrator}" / "book.m4b" + assert result.linked == 1 + assert result.scanned == 1 + assert qbit.requested_categories == ["Audiobooks"] + assert destination.read_bytes() == b"audio" + assert os.path.samefile(source, destination) + stored = repository.torrent("abc123") + assert stored is not None + assert stored["library_path"] == str(destination.parent) + assert any(message.startswith("Organizer scope: 1") for message, _, _ in progress) + assert any(message.startswith("Placing file 1/1") for message, _, _ in progress) + assert any(message.startswith("Organizer finished") for message, _, _ in progress) + assert any( + message.startswith("Progress 1/1 | 1 organized | 0 already existed") + for message, _, _ in progress + ) + + +def test_organizer_reports_already_existing_library_items(tmp_path: Path) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"audio") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(tmp_path / "library"), + "method": "copy", + }, + ), + ) + qbit = FakeQbit(downloads) + + first = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + qbit, + FakeMam(), + ) + ) + assert first.linked == 1 + + progress: list[tuple[str, str, dict | None]] = [] + second = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + qbit, + FakeMam(), + progress=lambda message, level, context: progress.append( + (message, level, context) + ), + ) + ) + + assert second.scanned == 1 + assert second.linked == 0 + assert second.already_existing == 1 + assert second.skipped == 0 + assert second.skip_reasons == {} + assert any( + message.startswith("Progress 1/1 | 0 organized | 1 already existed") + for message, _, _ in progress + ) + final_message, _, final_context = next( + row for row in progress if row[0].startswith("Organizer finished") + ) + assert "1 already existed" in final_message + assert final_context is not None + assert final_context["already_existing"] == 1 + + +def test_organizer_does_not_reprocess_beta_29_collection_records( + tmp_path: Path, +) -> None: + downloads = tmp_path / "downloads" + downloads.mkdir() + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + repository.record_linked( + { + "id": "abc123", + "mam_id": 88, + "title_search": "collection", + "created_at": "2026-08-10T00:00:00+00:00", + "library_path": None, + "library_files": [], + "collection_items": [ + {"title": "Book One", "library_path": "E:/Books/Book One"}, + {"title": "Book Two", "library_path": "E:/Books/Book Two"}, + ], + "meta": {"title": "Collection", "authors": ["An Author"]}, + }, + 88, + ) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(tmp_path / "library"), + "method": "copy", + }, + ), + ) + progress: list[tuple[str, str, dict | None]] = [] + + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + FakeQbit(downloads), + FakeMam(), + progress=lambda message, level, context: progress.append( + (message, level, context) + ), + ) + ) + + assert result.linked == 0 + assert result.already_existing == 1 + assert not (tmp_path / "library").exists() + assert any( + message.startswith("Already processed by beta 29") + and level == "warning" + and context is not None + and context["collection_books"] == 2 + for message, level, context in progress + ) + + +def test_organizer_continues_after_one_torrent_fails(tmp_path: Path) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"audio") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(tmp_path / "library"), + "method": "copy", + }, + ), + ) + progress: list[tuple[str, str, dict | None]] = [] + + qbit = MixedQbit(downloads) + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + qbit, + FakeMam(), + progress=lambda message, level, context: progress.append( + (message, level, context) + ), + ) + ) + + assert result.scanned == 2 + assert result.failed == 1 + assert result.linked == 1 + assert result.already_existing == 0 + assert result.skipped == 0 + assert qbit.requested_categories == ["Audiobooks"] + assert repository.torrent("abc123") is not None + assert any(message.startswith("Failed Missing Book") for message, _, _ in progress) + assert any( + message.startswith("Progress 1/2") and "1 errors" in message + for message, _, _ in progress + ) + failures = repository.table_rows("errored_torrents") + assert len(failures) == 1 + assert failures[0]["id"] == {"Organizer": "missing123"} + assert Path(failures[0]["context"]["source"]).parts[-2:] == ( + "missing", + "book.m4b", + ) + assert failures[0]["context"]["destination"].endswith("book.m4b") + assert failures[0]["context"]["method"] == "copy" + assert result.failures[0]["remediation"] + assert not list((tmp_path / "library").rglob("*.heavymlm-staging-*")) + + +def test_organizer_rolls_back_partial_copy_and_records_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"complete audio") + library_root = tmp_path / "library" + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(library_root), + "method": "copy", + }, + ), + ) + + def fail_after_partial_copy(_: Path, destination: Path) -> None: + destination.write_bytes(b"partial") + raise OSError("simulated disk write failure") + + monkeypatch.setattr("mlm.library.shutil.copy2", fail_after_partial_copy) + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + FakeQbit(downloads), + FakeMam(), + ) + ) + + target = library_root / "An Author" / "Book {A Narrator}" + assert result.failed == 1 + assert result.linked == 0 + assert not target.exists() + assert not list(library_root.rglob("*.heavymlm-staging-*")) + assert repository.torrent("abc123") is None + failure = repository.table_rows("errored_torrents")[0] + assert "simulated disk write failure" in failure["error"] + assert failure["context"]["source"] == str(source) + assert failure["context"]["destination"] == str(target / "book.m4b") + assert failure["context"]["method"] == "copy" + + +def test_organizer_replaces_empty_ghost_folder_and_clears_old_error( + tmp_path: Path, +) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"audio") + library_root = tmp_path / "library" + target = library_root / "An Author" / "Book {A Narrator}" + target.mkdir(parents=True) + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + repository.record_organizer_error( + "abc123", + "Book", + "old copy failure", + {"destination": str(target)}, + ) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(library_root), + "method": "copy", + }, + ), + ) + + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + FakeQbit(downloads), + FakeMam(), + ) + ) + + assert result.linked == 1 + assert (target / "book.m4b").read_bytes() == b"audio" + assert repository.table_rows("errored_torrents") == [] + + +def test_organizer_accepts_live_mam_nested_json_metadata(tmp_path: Path) -> None: + downloads = tmp_path / "downloads" + source = downloads / "download" / "book.m4b" + source.parent.mkdir(parents=True) + source.write_bytes(b"audio") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(tmp_path / "library"), + "method": "copy", + }, + ), + ) + + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + FakeQbit(downloads), + NestedJsonMam(), + ) + ) + + assert result.linked == 1 + assert result.skip_reasons.get("missing_author", 0) == 0 + + +def test_organizer_only_requests_configured_library_categories( + tmp_path: Path, +) -> None: + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + config = Config( + mam_id="cookie", + qbittorrent=(QbitConfig(url="http://qbit"),), + libraries=( + { + "category": "Audiobooks", + "library_dir": str(tmp_path / "audio"), + }, + { + "category": "Ebooks", + "library_dir": str(tmp_path / "ebooks"), + }, + ), + ) + qbit = ScopedQbit(tmp_path / "downloads") + + result = asyncio.run( + organize_completed( + config, + repository, + config.qbittorrent[0], + qbit, + FakeMam(), + ) + ) + + assert qbit.requested_categories == ["Audiobooks", "Ebooks"] + assert result.scanned == 2 + assert result.incomplete == 2 diff --git a/python/tests/test_qbittorrent.py b/python/tests/test_qbittorrent.py new file mode 100644 index 00000000..d905c540 --- /dev/null +++ b/python/tests/test_qbittorrent.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import asyncio + +import httpx + +from mlm.qbittorrent import QbitClient + + +def test_torrent_category_is_sent_to_qbittorrent() -> None: + requests: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=[]) + + async def run() -> None: + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient( + base_url="http://qbit", transport=transport + ) as http: + client = QbitClient("http://qbit", client=http) + await client.torrents(category="Audiobooks") + + asyncio.run(run()) + + assert len(requests) == 1 + assert requests[0].url.params["category"] == "Audiobooks" diff --git a/python/tests/test_request_auth.py b/python/tests/test_request_auth.py new file mode 100644 index 00000000..260e0c75 --- /dev/null +++ b/python/tests/test_request_auth.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import pytest + +from mlm.request_auth import hash_request_password, verify_request_password + + +def test_requester_passwords_are_salted_and_verified() -> None: + first = hash_request_password("correct horse") + second = hash_request_password("correct horse") + + assert first != second + assert "correct horse" not in first + assert verify_request_password("correct horse", first) is True + assert verify_request_password("wrong password", first) is False + assert verify_request_password("correct horse", "not-a-password-hash") is False + + +def test_requester_password_requires_eight_characters() -> None: + with pytest.raises(ValueError, match="at least 8"): + hash_request_password("short") diff --git a/python/tests/test_request_portal.py b/python/tests/test_request_portal.py new file mode 100644 index 00000000..d9cfa35d --- /dev/null +++ b/python/tests/test_request_portal.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +from pathlib import Path + +import httpx +import pytest +from fastapi.testclient import TestClient + +from mlm.config import load_config +from mlm.database import ensure_database +from mlm.repository import Repository +from mlm.request_auth import hash_request_password +from mlm.request_portal import ( + GoodreadsLookupError, + lookup_goodreads_book, + parse_goodreads_book, + validate_goodreads_url, +) +from mlm.web import create_app + + +class PortalMam: + def __init__(self) -> None: + self.queries: list[dict] = [] + self.row = { + "id": 321, + "title": "Dungeon Crawler Carl", + "author_info": '{"1":"Matt Dinniman"}', + "narrator_info": '{"2":"Jeff Hays"}', + "series_info": '{"3":["Dungeon Crawler Carl","1"]}', + "filetype": "m4b", + "size": "734.5 MiB", + "catname": "Audiobook", + "category": 1, + "mediatype": 1, + "main_cat": 13, + "language": 1, + "numfiles": 1, + "seeders": 12, + "leechers": 2, + "times_completed": 99, + "owner_name": "BookSeeder", + "added": "2026-08-05 12:00:00", + "personal_freeleech": 1, + "dl": "private-download-token", + } + + async def search(self, query: dict) -> dict: + self.queries.append(query) + if query.get("tor", {}).get("startNumber", 0): + return {"found": 1, "data": []} + return {"found": 1, "data": [self.row]} + + async def get_torrent_info_by_id(self, mam_id: int) -> dict | None: + return self.row if mam_id == self.row["id"] else None + + +class PortalServices: + def __init__(self, config) -> None: + self.config = config + self.jobs = {} + self.mam_stats = {} + self.mam = PortalMam() + self.triggered: list[str] = [] + + async def trigger(self, name: str) -> None: + self.triggered.append(name) + + async def reconfigure(self, config) -> None: + self.config = config + + +def portal_config( + path: Path, + *, + access_code: str = "", + username: str = "", + password: str = "", + users: tuple[dict[str, object], ...] = (), + require_account_login: bool = False, + domain: str = "requests.example.test", +) -> None: + password_hash = hash_request_password(password) if password else "" + user_rows = [] + for user in users: + permissions = user.get("permissions", ()) + permission_values = ", ".join(json.dumps(item) for item in permissions) + user_password_hash = hash_request_password(str(user["password"])) + user_rows.append( + "{ " + f"username = {json.dumps(user['username'])}, " + f"password_hash = {json.dumps(user_password_hash)}, " + f"display_name = {json.dumps(user.get('display_name', ''))}, " + f"permissions = [{permission_values}], " + f"weekly_request_limit = {int(user.get('weekly_request_limit', 0))}" + " }" + ) + users_toml = "[" + ", ".join(user_rows) + "]" + path.write_text( + f""" +mam_id = "session" +request_portal_enabled = true +request_portal_require_account_login = {str(require_account_login).lower()} +request_portal_domains = [{json.dumps(domain)}] +request_portal_title = "Randy's Library Requests" +request_portal_access_code = {json.dumps(access_code)} +request_portal_username = {json.dumps(username)} +request_portal_password_hash = {json.dumps(password_hash)} +request_portal_rate_limit = 20 +request_portal_users = {users_toml} +audio_types = ["m4b", "mp3"] +""", + encoding="utf-8", + ) + + +def test_goodreads_next_data_parser_reads_book_author_and_series() -> None: + state = { + "Book:page": { + "legacyId": 54659324, + "title": "Dungeon Crawler Carl", + "titleComplete": "Dungeon Crawler Carl (Dungeon Crawler Carl, #1)", + "primaryContributorEdge": {"node": {"__ref": "Contributor:author"}}, + "bookSeries": [{"userPosition": "1", "series": {"__ref": "Series:dcc"}}], + "details": { + "isbn13": "9780593820247", + "format": "Hardcover", + "language": {"name": "English"}, + }, + "imageUrl": "https://images.example.test/dcc.jpg", + }, + "Contributor:author": {"name": "Matt Dinniman"}, + "Series:dcc": {"title": "Dungeon Crawler Carl"}, + } + payload = {"props": {"pageProps": {"apolloState": state}}} + document = ( + '" + ) + + book = parse_goodreads_book( + document, + "https://www.goodreads.com/book/show/54659324-dungeon-crawler-carl", + ) + + assert book["goodreads_id"] == 54659324 + assert book["title"] == "Dungeon Crawler Carl" + assert book["authors"] == ["Matt Dinniman"] + assert book["series"] == "Dungeon Crawler Carl" + assert book["series_position"] == "1" + assert book["language"] == "English" + assert book["isbn"] == "9780593820247" + + +def test_goodreads_reader_rejects_non_goodreads_urls_and_redirects() -> None: + with pytest.raises(GoodreadsLookupError, match="full https"): + validate_goodreads_url("https://example.com/book/show/123") + + async def exercise() -> None: + async with httpx.AsyncClient( + transport=httpx.MockTransport( + lambda _: httpx.Response( + 302, + headers={"location": "https://example.com/private"}, + ) + ) + ) as client: + with pytest.raises(GoodreadsLookupError, match="full https"): + await lookup_goodreads_book( + "https://www.goodreads.com/book/show/123-example", + client=client, + ) + + asyncio.run(exercise()) + + +def test_custom_domain_is_request_only_and_approval_queues_release( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + portal_config(config_path) + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + app = create_app(config_path, database) + services = PortalServices(load_config(config_path)) + app.state.services = services + client = TestClient(app) + portal_headers = {"host": "requests.example.test"} + + portal = client.get( + "/", + params={ + "author": "Matt Dinniman", + "series": "Dungeon Crawler Carl", + "filetype": "m4b", + }, + headers=portal_headers, + ) + + assert portal.status_code == 200 + assert "Randy's Library Requests" in portal.text + assert "Goodreads link reader" in portal.text + assert "Dungeon Crawler Carl" in portal.text + assert "Send request for approval" in portal.text + stylesheet = client.get("/static/app.css", headers=portal_headers) + assert stylesheet.status_code == 200 + assert ".request-portal-body" in stylesheet.text + assert client.get("/config", headers=portal_headers).status_code == 404 + assert client.get("/requests", headers=portal_headers).status_code == 404 + assert client.get("/").text.find("Your library, at a glance") != -1 + + submitted = client.post( + "/request/submit", + data={ + "mam_id": "321", + "requester_name": "Reader", + "requester_contact": "reader@example.test", + "note": "M4B please", + "goodreads_url": "", + "website": "", + }, + headers=portal_headers, + follow_redirects=False, + ) + + assert submitted.status_code == 303 + assert submitted.headers["location"] == "/?submitted=pending" + requests = repository.request_rows(status="pending") + assert len(requests) == 1 + assert requests[0]["release"]["filetypes"] == ["m4b"] + assert repository.pending_selected() == [] + + inbox = client.get("/requests") + assert inbox.status_code == 200 + assert "Reader" in inbox.text + assert "M4B please" in inbox.text + assert "Open request portal" in inbox.text + approved = client.post( + "/requests/approve", + data={"request_id": requests[0]["id"]}, + follow_redirects=False, + ) + assert approved.status_code == 303 + assert repository.request_record(requests[0]["id"])["status"] == "approved" + assert repository.pending_selected()[0]["mam_id"] == 321 + + +def test_goodreads_link_prefills_filters_and_searches_mam( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_lookup(_: str) -> dict: + return { + "goodreads_id": 54659324, + "title": "Dungeon Crawler Carl", + "authors": ["Matt Dinniman"], + "series": "Dungeon Crawler Carl", + "series_position": "1", + "language": "English", + "isbn": "", + "cover_url": "", + "format": "Audiobook", + "url": "https://www.goodreads.com/book/show/54659324", + } + + monkeypatch.setattr("mlm.web.lookup_goodreads_book", fake_lookup) + config_path = tmp_path / "config.toml" + portal_config(config_path) + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + services = PortalServices(load_config(config_path)) + app.state.services = services + client = TestClient(app) + + response = client.get( + "/", + params={ + "goodreads_url": "https://www.goodreads.com/book/show/54659324", + "filetype": "m4b", + }, + headers={"host": "requests.example.test"}, + ) + + assert response.status_code == 200 + assert "Goodreads metadata loaded" in response.text + assert 'name="title" value="Dungeon Crawler Carl"' in response.text + assert 'name="author" value="Matt Dinniman"' in response.text + assert 'name="series" value="Dungeon Crawler Carl"' in response.text + assert services.mam.queries[0]["tor"]["text"] == "Matt Dinniman" + assert services.mam.queries[0]["tor"]["srchIn"] == ["author"] + + +def test_remote_request_portal_uses_shared_access_cookie(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + portal_config(config_path, access_code="family-only") + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + app.state.services = PortalServices(load_config(config_path)) + + async def exercise() -> None: + transport = httpx.ASGITransport( + app=app, + client=("203.0.113.20", 41234), + ) + async with httpx.AsyncClient( + transport=transport, + base_url="https://requests.example.test", + ) as client: + locked = await client.get("/") + assert locked.status_code == 200 + assert "private request portal" in locked.text + wrong = await client.post("/request/unlock", data={"access_code": "wrong"}) + assert wrong.status_code == 403 + unlocked = await client.post( + "/request/unlock", + data={"access_code": "family-only"}, + follow_redirects=False, + ) + assert unlocked.status_code == 303 + assert "mysuite_request_access=" in unlocked.headers["set-cookie"] + portal = await client.get("/", params={"q": "Dungeon"}) + assert "Goodreads link reader" in portal.text + + asyncio.run(exercise()) + + +def test_remote_request_portal_uses_username_and_password(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + portal_config( + config_path, + username="family", + password="correct horse", + ) + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + app.state.services = PortalServices(load_config(config_path)) + + async def exercise() -> None: + transport = httpx.ASGITransport( + app=app, + client=("203.0.113.20", 41234), + ) + async with httpx.AsyncClient( + transport=transport, + base_url="https://requests.example.test", + ) as client: + locked = await client.get("/") + assert locked.status_code == 200 + assert 'name="username"' in locked.text + assert 'name="password"' in locked.text + assert 'name="access_code"' not in locked.text + + wrong = await client.post( + "/request/unlock", + data={"username": "family", "password": "wrong password"}, + ) + assert wrong.status_code == 403 + assert "not valid" in wrong.text + + unlocked = await client.post( + "/request/unlock", + data={"username": "family", "password": "correct horse"}, + follow_redirects=False, + ) + assert unlocked.status_code == 303 + assert "mysuite_request_access=" in unlocked.headers["set-cookie"] + + portal = await client.get("/", params={"q": "Dungeon"}) + assert "Goodreads link reader" in portal.text + assert 'name="requester_name"' in portal.text + assert 'value="family"' in portal.text + assert "Sign out" in portal.text + + logged_out = await client.post("/request/logout", follow_redirects=False) + assert logged_out.status_code == 303 + locked_again = await client.get("/") + assert 'name="password"' in locked_again.text + + asyncio.run(exercise()) + + +def test_named_request_accounts_apply_auto_approval_permission( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + portal_config( + config_path, + require_account_login=True, + users=( + { + "username": "admin", + "password": "admin password", + "display_name": "Library Admin", + "permissions": ("auto_approve",), + "weekly_request_limit": 10, + }, + { + "username": "reader", + "password": "reader password", + "display_name": "Regular Reader", + "permissions": (), + "weekly_request_limit": 1, + }, + ), + ) + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + app = create_app(config_path, database) + services = PortalServices(load_config(config_path)) + app.state.services = services + local_preview = TestClient(app).get("/request") + assert 'name="username"' in local_preview.text + assert "Goodreads link reader" not in local_preview.text + + async def exercise() -> None: + transport = httpx.ASGITransport( + app=app, + client=("203.0.113.20", 41234), + ) + async with httpx.AsyncClient( + transport=transport, + base_url="https://requests.example.test", + ) as client: + locked = await client.get("/") + assert 'name="username"' in locked.text + assert "Goodreads link reader" not in locked.text + admin_login = await client.post( + "/request/unlock", + data={"username": "admin", "password": "admin password"}, + follow_redirects=False, + ) + assert admin_login.status_code == 303 + admin_portal = await client.get("/", params={"q": "Dungeon"}) + assert "Library Admin · auto-approval enabled" in admin_portal.text + assert "Request and schedule automatically" in admin_portal.text + assert "10 requests left" in admin_portal.text + assert "0 of 10 used" in admin_portal.text + + auto_submitted = await client.post( + "/request/submit", + data={ + "mam_id": "321", + "requester_name": "Library Admin", + "requester_contact": "", + "note": "Trusted request", + "goodreads_url": "", + "website": "", + }, + follow_redirects=False, + ) + assert auto_submitted.status_code == 303 + assert auto_submitted.headers["location"] == "/?submitted=approved" + await asyncio.sleep(0) + + auto_record = repository.request_rows()[0] + assert auto_record["status"] == "approved" + assert auto_record["requester_username"] == "admin" + assert auto_record["requester_permissions"] == ["auto_approve"] + assert auto_record["requester_weekly_limit"] == 10 + assert auto_record["decision_by"] == "admin" + assert "Automatically approved" in auto_record["decision_note"] + assert repository.pending_selected()[0]["mam_id"] == 321 + assert services.triggered == ["downloader"] + + await client.post("/request/logout") + reader_login = await client.post( + "/request/unlock", + data={"username": "reader", "password": "reader password"}, + follow_redirects=False, + ) + assert reader_login.status_code == 303 + services.mam.row = { + **services.mam.row, + "id": 322, + "title": "The Eye of the Bedlam Bride", + } + reader_portal = await client.get("/", params={"q": "Bedlam"}) + assert "Regular Reader · auto-approval enabled" not in reader_portal.text + assert "Send request for approval" in reader_portal.text + assert "1 request left" in reader_portal.text + + pending_submitted = await client.post( + "/request/submit", + data={ + "mam_id": "322", + "requester_name": "Regular Reader", + "requester_contact": "", + "note": "Please approve", + "goodreads_url": "", + "website": "", + }, + follow_redirects=False, + ) + assert pending_submitted.headers["location"] == "/?submitted=pending" + + pending_record = repository.request_rows(status="pending")[0] + assert pending_record["requester_username"] == "reader" + assert pending_record["requester_permissions"] == [] + assert pending_record["requester_weekly_limit"] == 1 + assert repository.has_pending_mam_id(322) is False + + quota_page = await client.get("/") + assert "0 requests left" in quota_page.text + assert "1 of 1 used" in quota_page.text + + services.mam.row = { + **services.mam.row, + "id": 323, + "title": "The Butcher's Masquerade", + } + blocked = await client.post( + "/request/submit", + data={ + "mam_id": "323", + "requester_name": "Regular Reader", + "requester_contact": "", + "note": "Over the limit", + "goodreads_url": "", + "website": "", + }, + follow_redirects=False, + ) + assert blocked.status_code == 303 + assert ( + "request_error=Weekly+request+limit+reached" + in blocked.headers["location"] + ) + blocked_page = await client.get(blocked.headers["location"]) + assert "has used all 1 requests" in blocked_page.text + assert len(repository.request_rows()) == 2 + assert repository.has_pending_mam_id(323) is False + + asyncio.run(exercise()) + + inbox = TestClient(app).get("/requests?status=all") + assert "Account: admin · trusted for auto-approval" in inbox.text + assert "Decision by admin" in inbox.text + assert "Account: reader" in inbox.text + + +def test_account_only_portal_fails_closed_and_rejects_shared_cookie( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + portal_config( + config_path, + access_code="old-family-code", + require_account_login=True, + users=( + { + "username": "reader", + "password": "reader password", + "display_name": "Tracked Reader", + }, + ), + ) + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + app = create_app(config_path, database) + app.state.services = PortalServices(load_config(config_path)) + legacy_cookie = hmac.new( + b"session", + b"old-family-code", + hashlib.sha256, + ).hexdigest() + + async def exercise() -> None: + transport = httpx.ASGITransport( + app=app, + client=("203.0.113.20", 41234), + ) + async with httpx.AsyncClient( + transport=transport, + base_url="https://requests.example.test", + cookies={"mysuite_request_access": legacy_cookie}, + ) as client: + locked = await client.get("/") + assert 'name="username"' in locked.text + assert "Goodreads link reader" not in locked.text + rejected = await client.post( + "/request/submit", + data={"mam_id": "321"}, + ) + assert rejected.status_code == 403 + assert repository.request_rows() == [] + + logged_in = await client.post( + "/request/unlock", + data={"username": "reader", "password": "reader password"}, + follow_redirects=False, + ) + assert logged_in.status_code == 303 + submitted = await client.post( + "/request/submit", + data={"mam_id": "321"}, + follow_redirects=False, + ) + assert submitted.status_code == 303 + assert repository.request_rows()[0]["requester_username"] == "reader" + + asyncio.run(exercise()) + + +def test_account_only_portal_needs_an_account_and_honors_forwarded_host( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + portal_config( + config_path, + require_account_login=True, + domain="bookrequest.randyplungis.com", + ) + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + app.state.services = PortalServices(load_config(config_path)) + + async def exercise() -> None: + transport = httpx.ASGITransport(app=app, client=("172.20.0.4", 41234)) + async with httpx.AsyncClient( + transport=transport, + base_url="http://mlm:3157", + headers={ + "x-forwarded-host": "bookrequest.randyplungis.com", + "x-forwarded-proto": "https", + }, + ) as client: + locked = await client.get("/") + assert locked.status_code == 503 + assert "Account setup required" in locked.text + assert 'name="access_code"' not in locked.text + assert (await client.get("/config")).status_code == 404 + + asyncio.run(exercise()) + + +def test_loopback_reverse_proxy_does_not_bypass_shared_access_code( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + portal_config(config_path, access_code="family-only") + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + app.state.services = PortalServices(load_config(config_path)) + + async def exercise() -> None: + transport = httpx.ASGITransport(app=app, client=("127.0.0.1", 41234)) + async with httpx.AsyncClient( + transport=transport, + base_url="http://requests.example.test", + headers={ + "x-forwarded-for": "203.0.113.20", + "x-forwarded-proto": "https", + }, + ) as client: + locked = await client.get("/") + assert locked.status_code == 200 + assert "private request portal" in locked.text + assert "Goodreads link reader" not in locked.text + assert 'href="/static/app.css?v=0.5.0b65"' in locked.text + assert "http://requests.example.test/static/" not in locked.text + + asyncio.run(exercise()) diff --git a/python/tests/test_search.py b/python/tests/test_search.py new file mode 100644 index 00000000..7c3c9157 --- /dev/null +++ b/python/tests/test_search.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from mlm.modules.heavymlm.search import ( + filter_search_results, + present_search_result, + search_seed, +) +from mlm.search import ( + build_search_query, + matches_filter, + metadata_matches, + normalize_title, + parse_size, + torrent_meta, +) + + +def test_search_payload_matches_mam_shape() -> None: + query = build_search_query( + { + "type": "bookmarks", + "cost": "free", + "query": "writer", + "search_in": ["author"], + } + ) + assert query["dlLink"] is True + assert "fields" not in query + assert query["tor"]["searchIn"] == "bookmarks" + assert query["tor"]["searchType"] == "fl-VIP" + assert query["tor"]["srchIn"] == ["author"] + + +def test_filter_size_flags_dates_and_peers() -> None: + row = { + "size": "100 MiB", + "browseflags": 1 << 5, + "added": "2025-07-06 05:40:54", + "seeders": 8, + "owner_name": "someone", + "language": 1, + } + assert matches_filter( + row, + { + "min_size": "90 MiB", + "max_size": "110 MiB", + "flags": {"abridged": True, "explicit": False}, + "uploaded_after": "2025-07-06", + "uploaded_before": "2025-07-06", + "min_seeders": 8, + "languages": ["English"], + }, + ) + assert parse_size("1.5 GiB") == int(1.5 * (1 << 30)) + assert parse_size("1,018.3 KiB") == int(1018.3 * (1 << 10)) + + +def test_normalize_title_matches_legacy_intent() -> None: + assert normalize_title("The Café & Book: Vol. 2") == "cafe and book vol 2" + + +def test_metadata_matching_requires_author_and_narrator_compatibility() -> None: + base = { + "media_type": "Audiobook", + "language": "English", + "edition": None, + "authors": ["Writer"], + "narrators": ["Narrator"], + } + assert metadata_matches(base, {**base, "media_type": "audiobook"}) + assert not metadata_matches(base, {**base, "authors": ["Someone Else"]}) + assert not metadata_matches(base, {**base, "narrators": []}) + + +def test_metadata_matching_checks_series_and_volume_entries() -> None: + book1 = { + "media_type": "Audiobook", + "language": "English", + "edition": None, + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["1"]}], + } + book2 = { + "media_type": "Audiobook", + "language": "English", + "edition": None, + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["2"]}], + } + book1_same_volume = { + "media_type": "Audiobook", + "language": "English", + "edition": None, + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [{"name": "My Eldritch Horror", "entries": ["1"]}], + } + standalone = { + "media_type": "Audiobook", + "language": "English", + "edition": None, + "authors": ["Author"], + "narrators": ["Narrator"], + "series": [], + } + assert metadata_matches(book1, book1_same_volume) + assert not metadata_matches(book1, book2) + assert not metadata_matches(book1, standalone) + + +def test_torrent_meta_decodes_mam_nested_json_fields() -> None: + meta = torrent_meta( + { + "id": "123", + "added": "2026-07-29 12:00:00", + "author_info": '{"42":"Julian Barnes"}', + "narrator_info": '{"7":"A Narrator & Reader"}', + "series_info": '{"9":["Example Series","2"]}', + "categories": "[4, 6]", + "browseflags": "0", + "main_cat": "14", + "category": "39", + "mediatype": "2", + "maincat": "2", + "catname": "Ebook", + "filetype": "epub", + "language": "1", + "numfiles": "1", + "size": "1,018.3 KiB", + "title": "Departure(s)", + "vip": "0", + } + ) + + assert meta["authors"] == ["Julian Barnes"] + assert meta["narrators"] == ["A Narrator & Reader"] + assert meta["series"] == [{"name": "Example Series", "entries": ["2"]}] + assert meta["categories"] == [4, 6] + + +def test_heavymlm_search_card_decodes_metadata_and_availability() -> None: + result = present_search_result( + { + "id": "123", + "title": "Departure(s) & Other Stories", + "author_info": '{"42":"Julian Barnes"}', + "narrator_info": '{"7":"A Narrator"}', + "series_info": '{"9":["Example Series","2"]}', + "filetype": "m4b mp3", + "size": "1,018.3 KiB", + "catname": "Audiobook", + "language": "1", + "numfiles": "2", + "seeders": "9", + "leechers": "1", + "times_completed": "27", + "owner_name": "Uploader", + "added": "2026-08-05 12:00:00", + "free": "1", + "fl_vip": "1", + "vip": "0", + } + ) + + assert result["title"] == "Departure(s) & Other Stories" + assert result["authors"] == ["Julian Barnes"] + assert result["narrators"] == ["A Narrator"] + assert result["series"] == [{"name": "Example Series", "entries": ["2"]}] + assert result["filetypes"] == ["m4b", "mp3"] + assert result["size"] == "1018.3 KiB" + assert result["language"] == "English" + assert result["availability"] == [ + {"label": "Global freeleech", "tone": "free"}, + {"label": "VIP freeleech", "tone": "vip"}, + ] + + +def test_heavymlm_search_card_falls_back_for_unknown_values() -> None: + result = present_search_result( + {"id": 1, "title": "Book", "size": "unknown", "free": "0"} + ) + + assert result["size"] == "unknown" + assert result["availability"] == [{"label": "Ratio download", "tone": "ratio"}] + + +def test_heavymlm_manual_filters_use_and_policy() -> None: + raw_rows = [ + { + "id": 1, + "title": "Dungeon Crawler Carl", + "author_info": {"1": "Matt Dinniman"}, + "series_info": {"1": ["Dungeon Crawler Carl", "1"]}, + "filetype": "m4b", + "catname": "Audiobook", + "language": 1, + "size": "500 MiB", + "seeders": 10, + }, + { + "id": 2, + "title": "Carl's Doomsday Scenario", + "author_info": {"1": "Matt Dinniman"}, + "series_info": {"1": ["Dungeon Crawler Carl", "2"]}, + "filetype": "mp3", + "catname": "Audiobook", + "language": 1, + "size": "600 MiB", + "seeders": 12, + }, + ] + filters = { + "author": "Matt Dinniman", + "series": "Dungeon Crawler Carl", + "filetype": "m4b", + "category": "audiobook", + "language": "English", + "min_seeders": 5, + "sort": "series", + } + + seed, fields = search_seed(filters) + results = filter_search_results( + [present_search_result(row) for row in raw_rows], filters + ) + + assert seed == "Matt Dinniman" + assert fields == ["author"] + assert [row["id"] for row in results] == [1] diff --git a/python/tests/test_series.py b/python/tests/test_series.py new file mode 100644 index 00000000..f7522925 --- /dev/null +++ b/python/tests/test_series.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +from mlm.config import Config +from mlm.database import ensure_database +from mlm.mam import MamClient +from mlm.modules.heavymlm.series import ( + detect_library_series, + detect_series_gaps, + extract_series_volume, + group_series_books, + parse_entry_number, + resolve_series_selection, + score_series_candidate, +) +from mlm.repository import Repository +from mlm.search import normalize_title + + +def test_parse_entry_number(): + assert parse_entry_number("1") == (0, 1.0, "1") + assert parse_entry_number("2.5") == (0, 2.5, "2.5") + assert parse_entry_number("#3") == (0, 3.0, "#3") + assert parse_entry_number("Book 4") == (0, 4.0, "book 4") + assert parse_entry_number("Volume 10") == (0, 10.0, "volume 10") + assert parse_entry_number("Special Prequel") == (1, 999999.0, "special prequel") + + entries = ["10", "1", "2.5", "Book 2", "Prequel"] + sorted_entries = sorted(entries, key=parse_entry_number) + assert sorted_entries == ["1", "Book 2", "2.5", "10", "Prequel"] + + +def test_extract_series_volume(): + row_with_series = { + "title": "The Eye of the Bedlam Bride", + "series": [{"name": "Dungeon Crawler Carl", "entries": ["5"]}], + } + assert extract_series_volume(row_with_series, "Dungeon Crawler Carl") == "5" + + row_with_title_only = { + "title": "Dungeon Crawler Carl Book 3", + "series": [], + } + assert extract_series_volume(row_with_title_only, "Dungeon Crawler Carl") == "3" + + +def test_score_series_candidate(): + config = Config( + mam_id="secret", + audio_types=("m4b", "mp3"), + ebook_types=("epub", "pdf"), + ) + + # M4B Freeleech with 20 seeders vs MP3 Ratio with 5 seeders + m4b_free = { + "media_type": "audiobook", + "filetypes": ["m4b"], + "free": 1, + "seeders": 20, + } + mp3_ratio = { + "media_type": "audiobook", + "filetypes": ["mp3"], + "free": 0, + "seeders": 5, + } + + score_m4b = score_series_candidate(m4b_free, config) + score_mp3 = score_series_candidate(mp3_ratio, config) + assert score_m4b > score_mp3 + + +def test_group_series_books(): + config = Config(mam_id="secret", audio_types=("m4b", "mp3")) + + raw_releases = [ + { + "id": 101, + "title": "Dungeon Crawler Carl", + "filetype": "mp3", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "1"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 10, + "free": 0, + }, + { + "id": 102, + "title": "Dungeon Crawler Carl (M4B)", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "1"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 25, + "free": 1, + }, + { + "id": 201, + "title": "Carl's Doomsday Scenario", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "2"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 15, + "free": 0, + }, + { + "id": 301, + "title": "The Dungeon Anarchist's Cookbook", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "3"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 30, + "free": 1, + }, + ] + + books = group_series_books(raw_releases, "Dungeon Crawler Carl", config) + assert len(books) == 3 + assert [b["volume"] for b in books] == ["1", "2", "3"] + + # Book 1 should pick M4B freeleech (ID 102) over MP3 release (ID 101) + assert books[0]["mam_id"] == 102 + assert books[0]["candidates_count"] == 2 + assert books[1]["mam_id"] == 201 + assert books[2]["mam_id"] == 301 + + +def test_resolve_series_selection(tmp_path: Path): + database = tmp_path / "data.sqlite3" + ensure_database(database) + repo = Repository(database) + + config = Config(mam_id="secret", audio_types=("m4b", "mp3")) + + raw_releases = [ + { + "id": 101, + "title": "Dungeon Crawler Carl", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "1"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 20, + "free": 1, + }, + { + "id": 201, + "title": "Carl's Doomsday Scenario", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "2"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 15, + "free": 0, + }, + ] + + # Pre-populate Book 1 as already in library + repo.add_selected( + { + "mam_id": 101, + "title_search": "dungeon crawler carl", + "meta": { + "title": "Dungeon Crawler Carl", + "media_type": "audiobook", + "authors": ["Matt Dinniman"], + "series": [{"name": "Dungeon Crawler Carl", "entries": ["1"]}], + }, + "started_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + } + ) + + class MockMam(MamClient): + def __init__(self): + pass + + async def search(self, query: dict): + return {"data": raw_releases, "found": len(raw_releases)} + + mam = MockMam() + + resolution = asyncio.run( + resolve_series_selection(config, repo, mam, "Dungeon Crawler Carl") + ) + + assert resolution["series_name"] == "Dungeon Crawler Carl" + assert resolution["total_books"] == 2 + assert len(resolution["already_present"]) == 1 + assert resolution["already_present"][0]["mam_id"] == 101 + assert len(resolution["missing_books"]) == 1 + assert resolution["missing_books"][0]["mam_id"] == 201 + + +def test_detect_series_gaps(): + assert detect_series_gaps(["1", "2", "3"]) == [] + assert detect_series_gaps(["1", "2", "4"]) == ["#3"] + assert detect_series_gaps(["1", "4", "5"]) == ["#2", "#3"] + assert detect_series_gaps(["2", "3", "4"]) == ["#1"] + assert detect_series_gaps(["Special Prequel"]) == [] + + +def test_detect_library_series(tmp_path: Path): + database = tmp_path / "data.sqlite3" + ensure_database(database) + repo = Repository(database) + + # Add books for Dungeon Crawler Carl (Books 1 & 3 - missing Book 2) + repo.add_selected( + { + "mam_id": 101, + "title_search": "dungeon crawler carl", + "meta": { + "title": "Dungeon Crawler Carl", + "media_type": "audiobook", + "authors": ["Matt Dinniman"], + "series": [{"name": "Dungeon Crawler Carl", "entries": ["1"]}], + }, + "started_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + } + ) + repo.add_selected( + { + "mam_id": 103, + "title_search": "the dungeon anarchists cookbook", + "meta": { + "title": "The Dungeon Anarchist's Cookbook", + "media_type": "audiobook", + "authors": ["Matt Dinniman"], + "series": [{"name": "Dungeon Crawler Carl", "entries": ["3"]}], + }, + "started_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + } + ) + + # Add book for Cradle (Book 1) + repo.add_selected( + { + "mam_id": 301, + "title_search": "unsouled", + "meta": { + "title": "Unsouled", + "media_type": "audiobook", + "authors": ["Will Wight"], + "series": [{"name": "Cradle", "entries": ["1"]}], + }, + "started_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + } + ) + + series_list = detect_library_series(repo) + assert len(series_list) == 2 + + cradle = next(s for s in series_list if s["series_name"] == "Cradle") + assert cradle["authors"] == ["Will Wight"] + assert cradle["volumes"] == ["1"] + assert cradle["gaps"] == [] + + dcc = next(s for s in series_list if s["series_name"] == "Dungeon Crawler Carl") + assert dcc["authors"] == ["Matt Dinniman"] + assert dcc["volumes"] == ["1", "3"] + assert dcc["gaps"] == ["#2"] + + +def test_detect_library_series_with_abs_books(tmp_path: Path): + db_file = tmp_path / "test.db" + ensure_database(db_file) + repo = Repository(db_file) + + # Insert ABS books for Stormlight Archive books 1 and 3 (gap at #2) + repo.upsert_abs_books( + [ + { + "id": "abs-item-1", + "title": "The Way of Kings", + "authors": ["Brandon Sanderson"], + "series": [{"name": "The Stormlight Archive", "entries": ["1"]}], + "library_path": "Brandon Sanderson/The Stormlight Archive/Book 1", + "asin": "B003ZWFO7E", + "source": "audiobookshelf", + }, + { + "id": "abs-item-3", + "title": "Oathbringer", + "authors": ["Brandon Sanderson"], + "series": [{"name": "The Stormlight Archive", "entries": ["3"]}], + "library_path": "Brandon Sanderson/The Stormlight Archive/Book 3", + "asin": "B071CQ5G3G", + "source": "audiobookshelf", + }, + ] + ) + + assert repo.abs_books_count() == 2 + records = repo.records_with_title(normalize_title("The Way of Kings")) + assert len(records) == 1 + assert records[0]["title"] == "The Way of Kings" + + series_list = detect_library_series(repo) + assert len(series_list) == 1 + sla = series_list[0] + assert sla["series_name"] == "The Stormlight Archive" + assert sla["authors"] == ["Brandon Sanderson"] + assert sla["volumes"] == ["1", "3"] + assert sla["gaps"] == ["#2"] diff --git a/python/tests/test_torrent.py b/python/tests/test_torrent.py new file mode 100644 index 00000000..8157af92 --- /dev/null +++ b/python/tests/test_torrent.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import hashlib + +from mlm.torrent import info_hash + + +def test_info_hash_uses_original_info_bytes() -> None: + info = b"d6:lengthi12e4:name4:booke" + torrent = b"d4:info" + info + b"e" + + assert info_hash(torrent) == hashlib.sha1(info).hexdigest() diff --git a/python/tests/test_web.py b/python/tests/test_web.py new file mode 100644 index 00000000..a78353e6 --- /dev/null +++ b/python/tests/test_web.py @@ -0,0 +1,1226 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +from mlm.config import load_config +from mlm.database import connect, ensure_database +from mlm.mam import MamClient +from mlm.migration import canonical_json +from mlm.repository import Repository +from mlm.request_auth import hash_request_password, verify_request_password +from mlm.scheduler import JobStatus +from mlm.web import create_app + + +class FakeServices: + def __init__(self, config) -> None: + self.config = config + self.jobs = {} + self.triggered: list[str] = [] + self.mam_stats = { + "slots_used": 100, + "slots_total": 150, + "slot_cap": 140, + "wedges": 8, + "wedge_buffer": 3, + } + + async def reconfigure(self, config) -> None: + self.config = config + + async def trigger(self, name: str) -> None: + self.triggered.append(name) + + +class FakeSearchMam: + def __init__(self) -> None: + self.query = None + + async def search(self, query: dict) -> dict: + self.query = query + if query.get("tor", {}).get("startNumber", 0): + return {"found": "237", "data": []} + return { + "found": "237", + "data": [ + { + "id": "321", + "title": "The Search Result", + "author_info": '{"1":"An Author"}', + "narrator_info": '{"2":"A Narrator"}', + "series_info": '{"3":["A Series","4"]}', + "filetype": "m4b", + "size": "734.5 MiB", + "catname": "Audiobook", + "language": "1", + "numfiles": "1", + "seeders": "12", + "leechers": "2", + "times_completed": "99", + "owner_name": "BookSeeder", + "added": "2026-08-05 12:00:00", + "personal_freeleech": "1", + "vip": "0", + } + ], + } + + +def test_dashboard_and_health_on_fresh_database(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + event = { + "id": "event-1", + "torrent_id": "torrent-1", + "mam_id": 1, + "created_at": "2025-01-01T00:00:00Z", + "event": "RemovedFromMam", + } + with connect(database) as connection: + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(event["id"]), + event["torrent_id"], + event["mam_id"], + canonical_json(event["created_at"]), + canonical_json(event), + ), + ) + Repository(database).add_selected( + { + "mam_id": 1001, + "title_search": "A queued book", + "created_at": "2025-01-01T00:00:00Z", + "started_at": None, + "removed_at": None, + "meta": {"title": "A queued book"}, + } + ) + app = create_app(config, database) + client = TestClient(app) + + health = client.get("/health") + dashboard = client.get("/") + triggered_dashboard = client.get("/?triggered=organizer") + + assert health.status_code == 200 + assert health.json()["status"] == "ok" + assert dashboard.status_code == 200 + assert "MyAnonaSuite" in dashboard.text + assert "HeavyMLM" in dashboard.text + assert "Request Portal" in dashboard.text + assert 'href="/request"' in dashboard.text + assert "Library Control" not in dashboard.text + assert "Your library, at a glance" in dashboard.text + assert "How HeavyMLM works" in dashboard.text + assert "There is no download queue to manage" in dashboard.text + assert "Download & organize" in dashboard.text + assert "HeavyMLM sends selected releases automatically" in dashboard.text + assert "Download Queue" not in dashboard.text + assert 'href="/records/selected_torrents"' not in dashboard.text + assert 'action="/actions/lists"' in dashboard.text + assert 'action="/actions/autograb"' in dashboard.text + assert 'action="/actions/downloader"' in dashboard.text + assert 'action="/actions/organizer"' in dashboard.text + assert 'action="/actions/cleaner"' in dashboard.text + assert "Run HeavyMLM now" in dashboard.text + assert "Schedules remain enabled" in dashboard.text + assert Repository(database).has_pending_mam_id(1001) is True + assert 'class="nav-link active"' in dashboard.text + assert "View live details" in triggered_dashboard.text + assert 'data-focus-job="organizer"' in triggered_dashboard.text + assert client.get("/static/app.css").status_code == 200 + events = client.get("/records/events") + assert events.status_code == 200 + assert "Lifecycle events" in events.text + assert "Event details" in events.text + config_page = client.get("/config") + assert config_page.status_code == 200 + assert "Complete configuration" in config_page.text + assert "Download anyway if wedge fails" in config_page.text + assert "Enable request portal" in config_page.text + assert 'name="request_portal_domains"' in config_page.text + assert 'name="request_portal_require_account_login"' in config_page.text + assert 'name="request_portal_username"' in config_page.text + assert 'name="request_portal_password"' in config_page.text + assert "Request login accounts" in config_page.text + assert 'action="/config/request-users/save"' in config_page.text + assert 'name="weekly_request_limit"' in config_page.text + assert 'name="config_toml"' in config_page.text + diagnostics = client.get("/diagnostics?live=0") + assert diagnostics.status_code == 200 + assert "Activity console" in diagnostics.text + assert "Auto-refresh paused" in diagnostics.text + absidekick = client.get("/suite/absidekick") + assert absidekick.status_code == 200 + assert 'data-suite="absidekick"' in absidekick.text + assert "ABSidekick" in absidekick.text + assert "Beta V.91.1 integrated" in absidekick.text + assert "Run Controls" in absidekick.text + assert "Live Log" in absidekick.text + assert 'id="activityPanel"' in absidekick.text + assert 'id="activityProgress"' in absidekick.text + assert "manual search" in absidekick.text.lower() + assert '
' in absidekick.text + assert '
' not in absidekick.text + assert 'id="searchTimeoutSeconds"' in absidekick.text + assert 'id="automaticFallbackProviders"' in absidekick.text + assert 'id="matchPolicyPreset"' in absidekick.text + assert 'id="policySummary"' in absidekick.text + assert 'id="useEmbeddedFileMetadata"' in absidekick.text + assert 'id="repairSeries"' in absidekick.text + assert 'src="/static/absidekick.js?v=0.5.0b65"' in absidekick.text + assert 'href="/static/absidekick.css?v=0.5.0b65"' in absidekick.text + absidekick_script = client.get("/static/absidekick.js") + assert absidekick_script.status_code == 200 + assert 'api("/api/review/search"' in absidekick_script.text + assert "MATCH FOUND" in absidekick_script.text + assert "NO CONFIDENT MATCH" in absidekick_script.text + assert "NO MATCH FOUND" in absidekick_script.text + assert "data-manual-search-outcome" in absidekick_script.text + assert 'api("/api/activity"' in absidekick_script.text + assert "Scanning Review Tags" in absidekick_script.text + assert "runVisibleAction" in absidekick_script.text + assert "renderPolicySummary" in absidekick_script.text + assert "renderSearchAttempts" in absidekick_script.text + assert "Search path" in absidekick_script.text + for page in ( + "run", + "review", + "targeting", + "matching", + "tags", + "config", + ): + assert client.get(f"/suite/absidekick/{page}").status_code == 200 + absidekick_config = client.get("/suite/absidekick/config") + assert "Connection" in absidekick_config.text + assert 'id="baseUrl"' in absidekick_config.text + assert 'id="token"' in absidekick_config.text + assert 'id="absTokenStatus"' in absidekick_config.text + assert "Paste token once; it will be saved privately" in absidekick_config.text + assert 'id="googleBooksApiKey"' in absidekick_config.text + assert 'id="testGoogleBooksBtn"' in absidekick_config.text + assert "How to create and secure a Google Books API key" in absidekick_config.text + assert "books.googleapis.com" in absidekick_config.text + assert 'id="openLibraryEnabled"' in absidekick_config.text + assert 'id="openLibraryContactEmail"' in absidekick_config.text + assert "official Open Library API guidelines" in absidekick_config.text + assert client.get("/suite/absidekick/not-real").status_code == 404 + absidekick_state = client.get("/api/absidekick/state") + assert absidekick_state.status_code == 200 + assert absidekick_state.json()["version"] == "Beta V.91.1" + assert absidekick_state.json()["activity"]["status"] == "idle" + assert client.get("/api/absidekick/activity").json()["activity"]["status"] == "idle" + missing_google_key = client.post("/api/absidekick/provider/google/test", json={}) + assert missing_google_key.status_code == 400 + assert "Enter a Google Books API key" in missing_google_key.json()["error"] + assert 'api("/api/provider/google/test"' in absidekick_script.text + assert "No Google request was sent" in absidekick_script.text + saved_absidekick = client.post( + "/api/absidekick/settings", + json={ + "settings": { + "connection": { + "baseUrl": "http://localhost:13378", + "libraryId": "library-1", + "provider": "audible", + "rememberConnection": True, + }, + "matching": {"threshold": 88}, + }, + "token": "abs-secret-token", + "openLibraryEnabled": True, + "openLibraryContactEmail": "owner@example.com", + }, + ) + assert saved_absidekick.status_code == 200 + assert saved_absidekick.json()["settings"]["connection"]["hasToken"] is True + assert "token" not in saved_absidekick.json()["settings"]["connection"] + assert ( + saved_absidekick.json()["settings"]["providers"]["openLibraryEnabled"] is True + ) + assert ( + saved_absidekick.json()["settings"]["providers"]["openLibraryContactEmail"] + == "owner@example.com" + ) + absidekick_settings = tmp_path / "absidekick" / "settings.json" + assert absidekick_settings.exists() + saved_settings_text = absidekick_settings.read_text(encoding="utf-8") + assert "abs-secret-token" in saved_settings_text + assert "owner@example.com" in saved_settings_text + invalid_open_library_contact = client.post( + "/api/absidekick/settings", + json={ + "settings": {}, + "openLibraryContactEmail": "not-an-email", + }, + ) + assert invalid_open_library_contact.status_code == 400 + assert ( + "valid Open Library contact email" + in invalid_open_library_contact.json()["error"] + ) + spender = client.get("/suite/mam-spender") + assert spender.status_code == 200 + assert 'data-suite="mam-spender"' in spender.text + assert "Spend deliberately" in spender.text + assert "SPEND_AUDIT.log" in spender.text + assert "What should the spender buy?" in spender.text + assert "Changes apply immediately" in spender.text + assert 'data-spender-setting-value="10000"' in spender.text + assert "Module theme" not in spender.text + for page in ("dashboard", "config", "history", "analytics", "mam-data"): + assert client.get(f"/suite/mam-spender/{page}").status_code == 200 + spender_config = client.get("/suite/mam-spender/config") + assert "MAM-Spender configuration" in spender_config.text + assert "Import old config.json" in spender_config.text + assert "MAM-Spender Web Edition v1.4.0" in spender_config.text + assert "0.5.0b65" in spender_config.text + assert "What should the spender buy?" in spender_config.text + assert "Module theme" not in spender_config.text + assert 'href="/suite/mam-spender/config"' in spender_config.text + # Keep bookmarks from the first integrated beta working. + assert ( + "MAM-Spender configuration" + in client.get("/suite/mam-spender?view=settings").text + ) + spender_history = client.get("/suite/mam-spender/history") + assert "MaM bonus history" in spender_history.text + assert client.get("/suite/mam-spender/not-real").status_code == 404 + assert client.get("/suite/not-real").status_code == 404 + + app.state.services = FakeServices(load_config(config)) + app.state.services.jobs["organizer:0"] = JobStatus( + running=True, + progress=[ + { + "created_at": "2025-01-01T00:00:00Z", + "level": "info", + "message": "Inspecting files: Book", + "context": {"current": 1, "total": 2}, + } + ], + ) + live_dashboard = client.get("/") + assert "40 left" in live_dashboard.text + assert "5 spendable" in live_dashboard.text + assert "1 running now" in live_dashboard.text + job_status = client.get("/api/jobs") + assert job_status.status_code == 200 + assert job_status.json()["jobs"]["organizer:0"]["running"] is True + assert ( + job_status.json()["jobs"]["organizer:0"]["progress"][0]["message"] + == "Inspecting files: Book" + ) + saved = client.post( + "/config", + data={ + "min_ratio": "2.5", + "unsat_buffer": "10", + "max_unsat_slots": "140", + "wedge_buffer": "3", + "prefer_wedges": "true", + "download_on_wedge_failure": "true", + "grab_both_formats": "true", + "request_portal_enabled": "true", + "request_portal_require_account_login": "true", + "request_portal_domains": "requests.example.test", + "request_portal_title": "Family Requests", + "request_portal_rate_limit": "15", + "request_portal_username": "family", + "request_portal_password": "correct horse", + "request_portal_access_code": "family-only", + "search_interval": "20", + "import_interval": "60", + "link_interval": "5", + }, + follow_redirects=False, + ) + assert saved.status_code == 303 + updated = load_config(config) + assert updated.max_unsat_slots == 140 + assert updated.wedge_buffer == 3 + assert updated.prefer_wedges is True + assert updated.download_on_wedge_failure is True + assert updated.grab_both_formats is True + assert updated.request_portal_enabled is True + assert updated.request_portal_require_account_login is True + assert updated.request_portal_domains == ("requests.example.test",) + assert updated.request_portal_title == "Family Requests" + assert updated.request_portal_username == "family" + assert verify_request_password( + "correct horse", updated.request_portal_password_hash + ) + assert "correct horse" not in config.read_text(encoding="utf-8") + assert updated.request_portal_access_code == "family-only" + assert updated.request_portal_rate_limit == 15 + + account_created = client.post( + "/config/request-users/save", + data={ + "username": "trusted-reader", + "display_name": "Trusted Reader", + "password": "reader password", + "auto_approve": "true", + "weekly_request_limit": "10", + }, + follow_redirects=False, + ) + assert account_created.status_code == 303 + account_config = load_config(config) + assert len(account_config.request_portal_users) == 1 + account = account_config.request_portal_users[0] + assert account.username == "trusted-reader" + assert account.display_name == "Trusted Reader" + assert account.permissions == ("auto_approve",) + assert account.weekly_request_limit == 10 + assert verify_request_password("reader password", account.password_hash) + account_page = client.get("/config#request-accounts") + assert "Trusted Reader" in account_page.text + assert "Auto-approve" in account_page.text + assert "10 requests per rolling week" in account_page.text + assert "does not grant access to HeavyMLM administration" in account_page.text + + account_deleted = client.post( + "/config/request-users/delete", + data={"username": "trusted-reader"}, + follow_redirects=False, + ) + assert account_deleted.status_code == 303 + assert load_config(config).request_portal_users == () + + full_saved = client.post( + "/config/full", + data={ + "config_toml": r""" +mam_id = "replacement-secret" +prefer_wedges = true +download_on_wedge_failure = true +wedge_buffer = 100 +audio_types = ["m4b"] + +[[qbittorrent]] +url = "http://localhost:8090" +password = "replacement-password" + +[[library]] +category = "Audiobooks" +library_dir = 'E:\MLM Audio' +method = "copy" +""" + }, + follow_redirects=False, + ) + assert full_saved.status_code == 303 + fully_updated = load_config(config) + assert fully_updated.mam_id == "replacement-secret" + assert fully_updated.wedge_buffer == 100 + assert fully_updated.download_on_wedge_failure is True + assert fully_updated.qbittorrent[0].password == "replacement-password" + assert app.state.services.config.wedge_buffer == 100 + + invalid_full_save = client.post( + "/config/full", + data={"config_toml": 'mam_id = "nope"\nunknown_setting = true\n'}, + ) + assert invalid_full_save.status_code == 400 + assert "unknown configuration fields" in invalid_full_save.text + assert load_config(config).mam_id == "replacement-secret" + + +def test_errors_explain_recovery_and_support_retry_and_dismiss( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + selected = { + "mam_id": 42, + "goodreads_id": None, + "hash": None, + "dl_link": "private-hash", + "unsat_buffer": 0, + "wedge_buffer": 3, + "cost": "UseWedge", + "category": "Audiobooks", + "tags": [], + "title_search": "example book", + "meta": {"mam_id": 42, "title": "Example Book", "authors": ["A. Writer"]}, + "grabber": "test", + "created_at": "2025-01-01T00:00:00Z", + "started_at": None, + "removed_at": None, + } + repository.add_selected(selected) + repository.record_grab_error( + selected, RuntimeError("wedge reserve reached (3 available, 3 reserved)") + ) + + app = create_app(config_path, database) + services = FakeServices(load_config(config_path)) + app.state.services = services + client = TestClient(app) + + page = client.get("/records/errored_torrents") + assert page.status_code == 200 + assert "Freeleech wedge reserve reached" in page.text + assert "wedge reserve reached (3 available, 3 reserved)" in page.text + assert "Path forward" in page.text + assert "lower the wedge buffer" in page.text + assert "Retry now" in page.text + assert "/operations?view=diagnostics&component=downloader" in page.text + + retried = client.post( + "/errors/retry", + data={"error_id": '{"Grabber":42}', "mam_id": "42"}, + follow_redirects=False, + ) + assert retried.status_code == 303 + assert repository.table_rows("errored_torrents") == [] + assert services.triggered == ["downloader"] + + repository.record_grab_error(selected, RuntimeError("temporary failure")) + repository.delete_selected(42) + stale_page = client.get("/records/errored_torrents") + assert "No longer pending" in stale_page.text + assert "Retry now" not in stale_page.text + stale_retry = client.post( + "/errors/retry", + data={"error_id": '{"Grabber":42}', "mam_id": "42"}, + ) + assert stale_retry.status_code == 409 + assert len(repository.table_rows("errored_torrents")) == 1 + dismissed = client.post( + "/errors/dismiss", + data={"error_id": '{"Grabber":42}'}, + follow_redirects=False, + ) + assert dismissed.status_code == 303 + assert repository.table_rows("errored_torrents") == [] + assert ( + client.post("/errors/dismiss", data={"error_id": "not-json"}).status_code == 400 + ) + + repository.record_grab_error( + selected, + RuntimeError("wedge rejected by tracker"), + context={ + "wedge_attempted": True, + "prefer_wedges": True, + "wedges_before": 250, + "wedges_after": 250, + "wedge_buffer": 100, + "download_on_wedge_failure": False, + "stage": "wedge_response", + "wedge_reason": "rejected", + "http_status": 200, + "content_type": "application/json", + "endpoint": "/tor/download.php?tid=42&fl", + "tracker_response": {"success": False, "error": "test rejection"}, + }, + ) + wedge_page = client.get("/records/errored_torrents") + assert "Wedge attempt debug" in wedge_page.text + assert "250 before" in wedge_page.text + assert "100 reserved" in wedge_page.text + assert "wedge_response / rejected" in wedge_page.text + assert "Disabled (strict)" in wedge_page.text + assert "/tor/download.php?tid=42&fl" in wedge_page.text + assert "test rejection" in wedge_page.text + + repository.add_selected(selected) + repository.record_grab_error(selected, RuntimeError("wedge rejected by tracker")) + repository.record_organizer_error( + "organizer-hash", + "Another Book", + "copy failed", + {"source": "D:\\Book.m4b", "destination": "E:\\Library\\Book.m4b"}, + ) + dismiss_all_page = client.get("/operations?view=errors") + assert "unresolved" in dismiss_all_page.text + assert 'action="/errors/dismiss-all"' in dismiss_all_page.text + assert ( + "will not delete torrents, downloads, files, or job history" + in dismiss_all_page.text + ) + + dismissed_all = client.post("/errors/dismiss-all", follow_redirects=False) + assert dismissed_all.status_code == 303 + assert dismissed_all.headers["location"].endswith("dismissed_all=2") + assert repository.table_rows("errored_torrents") == [] + assert repository.has_pending_mam_id(42) is True + + dismissal_confirmation = client.get(dismissed_all.headers["location"]) + assert "Dismissed 2 stored errors" in dismissal_confirmation.text + assert ( + "Torrents, downloads, files, and job history were not changed" + in dismissal_confirmation.text + ) + + +def test_organizer_copy_failure_is_visible_on_dashboard_and_errors( + tmp_path: Path, +) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + source = "D:\\Downloads\\Book\\book.m4b" + destination = "E:\\MLM Audio\\Writer\\Book\\book.m4b" + failure = { + "torrent": "Book", + "hash": "abc123", + "error": "FilePlacementError: file placement failed: disk is full", + "error_type": "FilePlacementError", + "source": source, + "destination": destination, + "method": "copy", + "remediation": "Free space on the library drive and run the organizer again.", + } + repository.record_organizer_error("abc123", "Book", failure["error"], failure) + app = create_app(config_path, database) + services = FakeServices(load_config(config_path)) + services.jobs["organizer:0"] = JobStatus( + last_result={ + "scanned": 1, + "linked": 0, + "already_existing": 2, + "incomplete": 0, + "skipped": 0, + "failed": 1, + "skip_reasons": {}, + "failures": [failure], + } + ) + app.state.services = services + client = TestClient(app) + + dashboard = client.get("/") + assert dashboard.status_code == 200 + assert "Organizer could not publish 1 library item(s)" in dashboard.text + assert "2 already existed" in dashboard.text + assert "disk is full" in dashboard.text + assert source in dashboard.text + assert destination in dashboard.text + assert "/operations?view=errors" in dashboard.text + + errors = client.get("/records/errored_torrents") + assert errors.status_code == 200 + assert "Library file placement failed" in errors.text + assert source in errors.text + assert destination in errors.text + assert "Free space on the library drive" in errors.text + assert "/operations?view=diagnostics&component=organizer" in errors.text + + +def test_search_renders_rich_heavymlm_release_cards(tmp_path: Path) -> None: + config_path = tmp_path / "config.toml" + config_path.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + services = FakeServices(load_config(config_path)) + services.mam = FakeSearchMam() # type: ignore[attr-defined] + app.state.services = services + client = TestClient(app) + + response = client.get("/search?q=Search+Result") + + assert response.status_code == 200 + assert "1 filtered match" in response.text + assert "Scanned 1 of 237 MaM releases" in response.text + assert "The Search Result" in response.text + assert "An Author" in response.text + assert "A Narrator" in response.text + assert "A Series" in response.text + assert "Personal freeleech" in response.text + assert "734.5 MiB" in response.text + assert "12 seeders" in response.text + assert services.mam.query["mediaInfo"] is True # type: ignore[attr-defined] + assert "description" not in services.mam.query # type: ignore[operator] + + +def test_search_combines_author_series_and_filetype_filters(tmp_path: Path) -> None: + class FilterMam: + def __init__(self) -> None: + self.queries: list[dict] = [] + + async def search(self, query: dict) -> dict: + self.queries.append(query) + return { + "found": 3, + "data": [ + { + "id": 1, + "title": "Dungeon Crawler Carl", + "author_info": '{"1":"Matt Dinniman"}', + "series_info": '{"1":["Dungeon Crawler Carl","1"]}', + "filetype": "m4b", + "catname": "Audiobook", + "language": 1, + "size": "500 MiB", + "seeders": 15, + }, + { + "id": 2, + "title": "Carl's Doomsday Scenario", + "author_info": '{"1":"Matt Dinniman"}', + "series_info": '{"1":["Dungeon Crawler Carl","2"]}', + "filetype": "mp3", + "catname": "Audiobook", + "language": 1, + "size": "600 MiB", + "seeders": 12, + }, + { + "id": 3, + "title": "Dominion of Blades", + "author_info": '{"1":"Matt Dinniman"}', + "series_info": '{"2":["Dominion of Blades","1"]}', + "filetype": "m4b", + "catname": "Audiobook", + "language": 1, + "size": "700 MiB", + "seeders": 8, + }, + ], + } + + config_path = tmp_path / "config.toml" + config_path.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + app = create_app(config_path, database) + services = FakeServices(load_config(config_path)) + mam = FilterMam() + services.mam = mam # type: ignore[attr-defined] + app.state.services = services + client = TestClient(app) + + response = client.get( + "/search", + params={ + "author": "Matt Dinniman", + "series": "Dungeon Crawler Carl", + "filetype": "m4b", + }, + ) + + assert response.status_code == 200 + assert "1 filtered match" in response.text + assert "Dungeon Crawler Carl" in response.text + assert "Carl's Doomsday Scenario" not in response.text + assert "Dominion of Blades" not in response.text + assert mam.queries[0]["tor"]["text"] == "Matt Dinniman" + assert mam.queries[0]["tor"]["srchIn"] == ["author"] + assert 'name="series" value="Dungeon Crawler Carl"' in response.text + assert '' in response.text + + +def test_record_pages_are_paginated(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + with connect(database) as connection: + for index in range(55): + event = { + "id": f"event-{index}", + "torrent_id": f"torrent-{index}", + "mam_id": index, + "created_at": f"2025-01-01T00:00:{index:02d}Z", + "event": "Test", + } + connection.execute( + """INSERT INTO events + (id_json, torrent_id, mam_id, created_at_json, payload_json) + VALUES (?, ?, ?, ?, ?)""", + ( + canonical_json(event["id"]), + event["torrent_id"], + event["mam_id"], + canonical_json(event["created_at"]), + canonical_json(event), + ), + ) + + client = TestClient(create_app(config, database)) + first = client.get("/records/events") + second = client.get("/records/events?page=2") + + assert first.status_code == 200 + assert "Page 1 / 2" in first.text + assert "/operations?view=events&page=2" in first.text + assert second.status_code == 200 + assert "Page 2 / 2" in second.text + + +def test_lists_and_processed_library_are_consolidated(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite3" + ensure_database(database) + repository = Repository(database) + repository.upsert_list( + { + "id": "179120590:abs", + "title": "ABS Reading List", + "updated_at": "2026-08-06T12:00:00Z", + } + ) + repository.upsert_list_item( + { + "guid": ["179120590:abs", "book-123"], + "list_id": "179120590:abs", + "title": "Tracked Book", + "authors": ["Example Author"], + "created_at": "2026-08-06T12:00:00Z", + "status": "already_grabbed", + "selected_mam_ids": [123], + "check_count": 1, + "last_result": "Skipped: already selected in an earlier run", + } + ) + repository.record_linked( + { + "id": "hash-123", + "mam_id": 123, + "title_search": "tracked book", + "created_at": "2026-08-06T12:00:00Z", + "meta": {"title": "Tracked Book", "authors": ["Example Author"]}, + "library_path": "E:\\MLM Ebook\\Example Author\\Tracked Book", + }, + None, + ) + repository.add_duplicate( + { + "mam_id": 124, + "title_search": "tracked book", + "meta": { + "title": "Tracked Book replacement", + "authors": ["Example Author"], + }, + }, + duplicate_of="hash-123", + ) + + client = TestClient(create_app(config, database)) + lists = client.get("/lists") + filtered = client.get("/lists?list_id=179120590%3Aabs") + library = client.get("/library") + duplicates = client.get("/library?view=duplicates") + old_lists = client.get("/records/list_items") + old_duplicates = client.get("/records/duplicate_torrents") + + assert lists.status_code == 200 + assert "ABS Reading List" in lists.text + assert "Tracked Book" in lists.text + assert "1 sources" in lists.text + assert filtered.status_code == 200 + assert "Skipped: already selected" in filtered.text + assert library.status_code == 200 + assert "MLM Processed Library" in library.text + assert "Tracked Book" in library.text + assert duplicates.status_code == 200 + assert "Tracked Book replacement" in duplicates.text + assert old_lists.url.path == "/lists" + assert old_duplicates.url.path == "/library" + assert old_duplicates.url.query == b"view=duplicates" + + +def test_modules_page_and_toggle_action(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = "secret"\n', encoding="utf-8") + database = tmp_path / "data.sqlite" + app = create_app(config, database) + client = TestClient(app) + + modules_get = client.get("/modules") + assert modules_get.status_code == 200 + assert "Module Manager" in modules_get.text + assert "HeavyMLM" in modules_get.text + assert "ABSidekick" in modules_get.text + assert "MAM-Spender" in modules_get.text + assert 'name="module_heavymlm"' in modules_get.text + + # Disable heavymlm, keep only mam_spender + toggle_post = client.post( + "/modules", + data={ + "module_mam_spender": "true", + }, + follow_redirects=False, + ) + assert toggle_post.status_code == 303 + assert toggle_post.headers["location"] == "/modules?saved=1" + + # Verify index now redirects to mam-spender dashboard + index_redirect = client.get("/", follow_redirects=False) + assert index_redirect.status_code == 303 + assert index_redirect.headers["location"] == "/suite/mam-spender/dashboard" + + +def test_tutorial_page(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = "secret"\n', encoding="utf-8") + database = tmp_path / "data.sqlite" + app = create_app(config, database) + client = TestClient(app) + + tutorial_get = client.get("/tutorial") + assert tutorial_get.status_code == 200 + assert "Welcome to MyAnonaSuite" in tutorial_get.text + assert "How Everything Fits Together" in tutorial_get.text + assert "Connecting Your MyAnonamouse Account" in tutorial_get.text + assert "IMPORTANT SAFETY ADVICE" in tutorial_get.text + + +def test_admin_search_series_select(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite" + app = create_app(config, database) + services = FakeServices(load_config(config)) + + raw_releases = [ + { + "id": 101, + "title": "Dungeon Crawler Carl Book 1", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "1"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 20, + "free": 1, + "dl": "https://example.com/dl/101", + }, + { + "id": 201, + "title": "Carl's Doomsday Scenario", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "2"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 15, + "free": 0, + "dl": "https://example.com/dl/201", + }, + ] + + class FakeMam(MamClient): + def __init__(self): + pass + + async def search(self, query: dict): + return {"data": raw_releases, "found": len(raw_releases)} + + async def get_torrent_info_by_id(self, tid: int): + for r in raw_releases: + if r["id"] == tid: + return r + return None + + fake_mam = FakeMam() + services.mam = fake_mam + app.state.services = services + client = TestClient(app) + + # Search GET with series filter shows the series banner and Grab Series buttons + search_res = client.get("/search?series=Dungeon+Crawler+Carl") + assert search_res.status_code == 200 + assert "📚 Series: Dungeon Crawler Carl" in search_res.text + assert "Download Entire Series" in search_res.text + assert "Grab Series" in search_res.text + + # POST /search/series/select queues all books in the series + post_res = client.post( + "/search/series/select", + data={"series_name": "Dungeon Crawler Carl"}, + follow_redirects=True, + ) + assert post_res.status_code == 200 + assert 'Queued 2 books for series "Dungeon Crawler Carl"' in post_res.text + + repo = Repository(database) + assert repo.has_pending_mam_id(101) is True + assert repo.has_pending_mam_id(201) is True + + +def test_request_portal_series_submit(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + pwd_hash = hash_request_password("secret-password") + config.write_text( + 'mam_id = ""\n' + "request_portal_enabled = true\n" + 'request_portal_domains = ["requests.example.com"]\n' + "[[request_portal_users]]\n" + 'username = "alice"\n' + f'password_hash = "{pwd_hash}"\n' + 'permissions = ["auto_approve"]\n', + encoding="utf-8", + ) + database = tmp_path / "data.sqlite" + app = create_app(config, database) + services = FakeServices(load_config(config)) + + raw_releases = [ + { + "id": 501, + "title": "The Way of Kings", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["The Stormlight Archive", "1"]}), + "author_info": json.dumps({"1": "Brandon Sanderson"}), + "seeders": 50, + "free": 1, + "dl": "https://example.com/dl/501", + }, + { + "id": 502, + "title": "Words of Radiance", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["The Stormlight Archive", "2"]}), + "author_info": json.dumps({"1": "Brandon Sanderson"}), + "seeders": 45, + "free": 1, + "dl": "https://example.com/dl/502", + }, + ] + + class FakeMam(MamClient): + def __init__(self): + pass + + async def search(self, query: dict): + return {"data": raw_releases, "found": len(raw_releases)} + + async def get_torrent_info_by_id(self, tid: int): + for r in raw_releases: + if r["id"] == tid: + return r + return None + + fake_mam = FakeMam() + services.mam = fake_mam + app.state.services = services + client = TestClient(app) + + # Login as alice (who has auto_approve) + login_res = client.post( + "/request/unlock", + data={"username": "alice", "password": "secret-password"}, + follow_redirects=True, + ) + assert login_res.status_code == 200 + + # Request entire series as alice + req_res = client.post( + "/request/series/submit", + data={ + "series_name": "The Stormlight Archive", + "requester_name": "Alice", + "note": "Love this series!", + }, + follow_redirects=True, + ) + assert req_res.status_code == 200 + assert "Series request automatically approved!" in req_res.text + assert "scheduled 2 missing books in series" in req_res.text + + repo = Repository(database) + requests = repo.request_rows(status="approved") + assert len(requests) == 2 + assert any(r["mam_id"] == 501 for r in requests) + assert any(r["mam_id"] == 502 for r in requests) + + +def test_library_missing_series_view_and_select(tmp_path: Path) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite" + app = create_app(config, database) + services = FakeServices(load_config(config)) + repo = Repository(database) + + # Pre-populate Book 1 of Dungeon Crawler Carl in library + repo.add_selected( + { + "mam_id": 101, + "title_search": "dungeon crawler carl", + "meta": { + "title": "Dungeon Crawler Carl", + "media_type": "audiobook", + "authors": ["Matt Dinniman"], + "series": [{"name": "Dungeon Crawler Carl", "entries": ["1"]}], + }, + "started_at": "2025-01-01T00:00:00Z", + "created_at": "2025-01-01T00:00:00Z", + } + ) + + raw_releases = [ + { + "id": 101, + "title": "Dungeon Crawler Carl", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "1"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 20, + "free": 1, + "dl": "https://example.com/dl/101", + }, + { + "id": 201, + "title": "Carl's Doomsday Scenario", + "filetype": "m4b", + "catname": "Audiobooks", + "mediatype": 1, + "main_cat": 13, + "series_info": json.dumps({"1": ["Dungeon Crawler Carl", "2"]}), + "author_info": json.dumps({"1": "Matt Dinniman"}), + "seeders": 15, + "free": 0, + "dl": "https://example.com/dl/201", + }, + ] + + class FakeMam(MamClient): + def __init__(self): + pass + + async def search(self, query: dict): + return {"data": raw_releases, "found": len(raw_releases)} + + async def get_torrent_info_by_id(self, tid: int): + for r in raw_releases: + if r["id"] == tid: + return r + return None + + fake_mam = FakeMam() + services.mam = fake_mam + app.state.services = services + client = TestClient(app) + + # 1. Check GET /library?view=missing renders series audit list + res = client.get("/library?view=missing") + assert res.status_code == 200 + assert "Missing series books" in res.text + assert "Find Missing Books on MaM" in res.text + assert "Dungeon Crawler Carl" in res.text + assert "Matt Dinniman" in res.text + + # 2. Check GET /library?view=missing&series=Dungeon+Crawler+Carl performs live scan + scan_res = client.get("/library?view=missing&series=Dungeon+Crawler+Carl") + assert scan_res.status_code == 200 + assert "Series: Dungeon Crawler Carl" in scan_res.text + assert "Download All Missing (1)" in scan_res.text + assert "Carl's Doomsday Scenario" in scan_res.text + + # 3. Check POST /library/series/select queues missing book (ID 201) + post_res = client.post( + "/library/series/select", + data={"series_name": "Dungeon Crawler Carl"}, + follow_redirects=True, + ) + assert post_res.status_code == 200 + assert 'Queued 1 missing book for series "Dungeon Crawler Carl"' in post_res.text + assert repo.has_pending_mam_id(201) is True + + +def test_library_abs_view_and_sync(tmp_path: Path, monkeypatch) -> None: + config = tmp_path / "config.toml" + config.write_text('mam_id = ""\n', encoding="utf-8") + database = tmp_path / "data.sqlite" + app = create_app(config, database) + services = FakeServices(load_config(config)) + repo = Repository(database) + + # Mock fetch_all_abs_library_books + fake_abs_books = [ + { + "id": "item-abc", + "abs_id": "item-abc", + "title": "Unsouled", + "authors": ["Will Wight"], + "series": [{"name": "Cradle", "entries": ["1"]}], + "library_path": "Will Wight/Cradle/01 - Unsouled.m4b", + "asin": "B01LW8PT7M", + "isbn": "", + "media_type": "audiobook", + "meta": { + "title": "Unsouled", + "authors": ["Will Wight"], + "series": [{"name": "Cradle", "entries": ["1"]}], + "media_type": "audiobook", + }, + "source": "audiobookshelf", + }, + { + "id": "item-def", + "abs_id": "item-def", + "title": "Soulsmith", + "authors": ["Will Wight"], + "series": [{"name": "Cradle", "entries": ["2"]}], + "library_path": "Will Wight/Cradle/02 - Soulsmith.m4b", + "asin": "B01LYP0528", + "isbn": "", + "media_type": "audiobook", + "meta": { + "title": "Soulsmith", + "authors": ["Will Wight"], + "series": [{"name": "Cradle", "entries": ["2"]}], + "media_type": "audiobook", + }, + "source": "audiobookshelf", + }, + ] + + monkeypatch.setattr( + "mlm.web.sync_audiobookshelf_library", + lambda r, s, token=None: { + "ok": True, + "total_synced": r.upsert_abs_books(fake_abs_books), + }, + ) + + app.state.services = services + client = TestClient(app) + + # 1. Trigger POST /library/sync-abs + sync_res = client.post("/library/sync-abs", follow_redirects=True) + assert sync_res.status_code == 200 + assert "Successfully synced 2 audiobooks" in sync_res.text + assert repo.abs_books_count() == 2 + + # 2. Check GET /library?view=abs displays synced ABS books + abs_view_res = client.get("/library?view=abs") + assert abs_view_res.status_code == 200 + assert "Audiobookshelf Library Catalog" in abs_view_res.text + assert "Unsouled" in abs_view_res.text + assert "Soulsmith" in abs_view_res.text + assert "ABS #item-abc" in abs_view_res.text diff --git a/server/Cargo.toml b/server/Cargo.toml deleted file mode 100644 index 03d7f16e..00000000 --- a/server/Cargo.toml +++ /dev/null @@ -1,82 +0,0 @@ -[package] -name = "mlm" -version = "0.4.9" -edition = "2024" - -[package.metadata.packager] -before-packaging-command = "cargo build --release" -product-name = "Myanonamouse Library Manager" -identifier = "io.github.stirlingmouse.mlm" -resources = ["Cargo.toml", "src", "assets"] -icons = ["assets/favicon.png"] -nsis = { installer-icon = "assets/tray.ico" } - -[dependencies] -anyhow = "1.0.100" -askama = { version = "0.14.0", features = ["code-in-doc", "serde_json"] } -axum = { version = "0.8.4", features = ["query", "macros"] } -axum-extra = { version = "0.10.1", features = ["form"] } -bytes = "1.11.0" -cookie = "0.18.1" -dirs = "6.0" -figment = { version = "0.10", features = ["toml", "env"] } -file-id = "0.2.2" -futures = "0.3" -# goodreads-metadata-scraper = "0.2.4" -# goodreads-metadata-scraper = { path = "../goodreads-metadata-scraper" } -htmlentity = "1.3.2" -itertools = "0.14.0" -lava_torrent = { git = "https://github.com/StirlingMouse/lava_torrent.git" } -log = "0.4.27" -matchr = "0.2.5" -mlm_db = { path = "../mlm_db" } -mlm_mam = { path = "../mlm_mam" } -mlm_parse = { path = "../mlm_parse" } -native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } -native_model = "0.4.20" -once_cell = "1.21.3" -openssl = { version = "0.10.73", features = ["vendored"] } -qbit = { git = "https://github.com/CampingAvocado/qbittorrent-webui-api.git" } -quick-xml = { version = "0.38.0", features = ["serialize"] } -regex = "1.12.2" -reqwest = "0.12.20" -reqwest_cookie_store = "0.8.0" -sanitize-filename = { git = "https://github.com/StirlingMouse/sanitize-filename.git" } -scraper = "0.23.1" -serde = "1.0.136" -serde_derive = "1.0.136" -serde_json = "1.0.140" -serde-nested-json = "0.1.3" -sublime_fuzzy = "0.7.0" -thiserror = "2.0.17" -time = { version = "0.3.41", features = [ - "formatting", - "local-offset", - "macros", - "serde", -] } -tokio = { version = "1.45.1", features = ["full"] } -tokio-stream = { version = "0.1.17", features = ["sync"] } -tokio-util = "0.7" -toml = "0.8.23" -tower = "0.5.2" -tower-http = { version = "0.6.6", features = ["fs"] } -tracing = "0.1" -tracing-appender = "0.2.3" -tracing-subscriber = { version = "0.3", features = [ - "local-time", - "env-filter", - "tracing-log", -] } -tracing-panic = "0.1.2" -unidecode = "0.3.0" -urlencoding = "2.1.3" -uuid = { version = "1.17.0", features = ["serde", "v4"] } - -[target.'cfg(windows)'.dependencies] -open = "5.3.2" -tray-item = "0.10.0" -winsafe = { version = "0.0.26", features = ["gui"] } - -[build-dependencies] -embed-resource = "3.0.5" diff --git a/server/assets/elements.js b/server/assets/elements.js deleted file mode 100644 index 43c0f03c..00000000 --- a/server/assets/elements.js +++ /dev/null @@ -1,40 +0,0 @@ -class ClearButton extends HTMLButtonElement { - constructor() { - super(); - } - - inputListener = () => { - if (this.input.value) this.show(); else this.hide() - } - - connectedCallback() { - const input = this.previousElementSibling - if (!(input instanceof HTMLInputElement)) { - throw Error('clear-button must be after input') - } - this.input = input - if (input.value) this.show() - input.addEventListener('change', this.inputListener) - this.addEventListener('click', e => { - console.log('clear click') - e.preventDefault() - e.stopPropagation() - input.value = '' - this.hide() - input.form.submit() - }) - } - - disconnectedCallback() { - this.input?.removeEventListener('change', this.inputListener) - } - - show() { - this.style.display = 'inline-flex' - } - - hide() { - this.style.display = 'none' - } -} -customElements.define("clear-button", ClearButton, { extends: 'button' }) diff --git a/server/assets/favicon.png b/server/assets/favicon.png deleted file mode 100644 index 195913c9..00000000 Binary files a/server/assets/favicon.png and /dev/null differ diff --git a/server/assets/favicon_dev.png b/server/assets/favicon_dev.png deleted file mode 100644 index 86caf9df..00000000 Binary files a/server/assets/favicon_dev.png and /dev/null differ diff --git a/server/assets/icons/abridged.png b/server/assets/icons/abridged.png deleted file mode 100644 index 36be296b..00000000 Binary files a/server/assets/icons/abridged.png and /dev/null differ diff --git a/server/assets/icons/cats/100_b.png b/server/assets/icons/cats/100_b.png deleted file mode 100644 index bc8e94ea..00000000 Binary files a/server/assets/icons/cats/100_b.png and /dev/null differ diff --git a/server/assets/icons/cats/101_b.png b/server/assets/icons/cats/101_b.png deleted file mode 100644 index 1beeb890..00000000 Binary files a/server/assets/icons/cats/101_b.png and /dev/null differ diff --git a/server/assets/icons/cats/102_b.png b/server/assets/icons/cats/102_b.png deleted file mode 100644 index 65a6f0d7..00000000 Binary files a/server/assets/icons/cats/102_b.png and /dev/null differ diff --git a/server/assets/icons/cats/103_b.png b/server/assets/icons/cats/103_b.png deleted file mode 100644 index c2c29a1c..00000000 Binary files a/server/assets/icons/cats/103_b.png and /dev/null differ diff --git a/server/assets/icons/cats/104_b.png b/server/assets/icons/cats/104_b.png deleted file mode 100644 index 17ec8dff..00000000 Binary files a/server/assets/icons/cats/104_b.png and /dev/null differ diff --git a/server/assets/icons/cats/106_b.png b/server/assets/icons/cats/106_b.png deleted file mode 100644 index d0d9e7f8..00000000 Binary files a/server/assets/icons/cats/106_b.png and /dev/null differ diff --git a/server/assets/icons/cats/107_b.png b/server/assets/icons/cats/107_b.png deleted file mode 100644 index 56a779da..00000000 Binary files a/server/assets/icons/cats/107_b.png and /dev/null differ diff --git a/server/assets/icons/cats/108_b.png b/server/assets/icons/cats/108_b.png deleted file mode 100644 index 3c4aff41..00000000 Binary files a/server/assets/icons/cats/108_b.png and /dev/null differ diff --git a/server/assets/icons/cats/109_b.png b/server/assets/icons/cats/109_b.png deleted file mode 100644 index db920329..00000000 Binary files a/server/assets/icons/cats/109_b.png and /dev/null differ diff --git a/server/assets/icons/cats/111_b.png b/server/assets/icons/cats/111_b.png deleted file mode 100644 index 39a6981d..00000000 Binary files a/server/assets/icons/cats/111_b.png and /dev/null differ diff --git a/server/assets/icons/cats/112_b.png b/server/assets/icons/cats/112_b.png deleted file mode 100644 index ed5900fa..00000000 Binary files a/server/assets/icons/cats/112_b.png and /dev/null differ diff --git a/server/assets/icons/cats/113_b.png b/server/assets/icons/cats/113_b.png deleted file mode 100644 index 4c6688c6..00000000 Binary files a/server/assets/icons/cats/113_b.png and /dev/null differ diff --git a/server/assets/icons/cats/114_b.png b/server/assets/icons/cats/114_b.png deleted file mode 100644 index eb724841..00000000 Binary files a/server/assets/icons/cats/114_b.png and /dev/null differ diff --git a/server/assets/icons/cats/115_b.png b/server/assets/icons/cats/115_b.png deleted file mode 100644 index 9d50b0f6..00000000 Binary files a/server/assets/icons/cats/115_b.png and /dev/null differ diff --git a/server/assets/icons/cats/118_b.png b/server/assets/icons/cats/118_b.png deleted file mode 100644 index ebdbaf85..00000000 Binary files a/server/assets/icons/cats/118_b.png and /dev/null differ diff --git a/server/assets/icons/cats/119_b.png b/server/assets/icons/cats/119_b.png deleted file mode 100644 index c10e3119..00000000 Binary files a/server/assets/icons/cats/119_b.png and /dev/null differ diff --git a/server/assets/icons/cats/120_b.png b/server/assets/icons/cats/120_b.png deleted file mode 100644 index 58139f9e..00000000 Binary files a/server/assets/icons/cats/120_b.png and /dev/null differ diff --git a/server/assets/icons/cats/126_b.png b/server/assets/icons/cats/126_b.png deleted file mode 100644 index 1b3a049c..00000000 Binary files a/server/assets/icons/cats/126_b.png and /dev/null differ diff --git a/server/assets/icons/cats/127_b.png b/server/assets/icons/cats/127_b.png deleted file mode 100644 index b209aea8..00000000 Binary files a/server/assets/icons/cats/127_b.png and /dev/null differ diff --git a/server/assets/icons/cats/128_b.png b/server/assets/icons/cats/128_b.png deleted file mode 100644 index 2ee15848..00000000 Binary files a/server/assets/icons/cats/128_b.png and /dev/null differ diff --git a/server/assets/icons/cats/132_b.png b/server/assets/icons/cats/132_b.png deleted file mode 100644 index ab6c967d..00000000 Binary files a/server/assets/icons/cats/132_b.png and /dev/null differ diff --git a/server/assets/icons/cats/17_b.png b/server/assets/icons/cats/17_b.png deleted file mode 100644 index df6118c3..00000000 Binary files a/server/assets/icons/cats/17_b.png and /dev/null differ diff --git a/server/assets/icons/cats/19_b.png b/server/assets/icons/cats/19_b.png deleted file mode 100644 index 90fddd91..00000000 Binary files a/server/assets/icons/cats/19_b.png and /dev/null differ diff --git a/server/assets/icons/cats/20_b.png b/server/assets/icons/cats/20_b.png deleted file mode 100644 index a6277b05..00000000 Binary files a/server/assets/icons/cats/20_b.png and /dev/null differ diff --git a/server/assets/icons/cats/22_b.png b/server/assets/icons/cats/22_b.png deleted file mode 100644 index 2a292ebe..00000000 Binary files a/server/assets/icons/cats/22_b.png and /dev/null differ diff --git a/server/assets/icons/cats/24_b.png b/server/assets/icons/cats/24_b.png deleted file mode 100644 index ada674e8..00000000 Binary files a/server/assets/icons/cats/24_b.png and /dev/null differ diff --git a/server/assets/icons/cats/26_b.png b/server/assets/icons/cats/26_b.png deleted file mode 100644 index edf43b72..00000000 Binary files a/server/assets/icons/cats/26_b.png and /dev/null differ diff --git a/server/assets/icons/cats/27_b.png b/server/assets/icons/cats/27_b.png deleted file mode 100644 index ce554b07..00000000 Binary files a/server/assets/icons/cats/27_b.png and /dev/null differ diff --git a/server/assets/icons/cats/30_b.png b/server/assets/icons/cats/30_b.png deleted file mode 100644 index 6e133965..00000000 Binary files a/server/assets/icons/cats/30_b.png and /dev/null differ diff --git a/server/assets/icons/cats/31_b.png b/server/assets/icons/cats/31_b.png deleted file mode 100644 index ef35dae0..00000000 Binary files a/server/assets/icons/cats/31_b.png and /dev/null differ diff --git a/server/assets/icons/cats/39_b.png b/server/assets/icons/cats/39_b.png deleted file mode 100644 index 015107df..00000000 Binary files a/server/assets/icons/cats/39_b.png and /dev/null differ diff --git a/server/assets/icons/cats/40_b.png b/server/assets/icons/cats/40_b.png deleted file mode 100644 index e96ccd0f..00000000 Binary files a/server/assets/icons/cats/40_b.png and /dev/null differ diff --git a/server/assets/icons/cats/41_b.png b/server/assets/icons/cats/41_b.png deleted file mode 100644 index eb59ec97..00000000 Binary files a/server/assets/icons/cats/41_b.png and /dev/null differ diff --git a/server/assets/icons/cats/42_b.png b/server/assets/icons/cats/42_b.png deleted file mode 100644 index cc737c35..00000000 Binary files a/server/assets/icons/cats/42_b.png and /dev/null differ diff --git a/server/assets/icons/cats/43_b.png b/server/assets/icons/cats/43_b.png deleted file mode 100644 index a6870ffe..00000000 Binary files a/server/assets/icons/cats/43_b.png and /dev/null differ diff --git a/server/assets/icons/cats/44_b.png b/server/assets/icons/cats/44_b.png deleted file mode 100644 index 7d7dc09d..00000000 Binary files a/server/assets/icons/cats/44_b.png and /dev/null differ diff --git a/server/assets/icons/cats/45_b.png b/server/assets/icons/cats/45_b.png deleted file mode 100644 index a9732843..00000000 Binary files a/server/assets/icons/cats/45_b.png and /dev/null differ diff --git a/server/assets/icons/cats/46_b.png b/server/assets/icons/cats/46_b.png deleted file mode 100644 index 6fd77fe9..00000000 Binary files a/server/assets/icons/cats/46_b.png and /dev/null differ diff --git a/server/assets/icons/cats/47_b.png b/server/assets/icons/cats/47_b.png deleted file mode 100644 index ad3646d9..00000000 Binary files a/server/assets/icons/cats/47_b.png and /dev/null differ diff --git a/server/assets/icons/cats/48_b.png b/server/assets/icons/cats/48_b.png deleted file mode 100644 index 57ed2182..00000000 Binary files a/server/assets/icons/cats/48_b.png and /dev/null differ diff --git a/server/assets/icons/cats/49_b.png b/server/assets/icons/cats/49_b.png deleted file mode 100644 index 466c24e4..00000000 Binary files a/server/assets/icons/cats/49_b.png and /dev/null differ diff --git a/server/assets/icons/cats/50_b.png b/server/assets/icons/cats/50_b.png deleted file mode 100644 index 2e4635c2..00000000 Binary files a/server/assets/icons/cats/50_b.png and /dev/null differ diff --git a/server/assets/icons/cats/51_b.png b/server/assets/icons/cats/51_b.png deleted file mode 100644 index a3ac7fea..00000000 Binary files a/server/assets/icons/cats/51_b.png and /dev/null differ diff --git a/server/assets/icons/cats/52_b.png b/server/assets/icons/cats/52_b.png deleted file mode 100644 index cd2e1e73..00000000 Binary files a/server/assets/icons/cats/52_b.png and /dev/null differ diff --git a/server/assets/icons/cats/53_b.png b/server/assets/icons/cats/53_b.png deleted file mode 100644 index ca55eeb9..00000000 Binary files a/server/assets/icons/cats/53_b.png and /dev/null differ diff --git a/server/assets/icons/cats/54_b.png b/server/assets/icons/cats/54_b.png deleted file mode 100644 index 8fba98c4..00000000 Binary files a/server/assets/icons/cats/54_b.png and /dev/null differ diff --git a/server/assets/icons/cats/55_b.png b/server/assets/icons/cats/55_b.png deleted file mode 100644 index 29c714b7..00000000 Binary files a/server/assets/icons/cats/55_b.png and /dev/null differ diff --git a/server/assets/icons/cats/56_b.png b/server/assets/icons/cats/56_b.png deleted file mode 100644 index c6a0098e..00000000 Binary files a/server/assets/icons/cats/56_b.png and /dev/null differ diff --git a/server/assets/icons/cats/57_b.png b/server/assets/icons/cats/57_b.png deleted file mode 100644 index f379d6b7..00000000 Binary files a/server/assets/icons/cats/57_b.png and /dev/null differ diff --git a/server/assets/icons/cats/58_b.png b/server/assets/icons/cats/58_b.png deleted file mode 100644 index 1534d12e..00000000 Binary files a/server/assets/icons/cats/58_b.png and /dev/null differ diff --git a/server/assets/icons/cats/59_b.png b/server/assets/icons/cats/59_b.png deleted file mode 100644 index 5b980478..00000000 Binary files a/server/assets/icons/cats/59_b.png and /dev/null differ diff --git a/server/assets/icons/cats/60_b.png b/server/assets/icons/cats/60_b.png deleted file mode 100644 index dbfea4ab..00000000 Binary files a/server/assets/icons/cats/60_b.png and /dev/null differ diff --git a/server/assets/icons/cats/61_b.png b/server/assets/icons/cats/61_b.png deleted file mode 100644 index 945aea20..00000000 Binary files a/server/assets/icons/cats/61_b.png and /dev/null differ diff --git a/server/assets/icons/cats/62_b.png b/server/assets/icons/cats/62_b.png deleted file mode 100644 index 3a71ff38..00000000 Binary files a/server/assets/icons/cats/62_b.png and /dev/null differ diff --git a/server/assets/icons/cats/63_b.png b/server/assets/icons/cats/63_b.png deleted file mode 100644 index 379d672a..00000000 Binary files a/server/assets/icons/cats/63_b.png and /dev/null differ diff --git a/server/assets/icons/cats/64_b.png b/server/assets/icons/cats/64_b.png deleted file mode 100644 index 30a35714..00000000 Binary files a/server/assets/icons/cats/64_b.png and /dev/null differ diff --git a/server/assets/icons/cats/65_b.png b/server/assets/icons/cats/65_b.png deleted file mode 100644 index e22ac13c..00000000 Binary files a/server/assets/icons/cats/65_b.png and /dev/null differ diff --git a/server/assets/icons/cats/66_b.png b/server/assets/icons/cats/66_b.png deleted file mode 100644 index ab4c634a..00000000 Binary files a/server/assets/icons/cats/66_b.png and /dev/null differ diff --git a/server/assets/icons/cats/67_b.png b/server/assets/icons/cats/67_b.png deleted file mode 100644 index b6facd44..00000000 Binary files a/server/assets/icons/cats/67_b.png and /dev/null differ diff --git a/server/assets/icons/cats/68_b.png b/server/assets/icons/cats/68_b.png deleted file mode 100644 index 965c97c4..00000000 Binary files a/server/assets/icons/cats/68_b.png and /dev/null differ diff --git a/server/assets/icons/cats/69_b.png b/server/assets/icons/cats/69_b.png deleted file mode 100644 index ce894ea3..00000000 Binary files a/server/assets/icons/cats/69_b.png and /dev/null differ diff --git a/server/assets/icons/cats/70_b.png b/server/assets/icons/cats/70_b.png deleted file mode 100644 index d6823761..00000000 Binary files a/server/assets/icons/cats/70_b.png and /dev/null differ diff --git a/server/assets/icons/cats/71_b.png b/server/assets/icons/cats/71_b.png deleted file mode 100644 index 93d8eebb..00000000 Binary files a/server/assets/icons/cats/71_b.png and /dev/null differ diff --git a/server/assets/icons/cats/72_b.png b/server/assets/icons/cats/72_b.png deleted file mode 100644 index 52aec460..00000000 Binary files a/server/assets/icons/cats/72_b.png and /dev/null differ diff --git a/server/assets/icons/cats/73_b.png b/server/assets/icons/cats/73_b.png deleted file mode 100644 index b89c7620..00000000 Binary files a/server/assets/icons/cats/73_b.png and /dev/null differ diff --git a/server/assets/icons/cats/74_b.png b/server/assets/icons/cats/74_b.png deleted file mode 100644 index 3cbd28db..00000000 Binary files a/server/assets/icons/cats/74_b.png and /dev/null differ diff --git a/server/assets/icons/cats/75_b.png b/server/assets/icons/cats/75_b.png deleted file mode 100644 index 274ac177..00000000 Binary files a/server/assets/icons/cats/75_b.png and /dev/null differ diff --git a/server/assets/icons/cats/76_b.png b/server/assets/icons/cats/76_b.png deleted file mode 100644 index 42f5e659..00000000 Binary files a/server/assets/icons/cats/76_b.png and /dev/null differ diff --git a/server/assets/icons/cats/77_b.png b/server/assets/icons/cats/77_b.png deleted file mode 100644 index 507c40c5..00000000 Binary files a/server/assets/icons/cats/77_b.png and /dev/null differ diff --git a/server/assets/icons/cats/78_b.png b/server/assets/icons/cats/78_b.png deleted file mode 100644 index 50cc02bf..00000000 Binary files a/server/assets/icons/cats/78_b.png and /dev/null differ diff --git a/server/assets/icons/cats/79_b.png b/server/assets/icons/cats/79_b.png deleted file mode 100644 index 5f51f98f..00000000 Binary files a/server/assets/icons/cats/79_b.png and /dev/null differ diff --git a/server/assets/icons/cats/81_b.png b/server/assets/icons/cats/81_b.png deleted file mode 100644 index e237c714..00000000 Binary files a/server/assets/icons/cats/81_b.png and /dev/null differ diff --git a/server/assets/icons/cats/82_b.png b/server/assets/icons/cats/82_b.png deleted file mode 100644 index 2bf77bfe..00000000 Binary files a/server/assets/icons/cats/82_b.png and /dev/null differ diff --git a/server/assets/icons/cats/83_b.png b/server/assets/icons/cats/83_b.png deleted file mode 100644 index ddc09ecf..00000000 Binary files a/server/assets/icons/cats/83_b.png and /dev/null differ diff --git a/server/assets/icons/cats/84_b.png b/server/assets/icons/cats/84_b.png deleted file mode 100644 index 5599c492..00000000 Binary files a/server/assets/icons/cats/84_b.png and /dev/null differ diff --git a/server/assets/icons/cats/85_b.png b/server/assets/icons/cats/85_b.png deleted file mode 100644 index a900469a..00000000 Binary files a/server/assets/icons/cats/85_b.png and /dev/null differ diff --git a/server/assets/icons/cats/87_b.png b/server/assets/icons/cats/87_b.png deleted file mode 100644 index 5cc491bb..00000000 Binary files a/server/assets/icons/cats/87_b.png and /dev/null differ diff --git a/server/assets/icons/cats/88_b.png b/server/assets/icons/cats/88_b.png deleted file mode 100644 index e033e29d..00000000 Binary files a/server/assets/icons/cats/88_b.png and /dev/null differ diff --git a/server/assets/icons/cats/89_b.png b/server/assets/icons/cats/89_b.png deleted file mode 100644 index 4c67e061..00000000 Binary files a/server/assets/icons/cats/89_b.png and /dev/null differ diff --git a/server/assets/icons/cats/90_b.png b/server/assets/icons/cats/90_b.png deleted file mode 100644 index 79e5361c..00000000 Binary files a/server/assets/icons/cats/90_b.png and /dev/null differ diff --git a/server/assets/icons/cats/91_b.png b/server/assets/icons/cats/91_b.png deleted file mode 100644 index d9c6575c..00000000 Binary files a/server/assets/icons/cats/91_b.png and /dev/null differ diff --git a/server/assets/icons/cats/92_b.png b/server/assets/icons/cats/92_b.png deleted file mode 100644 index fe88a774..00000000 Binary files a/server/assets/icons/cats/92_b.png and /dev/null differ diff --git a/server/assets/icons/cats/94_b.png b/server/assets/icons/cats/94_b.png deleted file mode 100644 index 6c3a2372..00000000 Binary files a/server/assets/icons/cats/94_b.png and /dev/null differ diff --git a/server/assets/icons/cats/95_b.png b/server/assets/icons/cats/95_b.png deleted file mode 100644 index 35ea2e8a..00000000 Binary files a/server/assets/icons/cats/95_b.png and /dev/null differ diff --git a/server/assets/icons/cats/96_b.png b/server/assets/icons/cats/96_b.png deleted file mode 100644 index 2b2dbb26..00000000 Binary files a/server/assets/icons/cats/96_b.png and /dev/null differ diff --git a/server/assets/icons/cats/97_b.png b/server/assets/icons/cats/97_b.png deleted file mode 100644 index f0325813..00000000 Binary files a/server/assets/icons/cats/97_b.png and /dev/null differ diff --git a/server/assets/icons/cats/98_b.png b/server/assets/icons/cats/98_b.png deleted file mode 100644 index d5d26bc4..00000000 Binary files a/server/assets/icons/cats/98_b.png and /dev/null differ diff --git a/server/assets/icons/cats/99_b.png b/server/assets/icons/cats/99_b.png deleted file mode 100644 index 16f63c1d..00000000 Binary files a/server/assets/icons/cats/99_b.png and /dev/null differ diff --git a/server/assets/icons/down.png b/server/assets/icons/down.png deleted file mode 100644 index bb339649..00000000 Binary files a/server/assets/icons/down.png and /dev/null differ diff --git a/server/assets/icons/downBig3.png b/server/assets/icons/downBig3.png deleted file mode 100644 index 24f179c9..00000000 Binary files a/server/assets/icons/downBig3.png and /dev/null differ diff --git a/server/assets/icons/flames.png b/server/assets/icons/flames.png deleted file mode 100644 index e2e23bed..00000000 Binary files a/server/assets/icons/flames.png and /dev/null differ diff --git a/server/assets/icons/freedownload.png b/server/assets/icons/freedownload.png deleted file mode 100644 index 96911fe4..00000000 Binary files a/server/assets/icons/freedownload.png and /dev/null differ diff --git a/server/assets/icons/greenCheck2.png b/server/assets/icons/greenCheck2.png deleted file mode 100644 index d362edff..00000000 Binary files a/server/assets/icons/greenCheck2.png and /dev/null differ diff --git a/server/assets/icons/hand.png b/server/assets/icons/hand.png deleted file mode 100644 index 87007edb..00000000 Binary files a/server/assets/icons/hand.png and /dev/null differ diff --git a/server/assets/icons/language.png b/server/assets/icons/language.png deleted file mode 100644 index eea6d2cd..00000000 Binary files a/server/assets/icons/language.png and /dev/null differ diff --git a/server/assets/icons/lgbt.png b/server/assets/icons/lgbt.png deleted file mode 100644 index b2de3527..00000000 Binary files a/server/assets/icons/lgbt.png and /dev/null differ diff --git a/server/assets/icons/lipssmall.png b/server/assets/icons/lipssmall.png deleted file mode 100644 index 0329093d..00000000 Binary files a/server/assets/icons/lipssmall.png and /dev/null differ diff --git a/server/assets/icons/snatched.png b/server/assets/icons/snatched.png deleted file mode 100644 index b79c3930..00000000 Binary files a/server/assets/icons/snatched.png and /dev/null differ diff --git a/server/assets/icons/unlocked.png b/server/assets/icons/unlocked.png deleted file mode 100644 index e91c0b30..00000000 Binary files a/server/assets/icons/unlocked.png and /dev/null differ diff --git a/server/assets/icons/upBig3.png b/server/assets/icons/upBig3.png deleted file mode 100644 index d5ecc50d..00000000 Binary files a/server/assets/icons/upBig3.png and /dev/null differ diff --git a/server/assets/icons/updown.png b/server/assets/icons/updown.png deleted file mode 100644 index 428404ad..00000000 Binary files a/server/assets/icons/updown.png and /dev/null differ diff --git a/server/assets/icons/vip.png b/server/assets/icons/vip.png deleted file mode 100644 index 9315b1d6..00000000 Binary files a/server/assets/icons/vip.png and /dev/null differ diff --git a/server/assets/index.js b/server/assets/index.js deleted file mode 100644 index cff4826e..00000000 --- a/server/assets/index.js +++ /dev/null @@ -1,159 +0,0 @@ -document.body.addEventListener('submit', e => { - // @ts-ignore - const form = e.target.form || e.target - if (!(form instanceof HTMLFormElement)) return - - if (form.getAttribute('inline') !== null) { - e.preventDefault() - const data = new URLSearchParams(); - // @ts-ignore - for (const pair of new FormData(form)) { - data.append(pair[0], pair[1]); - } - if (e.submitter && e.submitter instanceof HTMLButtonElement && e.submitter.name) { - data.append(e.submitter.name, e.submitter.value) - } - /** @type {HTMLTemplateElement} */ - const optimistic = e.submitter.querySelector('template.optimistic') ?? form.querySelector('template.optimistic') - if (optimistic) { - e.submitter.replaceChildren(optimistic.content.cloneNode(true)) - } - - fetch(location.href, { - method: form.method, - body: data, - }) - .then(async r => { - /** @type {HTMLTemplateElement} */ - const final = e.submitter.querySelector('template.final') ?? form.querySelector('template.final') - if (final && r.ok) { - e.submitter.replaceChildren(final.content.cloneNode(true)) - } else { - const html = await r.text() - const parser = new DOMParser() - const newDocument = parser.parseFromString(html, 'text/html') - const main = document.querySelector('main') - main.replaceWith(newDocument.querySelector('main')) - } - }) - } -}) - -document.body.addEventListener('formdata', e => { - const formData = e.formData; - const show = formData.getAll('show') - if (show.length > 1) formData.set('show', show.join(',')) - const query = formData.get('query') - if (query === '') formData.delete('query') -}) - -document.body.addEventListener('click', e => { - if (e.target instanceof HTMLButtonElement) { - if (e.target.dataset.prompt) { - if (!confirm(e.target.dataset.prompt)) { - e.preventDefault() - } - } - } - if (e.target instanceof HTMLAnchorElement && e.target.getAttribute('href').startsWith('?')) { - const link = new URLSearchParams(e.target.getAttribute('href')) - const current = new URLSearchParams(location.search) - const sortBy = link.get('sort_by') ?? current.get('sort_by') - const asc = link.get('sort_by') ? link.get('asc') : current.get('asc') - const show = link.get('show') ?? current.get('show') - const from = link.get('from') ?? current.get('from') - const page_size = link.get('page_size') ?? current.get('page_size') - // @ts-ignore - const filters = [...link.entries(), ...current.entries()] - .filter(([key,]) => key !== 'sort_by' && key !== 'asc' && key !== 'show' && key !== 'from' && key !== 'page_size') - const combined = new URLSearchParams() - if (sortBy) combined.set('sort_by', sortBy) - if (asc) combined.set('asc', asc) - if (show) combined.set('show', show) - if (from) combined.set('from', from) - if (page_size) combined.set('page_size', page_size) - if (e.shiftKey) { - for (const filter of filters) combined.set(filter[0], filter[1]) - } else { - const filter = filters[0] - if (filter) combined.set(filter[0], filter[1]) - } - const target = new URL(e.target.href) - target.search = combined.toString() - e.preventDefault() - location.href = target.toString() - } -}) - -document.body.addEventListener('change', e => { - // @ts-ignore - const toggles = e.target.closest('.option_group') - if (toggles?.classList.contains('query')) { - setTimeout(() => { - // @ts-ignore - const value = e.target.tagName === 'SELECT' - // @ts-ignore - ? [e.target.value] - : Array.from(toggles.querySelectorAll('input[type="checkbox"],input[type="radio"]')) - .filter(i => i.checked) - .map(i => i.type === "radio" && i.value == "on" ? '' : i.value) - const params = new URLSearchParams(location.search) - const value_str = value.join(',') - if (value_str) { - // @ts-ignore - params.set(e.target.name, value_str) - } else { - // @ts-ignore - params.delete(e.target.name) - } - const target = new URL(location.href) - target.search = params.toString() - location.href = target.toString() - }) - return - } - // @ts-ignore - if (e.target.type === 'checkbox') { - // @ts-ignore - const table = e.target.closest('.table,.table2') - if (table) { - // @ts-ignore - const name = e.target.name - if (name.endsWith('_all')) { - for (const checkbox of Array.from(table.querySelectorAll(`input[type="checkbox"][name="${name.slice(0, -4)}"]`))) { - // @ts-ignore - checkbox.checked = e.target.checked - } - const actions = document.querySelector(`.actions.actions_${name.slice(0, -4)}`) - // @ts-ignore - actions.style.display = e.target.checked ? 'flex' : 'none'; - } else { - const all = table.querySelector(`input[type="checkbox"][name="${name}_all"]`) - if (all) { - const actions = document.querySelector(`.actions.actions_${name}`) - const checkboxes = Array.from(table.querySelectorAll(`input[type="checkbox"][name="${name}"]`)) - const allNotChecked = checkboxes.every(c => !c.checked) - if (allNotChecked) { - all.checked = false - all.indeterminate = false - // @ts-ignore - if (actions) actions.style.display = 'none'; - return - } - - // @ts-ignore - if (actions) actions.style.display = 'flex'; - - const allChecked = checkboxes.every(c => c.checked) - if (allChecked) { - all.checked = true - all.indeterminate = false - return - } - all.checked = false - all.indeterminate = true - } - } - } - } -}) diff --git a/server/assets/style.css b/server/assets/style.css deleted file mode 100644 index 49b35cfa..00000000 --- a/server/assets/style.css +++ /dev/null @@ -1,429 +0,0 @@ -body { - font-family: Arial, sans-serif; - --color-1: #2a2438; - --color-2: #352f44; - --color-3: #5c5470; - --color-4: #dbd8e3; - - --background: var(--color-1); - --above: var(--color-2); - --text-faint: var(--color-3); - --text: var(--color-4); - --accent: hsl(331.8, 91.3%, 45%); - --accent-above: hsl(331.8, 91.3%, 55%); - --warn: #e08067; - - background: var(--background); - color: var(--text); -} - -a { - color: currentColor; - text-decoration-color: transparent; - - &:hover { - text-decoration-color: currentColor; - } - &:focus-ring { - text-decoration-color: currentColor; - } -} - - -ul { - margin-top: 0; - padding-left: 1em; -} - -nav > a { - padding: 4px; - background: var(--above); -} - -.row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; - - h1 { - flex: 1; - } -} - -.actions { - display: none; - gap: 8px; -} - -a.btn, -button { - appearance: none; - padding: 4px 8px; - border: none; - border-radius: 2px; - font-size: inherit; - color: var(--text); - background: var(--above); - cursor: pointer; - - &[data-prompt] { - color: var(--warn); - } - - &.icon, - &:has(> img:only-child) { - padding: 4px; - width: 28px; - height: 28px; - background: transparent; - - img { - width: 20px; - height: 20px; - } - } - - &&:hover { - background: var(--color-3); - } - &&:focus-ring { - background: var(--color-3); - } -} - -form { - - textarea { - appearance: none; - padding: 4px 8px; - border: none; - border-radius: 2px; - font-size: inherit; - color: var(--text); - background: var(--above); - width: max(220px, 20vw); - - &:focus { - background: var(--color-3); - outline: 2px solid var(--accent); - } - } - input[type=text] { - appearance: none; - padding: 4px 8px; - border: none; - border-radius: 2px; - font-size: inherit; - color: var(--text); - background: var(--above); - width: max(220px, 20vw); - - &:focus { - background: var(--color-3); - outline: 2px solid var(--accent); - } - } - input[type=number] { - appearance: none; - padding: 4px 8px; - border: none; - border-radius: 2px; - font-size: inherit; - color: var(--text); - background: var(--above); - width: 40px; - - &:focus { - background: var(--color-3); - outline: 2px solid var(--accent); - } - } - - button[is="clear-button"] { - position: absolute; - display: none; - margin-top: 4px; - margin-left: -4px; - transform: translateX(-100%); - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - font-size: inherit; - color: var(--text); - cursor: pointer; - - &::after { - content: "⨯"; - transform: translateY(-2px); - } - - &:hover { - background: var(--color-3); - } - &:focus-ring { - background: var(--color-3); - } - } - - &.page { - display: flex; - flex-direction: column; - gap: 16px; - - label { - display: flex; - - span { - display: inline-block; - width: 140px; - } - } - } -} - -select { - appearance: base-select; - border: none; - padding: 2px 4px; - background: var(--above); - color: var(--text); - border-radius: 2px; -} - -summary { - cursor: pointer; - user-select: none; -} - -.table_options { - display: flex; - gap: 16px; -} - -.option_group { - display: flex; - gap: 4px; - - & > div { - display: flex; - gap: 4px; - flex-wrap: wrap; - } - - label { - padding: 2px 4px; - background: var(--above); - border-radius: 2px; - &:has(:checked) { - background: var(--accent); - } - } - - input { - display: none; - } -} - -.pagination { - position: sticky; - bottom: 0; - display: grid; - grid-template-columns: min-content min-content auto min-content min-content; - align-items: center; - justify-content: center; - gap: 8px; - padding: 8px; - border-top: 1px solid currentColor; - background: var(--background); - - > div { - display: flex; - gap: 4px; - } - a { - display: flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - padding: 4px; - background: var(--above); - border-radius: 50%; - - &:hover { - text-decoration: none; - background: var(--color-3); - } - &:focus-ring { - text-decoration: none; - background: var(--color-3); - } - } - .active { - background: var(--accent); - - &:hover { - background: var(--accent-above); - } - &:focus-ring { - background: var(--accent-above); - } - } - .disabled { - color: var(--color-3); - background: var(--above) !important; - } -} - -.table { - display: grid; - --alternate: var(--above); - word-break: break-word; - - & > .header, & > div { - display: block; - padding: 4px; - } - - & > .header { - position: sticky; - top: 0; - font-weight: bold; - border-bottom: 1px solid currentColor; - background: var(--background); - } -} - -.table2 { - --alternate: var(--above); - word-break: break-word; - - &.MaMTorrentsTable { - margin: 0 -8px; - } - - &.MaMTorrentsTable > div { - grid-template-columns: 72px 54px 1fr 32px 84px 130px 64px; - - & > div { - padding: 8px 4px; - } - & > div:nth-child(1n+4) { - text-align: center; - } - & > div:first-of-type { - padding-left: 12px; - } - & > div:last-of-type { - text-align: right; - padding-right: 12px; - } - } - - & > div { - display: grid; - - &&&:first-of-type { - align-items: end; - background: var(--background); - } - &:nth-child(even) { - background: var(--alternate); - } - - & > .header, & > div { - display: block; - padding: 4px; - } - - & > .header { - position: sticky; - top: 0; - font-weight: bold; - border-bottom: 1px solid currentColor; - background: var(--background); - } - } - - &:not(.nohover) > div:hover { - background: var(--color-3); - } -} - -.list_item { - display: grid; - grid-template-columns: auto 1fr; - margin: 24px; - gap: 16px; - - img { - width: 64px; - } - h3 { - margin: 0; - } - p { - margin: 0.5em 0; - } - .author { - margin-top: 0; - font-style: italic; - } -} - -.torrent { - font-weight: bold; -} - -.faint { - opacity: 0.8; -} -.missing { - color: var(--warn); -} -.warn { - color: var(--warn); -} - -.configbox { - font-family: monospace; - - h3 { - margin-bottom: 0; - } - h4 { - margin-bottom: 0; - } - .string { - color: #b5bd68; - } - .num { - color: #de935f; - } -} - -.infoboxes { - display: flex; - flex-wrap: wrap; - gap: 16px; - max-width: min(932px, 100%); - - .infobox { - width: min(300px, 100%); - } -} - -.item { - display: inline-block; - margin: 2px 0; - padding: 2px 4px; - border-radius: 4px; - background-color: #aa86b72e; - - & + & { - margin-left: 4px; - } -} diff --git a/server/assets/tray.ico b/server/assets/tray.ico deleted file mode 100644 index d49caf7f..00000000 Binary files a/server/assets/tray.ico and /dev/null differ diff --git a/server/build.rs b/server/build.rs deleted file mode 100644 index 5b21a07d..00000000 --- a/server/build.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::time::SystemTime; - -extern crate embed_resource; - -fn main() { - let now = SystemTime::now(); - println!( - "cargo:rustc-env=DATE={}", - now.duration_since(SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - ); - #[cfg(target_family = "windows")] - embed_resource::compile("tray.rc", embed_resource::NONE) - .manifest_optional() - .unwrap(); -} diff --git a/server/src/audiobookshelf.rs b/server/src/audiobookshelf.rs deleted file mode 100644 index b80fa949..00000000 --- a/server/src/audiobookshelf.rs +++ /dev/null @@ -1,731 +0,0 @@ -use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; - -use anyhow::Result; -use axum::http::HeaderMap; -use mlm_db::{DatabaseExt as _, Flags, Torrent, TorrentMeta, impls::format_serie}; -use mlm_mam::search::MaMTorrent; -use native_db::Database; -use reqwest::{Url, header::AUTHORIZATION}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tracing::{debug, error, instrument, trace}; - -use crate::config::AudiobookShelfConfig; - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct LibrariesResponse { - pub libraries: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct Library { - pub id: String, - pub name: String, - pub folders: Vec, - // #[serde(rename = "displayOrder")] - // pub display_order: i64, - // pub icon: String, - // #[serde(rename = "mediaType")] - // pub media_type: String, - // pub provider: String, - // pub settings: Settings, - // #[serde(rename = "createdAt")] - // pub created_at: i64, - // #[serde(rename = "lastUpdate")] - // pub last_update: i64, -} - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct Folder { - pub id: String, - #[serde(rename = "fullPath")] - pub full_path: String, - #[serde(rename = "libraryId")] - pub library_id: String, - #[serde(rename = "addedAt")] - pub added_at: Option, -} - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Settings { -// #[serde(rename = "coverAspectRatio")] -// pub cover_aspect_ratio: i64, -// #[serde(rename = "disableWatcher")] -// pub disable_watcher: bool, -// #[serde(rename = "skipMatchingMediaWithAsin")] -// pub skip_matching_media_with_asin: bool, -// #[serde(rename = "skipMatchingMediaWithIsbn")] -// pub skip_matching_media_with_isbn: bool, -// } - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct SearchResponse { - // pub book: Vec, - pub authors: Vec, -} - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Book { -// #[serde(rename = "libraryItem")] -// pub library_item: LibraryItem, -// // #[serde(rename = "matchKey")] -// // pub match_key: Option, -// // #[serde(rename = "matchText")] -// // pub match_text: Option, -// } - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct AuthorItem { - pub id: String, - // pub asin: Option, - pub name: String, - // pub description: Option, - // #[serde(rename = "imagePath")] - // pub image_path: Option, - // #[serde(rename = "libraryId")] - // pub library_id: String, - // #[serde(rename = "addedAt")] - // pub added_at: i64, - // #[serde(rename = "updatedAt")] - // pub updated_at: i64, - // #[serde(rename = "numBooks")] - // pub num_books: i64, -} - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct LibraryItem { -// pub id: String, -// pub ino: String, -// #[serde(rename = "libraryId")] -// pub library_id: String, -// #[serde(rename = "folderId")] -// pub folder_id: String, -// pub path: String, -// #[serde(rename = "relPath")] -// pub rel_path: String, -// #[serde(rename = "isFile")] -// pub is_file: bool, -// #[serde(rename = "mtimeMs")] -// pub mtime_ms: i64, -// #[serde(rename = "ctimeMs")] -// pub ctime_ms: i64, -// #[serde(rename = "birthtimeMs")] -// pub birthtime_ms: i64, -// #[serde(rename = "addedAt")] -// pub added_at: i64, -// #[serde(rename = "updatedAt")] -// pub updated_at: i64, -// #[serde(rename = "lastScan")] -// pub last_scan: i64, -// #[serde(rename = "scanVersion")] -// pub scan_version: String, -// #[serde(rename = "isMissing")] -// pub is_missing: bool, -// #[serde(rename = "isInvalid")] -// pub is_invalid: bool, -// #[serde(rename = "mediaType")] -// pub media_type: String, -// pub media: Media, -// #[serde(rename = "libraryFiles")] -// pub library_files: Vec, -// pub size: i64, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Media { -// #[serde(rename = "libraryItemId")] -// pub library_item_id: String, -// pub metadata: Metadata, -// #[serde(rename = "coverPath")] -// pub cover_path: Option, -// pub tags: Vec, -// #[serde(rename = "audioFiles")] -// pub audio_files: Vec, -// pub chapters: Vec, -// pub duration: f64, -// pub size: i64, -// pub tracks: Vec, -// // #[serde(rename = "ebookFile")] -// // pub ebook_file: Value, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Metadata { -// pub title: String, -// #[serde(rename = "titleIgnorePrefix")] -// pub title_ignore_prefix: String, -// pub subtitle: Option, -// pub authors: Vec, -// pub narrators: Vec, -// pub series: Vec, -// pub genres: Vec, -// #[serde(rename = "publishedYear")] -// pub published_year: Option, -// #[serde(rename = "publishedDate")] -// pub published_date: Option, -// pub publisher: Option, -// pub description: String, -// pub isbn: Option, -// pub asin: Option, -// pub language: Option, -// pub explicit: bool, -// #[serde(rename = "authorName")] -// pub author_name: Option, -// #[serde(rename = "authorNameLF")] -// pub author_name_lf: Option, -// #[serde(rename = "narratorName")] -// pub narrator_name: Option, -// #[serde(rename = "seriesName")] -// pub series_name: Option, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Author { -// pub id: String, -// pub name: String, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Series { -// pub id: String, -// pub name: String, -// pub sequence: Option, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct AudioFile { -// pub index: i64, -// pub ino: String, -// pub metadata: Metadata2, -// #[serde(rename = "addedAt")] -// pub added_at: i64, -// #[serde(rename = "updatedAt")] -// pub updated_at: i64, -// #[serde(rename = "trackNumFromMeta")] -// pub track_num_from_meta: Option, -// // #[serde(rename = "discNumFromMeta")] -// // pub disc_num_from_meta: Option, -// #[serde(rename = "trackNumFromFilename")] -// pub track_num_from_filename: Option, -// // #[serde(rename = "discNumFromFilename")] -// // pub disc_num_from_filename: Option, -// #[serde(rename = "manuallyVerified")] -// pub manually_verified: bool, -// pub exclude: bool, -// // pub error: Value, -// pub format: String, -// pub duration: f64, -// #[serde(rename = "bitRate")] -// pub bit_rate: i64, -// // pub language: Value, -// pub codec: String, -// #[serde(rename = "timeBase")] -// pub time_base: String, -// pub channels: i64, -// #[serde(rename = "channelLayout")] -// pub channel_layout: String, -// // pub chapters: Vec, -// // #[serde(rename = "embeddedCoverArt")] -// // pub embedded_cover_art: Value, -// #[serde(rename = "metaTags")] -// pub meta_tags: MetaTags, -// #[serde(rename = "mimeType")] -// pub mime_type: String, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Metadata2 { -// pub filename: String, -// pub ext: String, -// pub path: String, -// #[serde(rename = "relPath")] -// pub rel_path: String, -// pub size: i64, -// #[serde(rename = "mtimeMs")] -// pub mtime_ms: i64, -// #[serde(rename = "ctimeMs")] -// pub ctime_ms: i64, -// #[serde(rename = "birthtimeMs")] -// pub birthtime_ms: i64, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct MetaTags { -// #[serde(rename = "tagAlbum")] -// pub tag_album: Option, -// #[serde(rename = "tagArtist")] -// pub tag_artist: Option, -// #[serde(rename = "tagGenre")] -// pub tag_genre: Option, -// #[serde(rename = "tagTitle")] -// pub tag_title: Option, -// #[serde(rename = "tagTrack")] -// pub tag_track: Option, -// #[serde(rename = "tagAlbumArtist")] -// pub tag_album_artist: Option, -// #[serde(rename = "tagComposer")] -// pub tag_composer: Option, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Chapter { -// pub id: i64, -// pub start: f64, -// pub end: f64, -// pub title: String, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Track { -// pub index: i64, -// #[serde(rename = "startOffset")] -// pub start_offset: f64, -// pub duration: f64, -// pub title: String, -// #[serde(rename = "contentUrl")] -// pub content_url: String, -// #[serde(rename = "mimeType")] -// pub mime_type: String, -// pub metadata: Metadata3, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Metadata3 { -// pub filename: String, -// pub ext: String, -// pub path: String, -// #[serde(rename = "relPath")] -// pub rel_path: String, -// pub size: i64, -// #[serde(rename = "mtimeMs")] -// pub mtime_ms: i64, -// #[serde(rename = "ctimeMs")] -// pub ctime_ms: i64, -// #[serde(rename = "birthtimeMs")] -// pub birthtime_ms: i64, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct LibraryFile { -// pub ino: String, -// pub metadata: Metadata4, -// #[serde(rename = "addedAt")] -// pub added_at: i64, -// #[serde(rename = "updatedAt")] -// pub updated_at: i64, -// #[serde(rename = "fileType")] -// pub file_type: String, -// } -// -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct Metadata4 { -// pub filename: String, -// pub ext: String, -// pub path: String, -// #[serde(rename = "relPath")] -// pub rel_path: String, -// pub size: i64, -// #[serde(rename = "mtimeMs")] -// pub mtime_ms: i64, -// #[serde(rename = "ctimeMs")] -// pub ctime_ms: i64, -// #[serde(rename = "birthtimeMs")] -// pub birthtime_ms: i64, -// } - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct AuthorResponse { - pub id: String, - // pub asin: Option, - pub name: String, - // pub description: Option, - // #[serde(rename = "imagePath")] - // pub image_path: Option, - // #[serde(rename = "addedAt")] - // pub added_at: i64, - // #[serde(rename = "updatedAt")] - // pub updated_at: i64, - #[serde(rename = "libraryItems")] - pub library_items: Vec, - // pub series: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Deserialize)] -pub struct LibraryItemMinified { - pub id: String, - pub ino: String, - #[serde(rename = "libraryId")] - pub library_id: String, - #[serde(rename = "folderId")] - pub folder_id: String, - pub path: String, - #[serde(rename = "relPath")] - pub rel_path: String, - #[serde(rename = "isFile")] - pub is_file: bool, - // #[serde(rename = "mtimeMs")] - // pub mtime_ms: i64, - // #[serde(rename = "ctimeMs")] - // pub ctime_ms: i64, - // #[serde(rename = "birthtimeMs")] - // pub birthtime_ms: i64, - // #[serde(rename = "addedAt")] - // pub added_at: i64, - // #[serde(rename = "updatedAt")] - // pub updated_at: i64, - // #[serde(rename = "isMissing")] - // pub is_missing: bool, - // #[serde(rename = "isInvalid")] - // pub is_invalid: bool, - // #[serde(rename = "mediaType")] - // pub media_type: String, - // pub media: MediaMinified, - // #[serde(rename = "numFiles")] - // pub num_files: i64, - // pub size: i64, -} - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct MediaMinified { -// pub metadata: MetadataSmall, -// #[serde(rename = "coverPath")] -// pub cover_path: Option, -// pub tags: Vec, -// #[serde(rename = "numTracks")] -// pub num_tracks: i64, -// #[serde(rename = "numAudioFiles")] -// pub num_audio_files: i64, -// #[serde(rename = "numChapters")] -// pub num_chapters: i64, -// pub duration: f64, -// pub size: i64, -// #[serde(rename = "ebookFileFormat")] -// pub ebook_file_format: Option, -// } - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct MetadataSmall { -// pub title: String, -// #[serde(rename = "titleIgnorePrefix")] -// pub title_ignore_prefix: String, -// pub subtitle: Option, -// #[serde(rename = "authorName")] -// pub author_name: Option, -// #[serde(rename = "narratorName")] -// pub narrator_name: Option, -// #[serde(rename = "seriesName")] -// pub series_name: Option, -// pub genres: Vec, -// #[serde(rename = "publishedYear")] -// pub published_year: Option, -// #[serde(rename = "publishedDate")] -// pub published_date: Option, -// pub publisher: Option, -// pub description: Option, -// pub isbn: Option, -// pub asin: Option, -// pub language: Option, -// pub explicit: bool, -// } - -#[derive(Default, Debug, Clone, PartialEq, Serialize)] -pub struct MediaUpdate<'a> { - metadata: MetadataUpdate<'a>, - // #[serde(rename = "coverPath")] - // pub cover_path: Option, - // pub tags: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize)] -pub struct MetadataUpdate<'a> { - pub title: &'a str, - pub subtitle: Option<&'a str>, - pub authors: Vec>, - pub narrators: Vec<&'a str>, - pub series: Vec>, - - // pub genres: Vec, - // #[serde(rename = "publishedYear")] - // pub published_year: Option<&'a str>, - // #[serde(rename = "publishedDate")] - // pub published_date: Option<&'a str>, - // pub publisher: Option<&'a str>, - pub description: Option<&'a str>, - pub isbn: Option<&'a str>, - pub asin: Option<&'a str>, - pub genres: BTreeSet<&'a str>, - pub language: Option<&'a str>, - pub explicit: bool, - pub abridged: bool, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize)] -pub struct AuthorUpdate<'a> { - pub name: &'a str, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize)] -pub struct SeriesUpdate<'a> { - pub name: &'a str, - pub sequence: Option, -} - -// #[derive(Default, Debug, Clone, PartialEq, Deserialize)] -// pub struct SeriesItem { -// pub id: String, -// pub name: String, -// pub items: Vec, -// } - -#[instrument(skip_all)] -pub async fn match_torrents_to_abs( - config: &AudiobookShelfConfig, - db: Arc>, -) -> Result<()> { - let abs = Abs::new(config)?; - let torrents = db.r_transaction()?.scan().primary::()?; - let torrents = torrents.all()?.filter(|t| { - t.as_ref() - .is_ok_and(|t| t.abs_id.is_none() && t.library_path.is_some()) - }); - - for torrent in torrents { - let mut torrent = torrent?; - let Some(book) = abs.get_book(&torrent).await? else { - trace!( - "Could not find ABS entry for torrent {} {}", - torrent.meta.mam_id, torrent.meta.title - ); - continue; - }; - debug!( - "Matched ABS entry with torrent {} {}", - torrent.meta.mam_id, torrent.meta.title - ); - torrent.abs_id = Some(book.id); - let (_guard, rw) = db.rw_async().await?; - rw.upsert(torrent)?; - rw.commit()?; - } - - Ok(()) -} - -pub struct Abs { - base_url: String, - client: reqwest::Client, -} - -impl Abs { - pub fn new(config: &AudiobookShelfConfig) -> Result { - // let mut headers = Default::default(); - let mut headers = HeaderMap::new(); - headers.insert(AUTHORIZATION, format!("Bearer {}", config.token).parse()?); - let client = reqwest::Client::builder() - .default_headers(headers) - .user_agent("MLM") - .build()?; - - Ok(Abs { - base_url: config.url.to_owned(), - client, - }) - } - - pub async fn get_book(&self, torrent: &Torrent) -> Result> { - let Some(library_path) = &torrent.library_path else { - return Ok(None); - }; - let Some(first_author) = torrent.meta.authors.first() else { - return Ok(None); - }; - let resp: LibrariesResponse = self - .client - .get(format!("{}/api/libraries", self.base_url)) - .send() - .await? - .error_for_status()? - .json() - .await?; - let libraries = resp.libraries.into_iter().filter(|l| { - l.folders - .iter() - .any(|f| library_path.starts_with(&f.full_path)) - }); - - for library in libraries { - let mut url: Url = format!("{}/api/libraries/{}/search", self.base_url, library.id) - .parse() - .unwrap(); - url.query_pairs_mut().append_pair("q", first_author); - let resp = self - .client - .get(url) - .send() - .await? - .error_for_status()? - .text() - .await?; - - let resp: SearchResponse = serde_json::from_str(&resp).map_err(|err| { - error!("Error parsing ABS response: {err}\nResponse: {resp}"); - err - })?; - - for author in resp.authors { - let url: Url = format!("{}/api/authors/{}?include=items", self.base_url, author.id) - .parse() - .unwrap(); - let resp = self - .client - .get(url) - .send() - .await? - .error_for_status()? - .text() - .await?; - - let resp: AuthorResponse = serde_json::from_str(&resp).map_err(|err| { - error!("Error parsing ABS response: {err}\nResponse: {resp}"); - err - })?; - - let Some(book) = resp - .library_items - .into_iter() - .find(|b| &PathBuf::from(&b.path) == library_path) - else { - continue; - }; - - return Ok(Some(book)); - } - } - - Ok(None) - } - - pub async fn update_book( - &self, - id: &str, - mam_torrent: &MaMTorrent, - meta: &TorrentMeta, - ) -> Result<()> { - let (title, subtitle) = parse_titles(meta); - let (isbn, asin) = parse_isbn(mam_torrent); - - self.client - .patch(format!("{}/api/items/{id}/media", self.base_url)) - .header("Content-Type", "application/json") - .body(serde_json::to_string(&MediaUpdate { - metadata: MetadataUpdate { - title, - subtitle, - authors: meta - .authors - .iter() - .map(|name| AuthorUpdate { name }) - .collect(), - series: meta - .series - .iter() - .map(|series| SeriesUpdate { - name: &series.name, - sequence: if series.entries.0.is_empty() { - None - } else { - Some(format!("{}", series.entries)) - }, - }) - .collect(), - narrators: meta.narrators.iter().map(|name| name.as_str()).collect(), - description: mam_torrent.description.as_deref(), - isbn, - asin, - genres: meta - .cat - .as_ref() - .map(|c| c.as_str()) - .into_iter() - .chain(meta.categories.iter().map(|c| c.as_str())) - .collect::>(), - language: meta.language.map(|l| l.to_str()), - explicit: meta - .flags - .is_some_and(|f| Flags::from_bitfield(f.0).explicit == Some(true)), - abridged: meta - .flags - .is_some_and(|f| Flags::from_bitfield(f.0).abridged == Some(true)), - }, - })?) - .send() - .await? - .error_for_status()?; - - Ok(()) - } - - pub async fn delete_book(&self, id: &str) -> Result<()> { - self.client - .delete(format!("{}/api/items/{id}", self.base_url)) - .send() - .await? - .error_for_status()?; - - Ok(()) - } -} - -pub fn create_metadata(mam_torrent: &MaMTorrent, meta: &TorrentMeta) -> serde_json::Value { - let (title, subtitle) = parse_titles(meta); - let (isbn, asin) = parse_isbn(mam_torrent); - let flags = Flags::from_bitfield(meta.flags.map_or(0, |f| f.0)); - - let metadata = json!({ - "authors": &meta.authors, - "narrators": &meta.narrators, - "series": &meta.series.iter().map(format_serie).collect::>(), - "title": title, - "subtitle": subtitle, - "description": mam_torrent.description, - "isbn": isbn, - "asin": asin, - "tags": if flags.lgbt == Some(true) { Some(vec!["LGBT"]) } else { None }, - "genres": meta - .cat - .as_ref() - .map(|c| c.as_str()) - .into_iter() - .chain(meta.categories.iter().map(|c| c.as_str())) - .collect::>(), - "language": meta.language.map(|l| l.to_str()), - "explicit": flags.explicit == Some(true), - "abridged": flags.abridged == Some(true), - }); - - metadata -} - -fn parse_titles(meta: &TorrentMeta) -> (&str, Option<&str>) { - let mut titles = meta.title.splitn(2, ":"); - let mut title = titles.next().unwrap(); - let mut subtitle = titles.next().map(|t| t.trim()); - if title.len() < 4 { - title = &meta.title; - subtitle = None; - } - - (title, subtitle) -} - -fn parse_isbn(mam_torrent: &MaMTorrent) -> (Option<&str>, Option<&str>) { - let isbn_raw: &str = mam_torrent.isbn.as_deref().unwrap_or(""); - let isbn = if isbn_raw.is_empty() || isbn_raw.starts_with("ASIN:") { - None - } else { - Some(isbn_raw.trim()) - }; - let asin = isbn_raw.strip_prefix("ASIN:").map(|s| s.trim()); - - (isbn, asin) -} diff --git a/server/src/autograbber.rs b/server/src/autograbber.rs deleted file mode 100644 index 66b520aa..00000000 --- a/server/src/autograbber.rs +++ /dev/null @@ -1,829 +0,0 @@ -use std::{ - fs::File, - io::{BufWriter, Write as _}, - ops::RangeInclusive, - sync::Arc, - time::Duration, -}; - -use anyhow::{Context, Error, Result}; -use itertools::Itertools as _; -use lava_torrent::torrent::v1::Torrent; -use mlm_db::{ - ClientStatus, DatabaseExt as _, DuplicateTorrent, Event, EventType, MetadataSource, - SelectedTorrent, Timestamp, TorrentCost, TorrentKey, TorrentMeta, VipStatus, -}; -use mlm_mam::{ - api::MaM, - enums::{SearchKind, SearchTarget}, - meta::MetaError, - search::{MaMTorrent, SearchFields, SearchQuery, SearchResult, Tor}, - serde::DATE_FORMAT, -}; -use mlm_parse::normalize_title; -use native_db::{Database, db_type, transaction::RwTransaction}; -use tokio::{ - fs, - sync::{MutexGuard, watch::Sender}, - time::sleep, -}; -use tracing::{Level, debug, enabled, error, info, instrument, trace, warn}; -use uuid::Uuid; - -use crate::{ - audiobookshelf::{self as abs, Abs}, - config::{Config, Cost, SortBy, TorrentFilter, TorrentSearch, Type}, - logging::write_event, - torrent_downloader::get_mam_torrent_file, -}; - -static AUTOGRABBER_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -#[instrument(skip_all)] -pub async fn run_autograbber( - config: Arc, - db: Arc>, - mam: Arc>, - autograb_trigger: Sender<()>, - index: usize, - autograb_config: Arc, -) -> Result<()> { - // Make sure we are only running one autograbber at a time - let _guard = AUTOGRABBER_MUTEX.lock().await; - - let user_info = mam.user_info().await?; - let max_torrents = user_info.unsat.limit.saturating_sub(user_info.unsat.count); - let name = autograb_config - .filter - .name - .clone() - .unwrap_or_else(|| index.to_string()); - debug!( - "autograbber {}, unsats: {:#?}; max_torrents: {max_torrents}", - name, user_info.unsat - ); - - let unsat_buffer = autograb_config.unsat_buffer.unwrap_or(config.unsat_buffer); - let mut max_torrents = max_torrents.saturating_sub(unsat_buffer); - - if max_torrents > 0 - && let Some(max_active_downloads) = autograb_config.max_active_downloads - { - let r = db.r_transaction()?; - let downloading_torrents = r - .scan() - .primary::()? - .all()? - .filter(|t| { - t.as_ref() - .is_ok_and(|t| t.grabber.as_ref() == Some(&name) && t.removed_at.is_none()) - }) - .count() as u64; - max_torrents = max_torrents.min(max_active_downloads.saturating_sub(downloading_torrents)); - } - - if max_torrents > 0 - || autograb_config.cost == Cost::MetadataOnly - || autograb_config.cost == Cost::MetadataOnlyAdd - { - search_and_select_torrents( - &config, - &db, - &autograb_config, - SearchFields { - dl_link: true, - ..Default::default() - }, - &mam, - max_torrents, - ) - .await - .context("search_torrents")?; - } - - if !config.qbittorrent.is_empty() { - autograb_trigger.send(())?; - } - - Ok(()) -} - -#[instrument(skip_all)] -pub async fn search_and_select_torrents( - config: &Config, - db: &Database<'_>, - torrent_search: &TorrentSearch, - fields: SearchFields, - mam: &MaM<'_>, - max_torrents: u64, -) -> Result { - let torrents = search_torrents(torrent_search, fields, mam) - .await - .context("search_torrents")?; - - if torrent_search.mark_removed { - let torrents = torrents.collect::>(); - mark_removed_torrents(db, mam, &torrents) - .await - .context("mark_removed_torrents")?; - - return select_torrents( - config, - db, - mam, - torrents.into_iter(), - &torrent_search.filter, - torrent_search.cost, - torrent_search.unsat_buffer, - torrent_search.wedge_buffer, - torrent_search.category.clone(), - torrent_search.dry_run, - max_torrents, - None, - ) - .await - .context("select_torrents"); - } - - select_torrents( - config, - db, - mam, - torrents, - &torrent_search.filter, - torrent_search.cost, - torrent_search.unsat_buffer, - torrent_search.wedge_buffer, - torrent_search.category.clone(), - torrent_search.dry_run, - max_torrents, - None, - ) - .await - .context("select_torrents") -} - -#[instrument(skip_all)] -pub async fn search_torrents( - torrent_search: &TorrentSearch, - fields: SearchFields, - mam: &MaM<'_>, -) -> Result> { - let target = match torrent_search.kind { - Type::Bookmarks => Some(SearchTarget::Bookmarks), - Type::Mine => Some(SearchTarget::Mine), - Type::Uploader(id) => Some(SearchTarget::Uploader(id)), - _ => None, - }; - let kind = match (torrent_search.kind, torrent_search.cost) { - (Type::Freeleech, _) => Some(SearchKind::Freeleech), - (_, Cost::Free) => Some(SearchKind::Free), - _ => None, - }; - let sort_type = torrent_search - .sort_by - .map(|sort_by| match sort_by { - SortBy::LowSeeders => "seedersAsc", - SortBy::LowSnatches => "snatchedAsc", - SortBy::OldestFirst => "dateAsc", - SortBy::Random => "random", - }) - .unwrap_or(match torrent_search.kind { - Type::New => "dateDesc", - _ => "", - }); - let (flags_is_hide, flags) = torrent_search.filter.flags.as_search_bitfield(); - let max_pages = torrent_search - .max_pages - .unwrap_or(match torrent_search.kind { - Type::Bookmarks | Type::Freeleech | Type::Mine => 50, - _ => 0, - }); - - let mut results: Option = None; - for page in 1.. { - let mut page_results = mam - .search(&SearchQuery { - fields, - perpage: 100, - tor: Tor { - start_number: results.as_ref().map_or(0, |r| r.data.len() as u64), - target, - kind, - text: torrent_search.query.clone().unwrap_or_default(), - srch_in: torrent_search.search_in.clone(), - main_cat: torrent_search.filter.categories.get_main_cats(), - cat: torrent_search.filter.categories.get_cats(), - browse_lang: torrent_search - .filter - .languages - .iter() - .map(|l| l.to_id()) - .collect(), - browse_flags_hide_vs_show: if flags.is_empty() { - None - } else { - Some(if flags_is_hide { 0 } else { 1 }) - }, - browse_flags: flags.clone(), - start_date: torrent_search - .filter - .uploaded_after - .map_or_else(|| Ok(String::new()), |d| d.format(&DATE_FORMAT))?, - end_date: torrent_search - .filter - .uploaded_before - .map_or_else(|| Ok(String::new()), |d| d.format(&DATE_FORMAT))?, - min_size: torrent_search.filter.min_size.bytes(), - max_size: torrent_search.filter.max_size.bytes(), - unit: torrent_search - .filter - .min_size - .unit() - .max(torrent_search.filter.max_size.unit()), - min_seeders: torrent_search.filter.min_seeders, - max_seeders: torrent_search.filter.max_seeders, - min_leechers: torrent_search.filter.min_leechers, - max_leechers: torrent_search.filter.max_leechers, - min_snatched: torrent_search.filter.min_snatched, - max_snatched: torrent_search.filter.max_snatched, - sort_type: sort_type.to_string(), - ..Default::default() - }, - - ..Default::default() - }) - .await - .context("search")?; - - debug!( - "result: perpage: {}, start: {}, data: {}, total: {}, found: {}", - page_results.perpage, - page_results.start, - page_results.data.len(), - page_results.total, - page_results.found - ); - - if page_results.data.is_empty() { - if results.is_none() { - results = Some(page_results); - } - break; - } - - if enabled!(Level::TRACE) { - trace!( - "torrents in result: {:?}", - page_results.data.iter().map(|t| t.id).collect::>() - ) - } - if let Some(results) = &mut results { - results.data.append(&mut page_results.data); - } else { - results = Some(page_results); - } - - let results = results.as_ref().unwrap(); - if page >= max_pages || results.data.len() >= results.found { - break; - } - sleep(Duration::from_millis(400)).await; - } - - let torrents = results - .unwrap() - .data - .into_iter() - .filter(|t| torrent_search.filter.matches(t)); - - Ok(torrents) -} - -#[instrument(skip_all)] -pub async fn mark_removed_torrents( - db: &Database<'_>, - mam: &MaM<'_>, - torrents: &[MaMTorrent], -) -> Result<()> { - if let (Some(first), Some(last)) = (torrents.first(), torrents.last()) { - let ids = first.id.min(last.id)..=first.id.max(last.id); - for id in ids { - let is_removed = torrents.iter().all(|t| t.id != id); - if is_removed { - let (guard, rw) = db.rw_async().await?; - let torrent = rw - .get() - .secondary::(TorrentKey::mam_id, id)?; - if let Some(mut torrent) = torrent - && torrent.client_status != Some(ClientStatus::RemovedFromMam) - { - if mam.get_torrent_info_by_id(id).await?.is_none() { - torrent.client_status = Some(ClientStatus::RemovedFromMam); - let tid = Some(torrent.id.clone()); - rw.upsert(torrent)?; - rw.commit()?; - drop(guard); - write_event(db, Event::new(tid, Some(id), EventType::RemovedFromMam)).await; - } - sleep(Duration::from_millis(400)).await; - } - } - } - } - Ok(()) -} - -#[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -pub async fn select_torrents>( - config: &Config, - db: &Database<'_>, - mam: &MaM<'_>, - torrents: T, - grabber: &TorrentFilter, - cost: Cost, - unsat_buffer: Option, - wedge_buffer: Option, - filter_category: Option, - dry_run: bool, - max_torrents: u64, - goodreads_id: Option, -) -> Result { - let mut selected_torrents = 0; - 'torrent: for torrent in torrents { - if config.ignore_torrents.contains(&torrent.id) { - trace!("Torrent {} is ignored", torrent.id); - continue; - } - - let meta = match torrent.as_meta() { - Ok(it) => it, - Err(err) => match err { - MetaError::UnknownMediaType(_) => { - warn!("{err} for torrent {} {}", torrent.id, torrent.title); - continue; - } - _ => return Err(err.into()), - }, - }; - let rw_opt = if dry_run { - None - } else { - Some(db.rw_async().await?) - }; - if let Some((_, rw)) = &rw_opt - && let Some(old_selected) = rw - .get() - .primary::(torrent.id) - .ok() - .flatten() - { - if let Some(unsat_buffer) = unsat_buffer - && old_selected.unsat_buffer.is_none_or(|u| unsat_buffer < u) - { - let mut updated = old_selected.clone(); - updated.unsat_buffer = Some(unsat_buffer); - if updated.meta != meta { - update_selected_torrent_meta(db, rw_opt.unwrap(), mam, updated, meta).await?; - } else { - rw.update(old_selected, updated)?; - rw_opt.unwrap().1.commit()?; - } - } else if old_selected.meta != meta { - update_selected_torrent_meta(db, rw_opt.unwrap(), mam, old_selected, meta).await?; - } - trace!("Torrent {} is already selected", torrent.id); - continue; - } - if let Some((_, rw)) = &rw_opt { - let old_library = rw - .get() - .secondary::(TorrentKey::mam_id, meta.mam_id)?; - if let Some(old) = old_library { - if old.meta != meta - || (cost == Cost::MetadataOnlyAdd - && old.linker.is_none() - && !torrent.owner_name.is_empty()) - { - update_torrent_meta( - config, - db, - rw_opt.unwrap(), - &torrent, - old, - meta, - false, - cost == Cost::MetadataOnlyAdd, - ) - .await?; - } - trace!("Torrent {} is already in library", torrent.id); - continue 'torrent; - } - } - if cost == Cost::MetadataOnlyAdd { - let mam_id = meta.mam_id; - add_metadata_only_torrent(rw_opt.unwrap(), torrent, meta) - .await - .or_else(|err| { - let err = err.downcast::()?; - if let db_type::Error::DuplicateKey { .. } = err { - warn!("Got dup key when adding torrent {}", mam_id); - Result::<(), anyhow::Error>::Ok(()) - } else { - Err(err.into()) - } - })?; - continue 'torrent; - } - if cost == Cost::MetadataOnly { - continue 'torrent; - } - let title_search = normalize_title(&meta.title); - let preferred_types = config.preferred_types(&meta.media_type); - let preference = preferred_types - .iter() - .position(|t| meta.filetypes.contains(t)); - if preference.is_none() { - debug!( - "Could not find any wanted formats in torrent {}, formats: {:?}, wanted: {:?}", - meta.mam_id, meta.filetypes, preferred_types - ); - continue 'torrent; - } - if let Some((_, rw)) = &rw_opt { - let old_selected = { - rw.scan() - .secondary::(mlm_db::SelectedTorrentKey::title_search)? - .range::>(title_search.as_str()..=title_search.as_str())? - .collect::, native_db::db_type::Error>>() - }?; - for old in old_selected { - if old.mam_id == meta.mam_id { - if old.meta != meta { - update_selected_torrent_meta(db, rw_opt.unwrap(), mam, old, meta).await?; - } - trace!("Torrent {} is already selected2", torrent.id); - continue 'torrent; - } - trace!( - "Checking old torrent {} with formats {:?}", - old.title_search, old.meta.filetypes - ); - if meta.matches(&old.meta) { - let old_preference = preferred_types - .iter() - .position(|t| old.meta.filetypes.contains(t)); - if old_preference <= preference { - if let Err(err) = - add_duplicate_torrent(rw, None, torrent.dl.clone(), title_search, meta) - { - error!("Error writing duplicate torrent: {err}"); - } - rw_opt.unwrap().1.commit()?; - trace!( - "Skipping torrent {} as we have {} selected", - torrent.id, old.meta.mam_id - ); - continue 'torrent; - } else { - if let Err(err) = add_duplicate_torrent( - rw, - None, - torrent.dl.clone(), - title_search.clone(), - old.meta.clone(), - ) { - error!("Error writing duplicate torrent: {err}"); - } - info!( - "Unselecting torrent \"{}\" with formats {:?}", - old.meta.title, old.meta.filetypes - ); - rw.remove(old)?; - } - } - } - } - if let Some((_, rw)) = &rw_opt { - let old_library = { - rw.scan() - .secondary::(mlm_db::TorrentKey::title_search)? - .range::>(title_search.as_str()..=title_search.as_str())? - .collect::, native_db::db_type::Error>>() - }?; - for old in old_library { - if old.meta.mam_id == meta.mam_id { - if old.meta != meta { - update_torrent_meta( - config, - db, - rw_opt.unwrap(), - &torrent, - old, - meta, - false, - false, - ) - .await?; - } - trace!("Torrent {} is already in library2", torrent.id); - continue 'torrent; - } - trace!( - "Checking old torrent {} with formats {:?}", - old.title_search, old.meta.filetypes - ); - if meta.matches(&old.meta) { - let old_preference = preferred_types - .iter() - .position(|t| old.meta.filetypes.contains(t)); - if old_preference <= preference { - if let Err(err) = add_duplicate_torrent( - rw, - Some(old.id), - torrent.dl.clone(), - title_search, - meta, - ) { - error!("Error writing duplicate torrent: {err}"); - } - rw_opt.unwrap().1.commit()?; - trace!( - "Skipping torrent {} as we have {} in libary", - torrent.id, old.meta.mam_id - ); - continue 'torrent; - } else { - info!( - "Selecting replacement for library torrent \"{}\" with formats {:?}", - old.meta.title, old.meta.filetypes - ); - } - } - } - } - let tags: Vec<_> = config - .tags - .iter() - .filter(|t| t.filter.matches(&torrent)) - .collect(); - let category = filter_category - .clone() - .or_else(|| tags.iter().find_map(|t| t.category.clone())); - let tags = tags.iter().flat_map(|t| t.tags.clone()).collect(); - let cost = if torrent.vip { - TorrentCost::Vip - } else if torrent.personal_freeleech { - TorrentCost::PersonalFreeleech - } else if torrent.free { - TorrentCost::GlobalFreeleech - } else if cost == Cost::Wedge { - TorrentCost::UseWedge - } else if cost == Cost::TryWedge { - TorrentCost::TryWedge - } else { - TorrentCost::Ratio - }; - info!( - "Selecting torrent \"{}\" in format {}, cost: {:?}, with category {:?} and tags {:?}", - torrent.title, torrent.filetype, cost, category, tags - ); - if let Some((_, rw)) = &rw_opt { - selected_torrents += 1; - rw.insert(mlm_db::SelectedTorrent { - mam_id: torrent.id, - goodreads_id, - hash: None, - dl_link: torrent - .dl - .clone() - .ok_or_else(|| Error::msg(format!("no dl field for torrent {}", torrent.id)))?, - unsat_buffer, - wedge_buffer, - cost, - category, - tags, - title_search, - meta, - grabber: grabber.name.clone(), - created_at: Timestamp::now(), - started_at: None, - removed_at: None, - })?; - rw_opt.unwrap().1.commit()?; - if selected_torrents >= max_torrents { - break; - } - } - } - - Ok(selected_torrents) -} - -#[instrument(skip_all)] -pub async fn add_metadata_only_torrent( - (_guard, rw): (MutexGuard<'_, ()>, RwTransaction<'_>), - torrent: MaMTorrent, - meta: TorrentMeta, -) -> Result<()> { - info!("Adding metadata only torrent \"{}\"", meta.title); - let id = Uuid::new_v4().to_string(); - - let mam_id = torrent.id; - { - rw.insert(mlm_db::Torrent { - id, - id_is_hash: false, - mam_id, - abs_id: None, - goodreads_id: None, - library_path: None, - library_files: Default::default(), - linker: if torrent.owner_name.is_empty() { - None - } else { - Some(torrent.owner_name) - }, - category: None, - selected_audio_format: None, - selected_ebook_format: None, - title_search: normalize_title(&meta.title), - meta, - created_at: Timestamp::now(), - replaced_with: None, - request_matadata_update: false, - library_mismatch: None, - client_status: None, - })?; - rw.commit()?; - } - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -pub async fn update_torrent_meta( - config: &Config, - db: &Database<'_>, - (guard, rw): (MutexGuard<'_, ()>, RwTransaction<'_>), - mam_torrent: &MaMTorrent, - mut torrent: mlm_db::Torrent, - meta: TorrentMeta, - allow_non_mam: bool, - linker_is_owner: bool, -) -> Result<()> { - if !allow_non_mam && torrent.meta.source != MetadataSource::Mam { - // Update VIP status and uploaded_at still - if torrent.meta.vip_status != meta.vip_status - || torrent.meta.uploaded_at != meta.uploaded_at - { - torrent.meta.vip_status = meta.vip_status; - torrent.meta.uploaded_at = meta.uploaded_at; - rw.upsert(torrent.clone())?; - rw.commit()?; - } - return Ok(()); - } - - // Check expiring VIP - if torrent.meta.vip_status != meta.vip_status - && torrent - .meta - .vip_status - .as_ref() - .is_some_and(|s| !s.is_vip()) - && meta.vip_status == Some(VipStatus::NotVip) - { - torrent.meta.vip_status = meta.vip_status.clone(); - // If expiring VIP was the only change, just silently update the database - if torrent.meta == meta { - rw.upsert(torrent.clone())?; - rw.commit()?; - return Ok(()); - } - } - - // Check uploaded_at and num_files - if torrent.meta.uploaded_at != meta.uploaded_at || torrent.meta.num_files != meta.num_files { - torrent.meta.uploaded_at = meta.uploaded_at; - torrent.meta.num_files = meta.num_files; - // If uploaded_at or num_files was the only change, just silently update the database - if torrent.meta == meta { - rw.upsert(torrent.clone())?; - rw.commit()?; - return Ok(()); - } - } - - if linker_is_owner && torrent.linker.is_none() { - torrent.linker = Some(mam_torrent.owner_name.clone()); - } - - let id = torrent.id.clone(); - let mam_id = meta.mam_id; - let diff = torrent.meta.diff(&meta); - debug!( - "Updating meta for torrent {}, diff:\n{}", - mam_id, - diff.iter() - .map(|field| format!(" {}: {} → {}", field.field, field.from, field.to)) - .join("\n") - ); - torrent.meta = meta.clone(); - torrent.title_search = normalize_title(&meta.title); - rw.upsert(torrent.clone())?; - rw.commit()?; - drop(guard); - - if let Some(library_path) = &torrent.library_path - && let serde_json::Value::Object(new) = abs::create_metadata(mam_torrent, &meta) - { - let metadata_path = library_path.join("metadata.json"); - if metadata_path.exists() { - let existing = fs::read_to_string(&metadata_path).await?; - let mut existing: serde_json::Map = - serde_json::from_str(&existing)?; - for (key, value) in new { - existing.insert(key, value); - } - let file = File::create(&metadata_path)?; - let mut writer = BufWriter::new(file); - serde_json::to_writer(&mut writer, &serde_json::Value::Object(existing))?; - writer.flush()?; - debug!("updated ABS metadata file {}", torrent.meta.mam_id); - } - if let (Some(abs_id), Some(abs_config)) = (&torrent.abs_id, &config.audiobookshelf) { - let abs = Abs::new(abs_config)?; - match abs.update_book(abs_id, mam_torrent, &meta).await { - Ok(_) => debug!("updated ABS via API {}", torrent.meta.mam_id), - Err(err) => warn!("Failed updating book {} in abs: {err}", torrent.meta.mam_id), - } - } - } - - if !diff.is_empty() { - write_event( - db, - Event::new(Some(id), Some(mam_id), EventType::Updated { fields: diff }), - ) - .await; - } - Ok(()) -} - -async fn update_selected_torrent_meta( - db: &Database<'_>, - (guard, rw): (MutexGuard<'_, ()>, RwTransaction<'_>), - mam: &MaM<'_>, - torrent: SelectedTorrent, - meta: TorrentMeta, -) -> Result<()> { - let mam_id = meta.mam_id; - let diff = torrent.meta.diff(&meta); - debug!( - "Updating meta for selected torrent {}, diff:\n{}", - mam_id, - diff.iter() - .map(|field| format!(" {}: {} -> {}", field.field, field.from, field.to)) - .join("\n") - ); - let hash = get_mam_torrent_hash(mam, &torrent.dl_link).await.ok(); - let mut torrent = torrent; - torrent.meta = meta; - rw.upsert(torrent)?; - rw.commit()?; - drop(guard); - write_event( - db, - Event::new(hash, Some(mam_id), EventType::Updated { fields: diff }), - ) - .await; - Ok(()) -} - -pub async fn get_mam_torrent_hash(mam: &MaM<'_>, dl_link: &str) -> Result { - let torrent_file_bytes = get_mam_torrent_file(mam, dl_link).await?; - let torrent_file = Torrent::read_from_bytes(torrent_file_bytes.clone())?; - let hash = torrent_file.info_hash(); - Ok(hash) -} - -fn add_duplicate_torrent( - rw: &RwTransaction<'_>, - duplicate_of: Option, - dl_link: Option, - title_search: String, - meta: TorrentMeta, -) -> Result<()> { - rw.upsert(DuplicateTorrent { - mam_id: meta.mam_id, - dl_link, - title_search, - meta, - created_at: Timestamp::now(), - duplicate_of, - })?; - Ok(()) -} diff --git a/server/src/cleaner.rs b/server/src/cleaner.rs deleted file mode 100644 index de1ba4b0..00000000 --- a/server/src/cleaner.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::{fs, io::ErrorKind, mem, ops::Deref, sync::Arc}; - -use anyhow::Result; -use mlm_db::{ - self, DatabaseExt as _, ErroredTorrentId, Event, EventType, Timestamp, Torrent, TorrentKey, -}; -use native_db::Database; -use tracing::{debug, info, instrument, trace, warn}; - -use crate::{ - audiobookshelf::Abs, - config::Config, - linker::file_size, - logging::{TorrentMetaError, update_errored_torrent, write_event}, - qbittorrent::ensure_category_exists, -}; - -#[instrument(skip_all)] -pub async fn run_library_cleaner(config: Arc, db: Arc>) -> Result<()> { - let torrents: Vec = { - let r = db.r_transaction()?; - let torrents = r.scan().secondary::(TorrentKey::title_search)?; - torrents - .all()? - .filter_map(|t| t.ok()) - .filter(|t| t.library_path.is_some()) - .collect() - }; - let mut batch: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if !current.matches(&torrent) { - process_batch(&config, &db, mem::take(&mut batch)).await?; - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - process_batch(&config, &db, batch).await?; - - Ok(()) -} - -#[instrument(skip_all)] -async fn process_batch(config: &Config, db: &Database<'_>, batch: Vec) -> Result<()> { - if batch.len() == 1 { - return Ok(()); - }; - let mut batch = batch - .into_iter() - .map(|torrent| { - let preferred_types = config.preferred_types(&torrent.meta.media_type); - let preference = preferred_types - .iter() - .position(|t| torrent.meta.filetypes.contains(t)) - .unwrap_or(usize::MAX); - (torrent, preference) - }) - .collect::>(); - batch.sort_by_key(|(_, preference)| *preference); - if batch[0].1 == batch[1].1 { - trace!( - "need to compare torrent \"{}\" and \"{}\" by size", - batch[0].0.meta.title, batch[1].0.meta.title - ); - let mut new_batch = batch - .into_iter() - .map(|(torrent, preference)| { - let mut size = 0; - if let Some(library_path) = &torrent.library_path { - for file in &torrent.library_files { - let path = library_path.join(file); - size += fs::metadata(path).map_or(0, |s| file_size(&s)); - } - } - (torrent, preference, size) - }) - .collect::>(); - new_batch.sort_by(|a, b| a.1.cmp(&b.1).then(b.2.cmp(&a.2))); - trace!("new_batch {:?}", new_batch); - batch = new_batch - .into_iter() - .map(|(torrent, preference, _)| (torrent, preference)) - .collect(); - } - let (keep, _) = batch.remove(0); - for (mut remove, _) in batch { - info!( - "Replacing library torrent \"{}\" {} with {}", - remove.meta.title, remove.meta.mam_id, keep.meta.mam_id - ); - remove.replaced_with = Some((keep.id.clone(), Timestamp::now())); - let result = clean_torrent( - config, - db, - remove.clone(), - keep.library_path.is_some() && keep.library_path != remove.library_path, - ) - .await - .map_err(|err| anyhow::Error::new(TorrentMetaError(remove.meta.clone(), err))); - update_errored_torrent( - db, - ErroredTorrentId::Cleaner(remove.id), - remove.meta.title, - result, - ) - .await - } - - Ok(()) -} - -#[instrument(skip_all)] -pub async fn clean_torrent( - config: &Config, - db: &Database<'_>, - mut remove: Torrent, - delete_in_abs: bool, -) -> Result<()> { - if remove.id_is_hash { - for qbit_conf in config.qbittorrent.iter() { - if let Some(on_cleaned) = &qbit_conf.on_cleaned { - let qbit = qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await?; - - if let Some(category) = &on_cleaned.category { - ensure_category_exists(&qbit, &qbit_conf.url, category).await?; - qbit.set_category(Some(vec![&remove.id]), category).await?; - } - - if !on_cleaned.tags.is_empty() { - qbit.add_tags( - Some(vec![&remove.id]), - on_cleaned.tags.iter().map(Deref::deref).collect(), - ) - .await?; - } - } - trace!("qbit updated"); - } - } - - remove_library_files(config, &remove, delete_in_abs).await?; - - let id = remove.id.clone(); - let mam_id = remove.meta.mam_id; - let library_path = remove.library_path.take(); - let mut library_files = remove.library_files.clone(); - remove.library_mismatch = None; - remove.abs_id = None; - library_files.sort(); - { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(remove)?; - rw.commit()?; - } - - if let Some(library_path) = library_path { - write_event( - db, - Event::new( - Some(id), - Some(mam_id), - EventType::Cleaned { - library_path, - files: library_files, - }, - ), - ) - .await; - } - - Ok(()) -} - -#[instrument(skip_all)] -pub async fn remove_library_files( - config: &Config, - remove: &Torrent, - delete_in_abs: bool, -) -> Result<()> { - if delete_in_abs - && let (Some(abs_id), Some(abs_config)) = (&remove.abs_id, &config.audiobookshelf) - { - let abs = Abs::new(abs_config)?; - if let Err(err) = abs.delete_book(abs_id).await { - warn!("Failed deleting book from abs: {err}"); - } - } - - if let Some(library_path) = &remove.library_path { - debug!("Removing library files for torrent {}", remove.meta.mam_id); - for file in remove.library_files.iter() { - let path = library_path.join(file); - fs::remove_file(path).or_else(|err| { - if err.kind() == ErrorKind::NotFound { - trace!("file already missing"); - Ok(()) - } else { - Err(err) - } - })?; - if let Some(sub_dir) = file.parent() { - fs::remove_dir(sub_dir).ok(); - } - } - let mut remove_files = true; - let mut files_to_remove = vec![]; - if let Ok(files) = fs::read_dir(library_path) { - for file in files { - if let Ok(file) = file { - if file.file_name() == "cover.jpg" || file.file_name() == "metadata.json" { - files_to_remove.push(file); - } else { - remove_files = false; - } - } else { - remove_files = false; - } - } - if remove_files { - for file in files_to_remove { - fs::remove_file(file.path()).ok(); - } - } - } - fs::remove_dir(library_path).ok(); - trace!("files removed"); - } - - Ok(()) -} diff --git a/server/src/config.rs b/server/src/config.rs deleted file mode 100644 index 64eba842..00000000 --- a/server/src/config.rs +++ /dev/null @@ -1,388 +0,0 @@ -use std::{collections::BTreeMap, path::PathBuf}; - -use mlm_db::{ - Flags, Language, MediaType, OldDbMainCat, Size, - impls::{parse, parse_opt, parse_vec}, -}; -use mlm_mam::{ - enums::{Categories, SearchIn, SnatchlistType}, - serde::parse_opt_date, -}; -use serde::{Deserialize, Serialize}; -use time::Date; - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Config { - pub mam_id: String, - #[serde(default = "default_host")] - pub web_host: String, - #[serde(default = "default_port")] - pub web_port: u16, - #[serde(default = "default_min_ratio")] - pub min_ratio: f64, - #[serde(default = "default_unsat_buffer")] - pub unsat_buffer: u64, - #[serde(default)] - pub wedge_buffer: u64, - #[serde(default)] - pub add_torrents_stopped: bool, - #[serde(default)] - pub exclude_narrator_in_library_dir: bool, - #[serde(default = "default_search_interval")] - pub search_interval: u64, - #[serde(default = "default_link_interval")] - pub link_interval: u64, - #[serde(default = "default_import_interval", alias = "goodreads_interval")] - pub import_interval: u64, - #[serde(default)] - pub ignore_torrents: Vec, - - #[serde(default = "default_audio_types")] - pub audio_types: Vec, - #[serde(default = "default_ebook_types")] - pub ebook_types: Vec, - #[serde(default = "default_music_types")] - pub music_types: Vec, - #[serde(default = "default_radio_types")] - pub radio_types: Vec, - - #[serde(default)] - pub search: SearchConfig, - pub audiobookshelf: Option, - - #[serde(default)] - #[serde(rename = "autograb")] - pub autograbs: Vec, - #[serde(default)] - pub snatchlist: Vec, - - #[serde(default)] - #[serde(rename = "goodreads_list")] - pub goodreads_lists: Vec, - #[serde(default)] - #[serde(rename = "notion_list")] - pub notion_lists: Vec, - - #[serde(default)] - #[serde(rename = "tag")] - pub tags: Vec, - - #[serde(default)] - pub qbittorrent: Vec, - - #[serde(default)] - #[serde(rename = "library")] - pub libraries: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SearchConfig { - #[serde(deserialize_with = "parse_opt")] - pub wedge_over: Option, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AudiobookShelfConfig { - pub url: String, - pub token: String, - #[serde(default = "default_abs_interval")] - pub interval: u64, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TorrentSearch { - #[serde(rename = "type")] - pub kind: Type, - #[serde(default)] - pub cost: Cost, - pub query: Option, - #[serde(default)] - pub search_in: Vec, - pub sort_by: Option, - pub max_pages: Option, - #[serde(flatten)] - pub filter: TorrentFilter, - - pub search_interval: Option, - pub unsat_buffer: Option, - pub max_active_downloads: Option, - pub wedge_buffer: Option, - #[serde(default)] - pub dry_run: bool, - #[serde(default)] - pub mark_removed: bool, - pub category: Option, -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Type { - Bookmarks, - Freeleech, - New, - Mine, - Uploader(u64), -} - -#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum SortBy { - LowSeeders, - LowSnatches, - OldestFirst, - Random, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct SnatchlistSearch { - #[serde(rename = "type")] - pub kind: SnatchlistType, - #[serde(default)] - pub cost: Cost, - pub max_pages: Option, - #[serde(flatten)] - pub filter: TorrentFilter, - - pub search_interval: Option, - #[serde(default)] - pub dry_run: bool, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct GoodreadsList { - pub url: String, - pub name: Option, - #[serde(default)] - #[serde(deserialize_with = "parse_opt")] - pub prefer_format: Option, - pub grab: Vec, - - pub search_interval: Option, - pub unsat_buffer: Option, - pub wedge_buffer: Option, - #[serde(default)] - pub dry_run: bool, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct NotionList { - pub data_source: String, - pub token: String, - pub name: String, - pub mam_fields: Vec, - pub grab: Vec, - - pub search_interval: Option, - pub unsat_buffer: Option, - pub wedge_buffer: Option, - #[serde(default)] - pub dry_run: bool, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Grab { - #[serde(default)] - pub cost: Cost, - #[serde(flatten)] - pub filter: TorrentFilter, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TagFilter { - #[serde(flatten)] - pub filter: TorrentFilter, - #[serde(default)] - pub category: Option, - #[serde(default)] - pub tags: Vec, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct TorrentFilter { - #[serde(default)] - pub name: Option, - - #[serde(default)] - #[serde(deserialize_with = "parse_vec")] - pub media_type: Vec, - #[serde(default)] - pub categories: Categories, - #[serde(default)] - #[serde(deserialize_with = "parse_vec")] - pub languages: Vec, - #[serde(default)] - pub flags: Flags, - #[serde(default)] - #[serde(deserialize_with = "parse")] - pub min_size: Size, - #[serde(default)] - #[serde(deserialize_with = "parse")] - pub max_size: Size, - #[serde(default)] - pub exclude_uploader: Vec, - - #[serde(default)] - #[serde(deserialize_with = "parse_opt_date")] - pub uploaded_after: Option, - #[serde(default)] - #[serde(deserialize_with = "parse_opt_date")] - pub uploaded_before: Option, - pub min_seeders: Option, - pub max_seeders: Option, - pub min_leechers: Option, - pub max_leechers: Option, - pub min_snatched: Option, - pub max_snatched: Option, -} - -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Cost { - #[default] - Free, - Wedge, - TryWedge, - #[serde(alias = "all")] - Ratio, - MetadataOnly, - MetadataOnlyAdd, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct QbitConfig { - pub url: String, - #[serde(default)] - pub username: String, - #[serde(default)] - pub password: String, - pub on_cleaned: Option, - pub on_invalid_torrent: Option, - #[serde(default)] - pub path_mapping: BTreeMap, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct QbitUpdate { - pub category: Option, - #[serde(default)] - pub tags: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -pub enum Library { - ByDir(LibraryByDir), - ByCategory(LibraryByCategory), -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LibraryByDir { - pub download_dir: PathBuf, - pub library_dir: PathBuf, - #[serde(flatten)] - pub tag_filters: LibraryTagFilters, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LibraryByCategory { - pub category: String, - pub library_dir: PathBuf, - #[serde(flatten)] - pub tag_filters: LibraryTagFilters, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LibraryTagFilters { - #[serde(default)] - pub name: Option, - - #[serde(default)] - pub method: LibraryLinkMethod, - #[serde(default)] - pub allow_tags: Vec, - #[serde(default)] - pub deny_tags: Vec, - pub audio_types: Option>, - pub ebook_types: Option>, -} - -#[derive(Clone, Copy, Debug, Deserialize, Serialize, Default, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum LibraryLinkMethod { - #[default] - Hardlink, - HardlinkOrCopy, - HardlinkOrSymlink, - Copy, - Symlink, - NoLink, -} - -fn default_host() -> String { - "0.0.0.0".to_owned() -} - -fn default_port() -> u16 { - 3157 -} - -fn default_min_ratio() -> f64 { - 2.0 -} - -fn default_unsat_buffer() -> u64 { - 10 -} - -fn default_search_interval() -> u64 { - 30 -} - -fn default_link_interval() -> u64 { - 10 -} - -fn default_import_interval() -> u64 { - 135 -} - -fn default_abs_interval() -> u64 { - 10 -} - -fn default_audio_types() -> Vec { - ["m4b", "m4a", "mp4", "mp3", "ogg"] - .iter() - .map(ToString::to_string) - .collect() -} - -fn default_ebook_types() -> Vec { - ["cbz", "epub", "pdf", "mobi", "azw3", "azw", "cbr"] - .iter() - .map(ToString::to_string) - .collect() -} - -fn default_music_types() -> Vec { - ["pdf", "mp3"].iter().map(ToString::to_string).collect() -} - -fn default_radio_types() -> Vec { - ["mp3"].iter().map(ToString::to_string).collect() -} diff --git a/server/src/config_impl.rs b/server/src/config_impl.rs deleted file mode 100644 index cab9d29c..00000000 --- a/server/src/config_impl.rs +++ /dev/null @@ -1,1227 +0,0 @@ -use std::path::PathBuf; - -use anyhow::{Result, ensure}; -use mlm_db::{Flags, Language, MediaType, OldCategory, Size, Torrent, TorrentMeta}; -use mlm_mam::{search::MaMTorrent, serde::DATE_TIME_FORMAT, user_torrent::UserDetailsTorrent}; -use reqwest::Url; -use time::UtcDateTime; -use tracing::error; - -use crate::config::{ - Config, GoodreadsList, Library, LibraryLinkMethod, LibraryTagFilters, TorrentFilter, -}; - -impl Config { - pub fn preferred_types<'a>(&'a self, media_type: &MediaType) -> &'a [String] { - match media_type { - MediaType::Audiobook => &self.audio_types, - MediaType::Ebook => &self.ebook_types, - MediaType::Musicology => &self.music_types, - MediaType::Radio => &self.radio_types, - MediaType::Manga => &self.ebook_types, - MediaType::ComicBook => &self.ebook_types, - MediaType::PeriodicalEbook => &self.ebook_types, - MediaType::PeriodicalAudiobook => &self.audio_types, - } - } -} - -impl TorrentFilter { - pub fn matches(&self, torrent: &MaMTorrent) -> bool { - if !self.media_type.is_empty() - && MediaType::from_id(torrent.mediatype) - .is_none_or(|media_type| !self.media_type.contains(&media_type)) - { - return false; - } - - if !self.categories.matches(torrent.category) { - return false; - } - - if !self.languages.is_empty() { - if let Some(language) = Language::from_id(torrent.language) { - if !self.languages.contains(&language) { - return false; - } - } else { - error!( - "Failed parsing language \"{}\" for torrent \"{}\"", - torrent.language, torrent.title - ); - return false; - } - } - - let torrent_flags = Flags::from_bitfield(torrent.browseflags); - if !self.flags.matches(&torrent_flags) { - return false; - } - - if self.min_size.bytes() > 0 || self.max_size.bytes() > 0 { - match Size::try_from(torrent.size.clone()) { - Ok(size) => { - if self.min_size.bytes() > 0 && size < self.min_size { - return false; - } - if self.max_size.bytes() > 0 && size > self.max_size { - return false; - } - } - Err(_) => { - error!( - "Failed parsing size \"{}\" for torrent \"{}\"", - torrent.size, torrent.title - ); - return false; - } - }; - } - - if self.exclude_uploader.contains(&torrent.owner_name) { - return false; - } - - if self.uploaded_after.is_some() || self.uploaded_before.is_some() { - match UtcDateTime::parse(&torrent.added, &DATE_TIME_FORMAT) { - Ok(added) => { - if let Some(uploaded_after) = self.uploaded_after { - if added.date() < uploaded_after { - return false; - } - } - if let Some(uploaded_before) = self.uploaded_before { - if added.date() > uploaded_before { - return false; - } - } - } - Err(_) => { - error!( - "Failed parsing added \"{}\" for torrent \"{}\"", - torrent.added, torrent.title - ); - return false; - } - } - } - - if let Some(min_seeders) = self.min_seeders { - if torrent.seeders < min_seeders { - return false; - } - } - if let Some(max_seeders) = self.max_seeders { - if torrent.seeders > max_seeders { - return false; - } - } - if let Some(min_leechers) = self.min_leechers { - if torrent.leechers < min_leechers { - return false; - } - } - if let Some(max_leechers) = self.max_leechers { - if torrent.leechers > max_leechers { - return false; - } - } - if let Some(min_snatched) = self.min_snatched { - if torrent.times_completed < min_snatched { - return false; - } - } - if let Some(max_snatched) = self.max_snatched { - if torrent.times_completed > max_snatched { - return false; - } - } - - true - } - - pub fn matches_user(&self, torrent: &UserDetailsTorrent) -> bool { - if !self.categories.matches(torrent.category) { - return false; - } - - let torrent_flags = Flags::from_bitfield(torrent.browse_flags); - if !self.flags.matches(&torrent_flags) { - return false; - } - - if self.min_size.bytes() > 0 || self.max_size.bytes() > 0 { - match Size::try_from(torrent.size.clone()) { - Ok(size) => { - if self.min_size.bytes() > 0 && size < self.min_size { - return false; - } - if self.max_size.bytes() > 0 && size > self.max_size { - return false; - } - } - Err(_) => { - error!( - "Failed parsing size \"{}\" for torrent \"{}\"", - torrent.size, torrent.title - ); - return false; - } - }; - } - - if self.exclude_uploader.contains(&torrent.uploader_name) { - return false; - } - - if let Some(min_seeders) = self.min_seeders { - if torrent.seeders < min_seeders { - return false; - } - } - if let Some(max_seeders) = self.max_seeders { - if torrent.seeders > max_seeders { - return false; - } - } - if let Some(min_leechers) = self.min_leechers { - if torrent.leechers < min_leechers { - return false; - } - } - if let Some(max_leechers) = self.max_leechers { - if torrent.leechers > max_leechers { - return false; - } - } - if let Some(min_snatched) = self.min_snatched { - if torrent.times_completed < min_snatched { - return false; - } - } - if let Some(max_snatched) = self.max_snatched { - if torrent.times_completed > max_snatched { - return false; - } - } - - true - } - - pub(crate) fn matches_lib(&self, torrent: &Torrent) -> Result { - self.matches_meta(&torrent.meta) - } - - pub(crate) fn matches_meta(&self, meta: &TorrentMeta) -> Result { - if let Some(language) = &meta.language { - if !self.languages.is_empty() && !self.languages.contains(language) { - return Ok(false); - } - } else { - ensure!( - self.languages.is_empty(), - "has language selection and no stored language" - ); - } - if let Some(cat) = &meta.cat { - match cat { - OldCategory::Audio(category) => { - if self - .categories - .audio - .as_ref() - .is_some_and(|cats| !cats.contains(category)) - { - return Ok(false); - } - } - OldCategory::Ebook(category) => { - if self - .categories - .ebook - .as_ref() - .is_some_and(|cats| !cats.contains(category)) - { - return Ok(false); - } - } - OldCategory::Musicology(category) => { - if self - .categories - .musicology - .as_ref() - .is_some_and(|cats| !cats.contains(category)) - { - return Ok(false); - } - } - OldCategory::Radio(category) => { - if self - .categories - .radio - .as_ref() - .is_some_and(|cats| !cats.contains(category)) - { - return Ok(false); - } - } - } - } else { - if self.categories.audio.as_ref().is_some_and(|c| c.is_empty()) - && meta.media_type == MediaType::Audiobook - { - return Ok(false); - } - if self.categories.ebook.as_ref().is_some_and(|c| c.is_empty()) - && meta.media_type == MediaType::Ebook - { - return Ok(false); - } - ensure!( - self.categories.audio.as_ref().is_none_or(|c| c.is_empty()), - "has advanced audio selection and no stored category" - ); - ensure!( - self.categories.ebook.as_ref().is_none_or(|c| c.is_empty()), - "has advanced ebook selection and no stored category" - ); - } - if let Some(flags) = meta.flags { - let flags: Flags = flags.into(); - if !self.flags.matches(&flags) { - return Ok(false); - } - } else { - ensure!( - self.flags.as_bitfield() == 0, - "has flags selection and no stored flags" - ); - } - - ensure!(self.min_size.bytes() == 0, "has min_size"); - ensure!(self.max_size.bytes() == 0, "has max_size"); - ensure!(self.exclude_uploader.is_empty(), "has exclude_uploader"); - ensure!(self.uploaded_after.is_none(), "has uploaded_after"); - ensure!(self.uploaded_before.is_none(), "has uploaded_before"); - ensure!(self.min_seeders.is_none(), "has min_seeders"); - ensure!(self.max_seeders.is_none(), "has max_seeders"); - ensure!(self.min_leechers.is_none(), "has min_leechers"); - ensure!(self.max_leechers.is_none(), "has max_leechers"); - ensure!(self.min_snatched.is_none(), "has min_snatched"); - ensure!(self.max_snatched.is_none(), "has max_snatched"); - - Ok(true) - } -} - -impl GoodreadsList { - pub fn list_id(&self) -> Result { - let link: Url = self.url.parse()?; - let user_id = link - .path_segments() - .iter_mut() - .flatten() - .next_back() - .ok_or(anyhow::Error::msg("Failed to get goodreads user id"))?; - let (_, shelf) = link - .query_pairs() - .find(|(name, _)| name == "shelf") - .unwrap_or_default(); - let list_id = format!("{user_id}:{shelf}"); - Ok(list_id) - } - - pub fn allow_audio(&self) -> bool { - self.grab.iter().any(|g| { - g.filter - .categories - .audio - .as_ref() - .is_none_or(|c| !c.is_empty()) - }) - } - - pub fn allow_ebook(&self) -> bool { - self.grab.iter().any(|g| { - g.filter - .categories - .ebook - .as_ref() - .is_none_or(|c| !c.is_empty()) - }) - } -} - -impl Library { - pub fn method(&self) -> LibraryLinkMethod { - match self { - Library::ByDir(l) => l.tag_filters.method, - Library::ByCategory(l) => l.tag_filters.method, - } - } - - pub fn library_dir(&self) -> &PathBuf { - match self { - Library::ByDir(l) => &l.library_dir, - Library::ByCategory(l) => &l.library_dir, - } - } - - pub fn tag_filters(&self) -> &LibraryTagFilters { - match self { - Library::ByDir(l) => &l.tag_filters, - Library::ByCategory(l) => &l.tag_filters, - } - } -} - -#[cfg(test)] -mod tests { - use mlm_db::{AudiobookCategory, FlagBits, Timestamp, TorrentMeta}; - use time::macros::date; - - use mlm_mam::enums::Categories; - - use super::*; - - #[test] - fn test_uploaded_after() { - let torrent = MaMTorrent { - category: AudiobookCategory::ActionAdventure.to_id() as u64, - added: "2025-07-06 05:40:54".to_owned(), - ..Default::default() - }; - let filter = TorrentFilter { - uploaded_after: Some(date!(2025 - 07 - 05)), - ..Default::default() - }; - assert!(filter.matches(&torrent)); - let filter = TorrentFilter { - uploaded_after: Some(date!(2025 - 07 - 07)), - ..Default::default() - }; - assert!(!filter.matches(&torrent)); - } - - #[test] - fn test_uploaded_after_should_be_inclusive() { - let torrent = MaMTorrent { - category: AudiobookCategory::ActionAdventure.to_id() as u64, - added: "2025-07-06 05:40:54".to_owned(), - ..Default::default() - }; - let filter = TorrentFilter { - uploaded_after: Some(date!(2025 - 07 - 06)), - ..Default::default() - }; - assert!(filter.matches(&torrent)); - } - - #[test] - fn test_uploaded_before() { - let torrent = MaMTorrent { - category: AudiobookCategory::ActionAdventure.to_id() as u64, - added: "2025-07-06 05:40:54".to_owned(), - ..Default::default() - }; - let filter = TorrentFilter { - uploaded_before: Some(date!(2025 - 07 - 07)), - ..Default::default() - }; - assert!(filter.matches(&torrent)); - let filter = TorrentFilter { - uploaded_before: Some(date!(2025 - 07 - 05)), - ..Default::default() - }; - assert!(!filter.matches(&torrent)); - } - - #[test] - fn test_uploaded_before_should_be_inclusive() { - let torrent = MaMTorrent { - category: AudiobookCategory::ActionAdventure.to_id() as u64, - added: "2025-07-06 05:40:54".to_owned(), - ..Default::default() - }; - let filter = TorrentFilter { - uploaded_before: Some(date!(2025 - 07 - 06)), - ..Default::default() - }; - assert!(filter.matches(&torrent)); - } - - mod filter_matches { - use super::*; - - fn create_default_torrent() -> MaMTorrent { - MaMTorrent { - id: 1, - added: "2023-01-20 10:00:00".to_string(), - browseflags: Flags { - violence: Some(true), - ..Default::default() - } - .as_bitfield(), - category: AudiobookCategory::GeneralFiction.to_id() as u64, - language: Language::English.to_id(), - leechers: 5, - owner_name: "TestUploader".to_string(), - seeders: 50, - size: "5 GiB".to_string(), - times_completed: 10, - title: "Test Torrent".to_string(), - ..Default::default() - } - } - - #[test] - fn test_default_filter_matches_all() { - let filter = TorrentFilter::default(); - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "A default filter should match a default torrent." - ); - } - - // --- Category Filtering --- - #[test] - fn test_category_match() { - let filter = TorrentFilter { - categories: Categories { - audio: Some(vec![AudiobookCategory::GeneralFiction]), - ebook: Some(vec![]), - musicology: Some(vec![]), - radio: Some(vec![]), - }, - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!(filter.matches(&torrent), "Should match category"); - } - - #[test] - fn test_category_no_match() { - let filter = TorrentFilter { - categories: Categories { - audio: Some(vec![AudiobookCategory::GeneralNonFic]), - ebook: Some(vec![]), - musicology: Some(vec![]), - radio: Some(vec![]), - }, - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!(!filter.matches(&torrent), "Should not match category"); - } - - // --- Language Filtering --- - #[test] - fn test_language_match() { - let filter = TorrentFilter { - languages: vec![Language::English, Language::German], - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!(filter.matches(&torrent), "Should match English language."); - } - - #[test] - fn test_language_no_match() { - let filter = TorrentFilter { - languages: vec![Language::French, Language::German], - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "Should not match, filter only contains French/German." - ); - } - - #[test] - fn test_language_parse_fail() { - let filter = TorrentFilter { - languages: vec![Language::French], - ..TorrentFilter::default() - }; - let mut torrent = create_default_torrent(); - torrent.language = 99; // Invalid Language - assert!( - !filter.matches(&torrent), - "Should not match if language ID cannot be mapped when a language filter is active." - ); - } - - // --- Flags Filtering --- - #[test] - fn test_flags_match() { - let filter = TorrentFilter { - flags: Flags { - violence: Some(true), - ..Default::default() - }, - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "Should match if torrent has the required flag." - ); - } - - #[test] - fn test_flags_no_match() { - let filter = TorrentFilter { - flags: Flags { - explicit: Some(true), - ..Default::default() - }, - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "Should not match if torrent is missing a required flag." - ); - } - - // --- Size Filtering --- - #[test] - fn test_min_size_match() { - let filter = TorrentFilter { - min_size: Size::from_bytes(1_000_000_000), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "Torrent size 5GB should be >= 1GB min size." - ); - } - - #[test] - fn test_min_size_no_match() { - let filter = TorrentFilter { - min_size: Size::from_bytes(10_000_000_000), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "Torrent size 5GB should be < 10GB min size." - ); - } - - #[test] - fn test_max_size_match() { - let filter = TorrentFilter { - max_size: Size::from_bytes(10_000_000_000), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "Torrent size 5GB should be <= 10GB max size." - ); - } - - #[test] - fn test_max_size_no_match() { - let filter = TorrentFilter { - max_size: Size::from_bytes(1_000_000_000), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "Torrent size 5GB should be > 1GB max size." - ); - } - - #[test] - fn test_size_parsing_failure() { - let filter = TorrentFilter { - min_size: Size::from_bytes(1), - ..TorrentFilter::default() - }; - let mut torrent = create_default_torrent(); - torrent.size = "INVALID_SIZE".to_string(); - assert!( - !filter.matches(&torrent), - "Should fail if torrent size cannot be parsed when size filter is active." - ); - } - - // --- Uploader Exclusion --- - #[test] - fn test_uploader_excluded() { - let filter = TorrentFilter { - exclude_uploader: vec!["BadUploader".to_string(), "TestUploader".to_string()], - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "Should fail if uploader is in the exclusion list." - ); - } - - #[test] - fn test_uploader_not_excluded() { - let filter = TorrentFilter { - exclude_uploader: vec!["BadUploader".to_string()], - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "Should pass if uploader is not in the exclusion list." - ); - } - - // --- Date Filtering --- - #[test] - fn test_uploaded_after_match() { - let filter = TorrentFilter { - uploaded_after: Some(date!(2023 - 01 - 15)), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "2023-01-20 should be >= 2023-01-15" - ); - } - - #[test] - fn test_uploaded_after_no_match() { - let filter = TorrentFilter { - uploaded_after: Some(date!(2023 - 01 - 25)), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "2023-01-20 should be < 2023-01-25" - ); - } - - #[test] - fn test_uploaded_before_match() { - let filter = TorrentFilter { - uploaded_before: Some(date!(2023 - 01 - 25)), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "2023-01-20 should be <= 2023-01-25" - ); - } - - #[test] - fn test_uploaded_before_no_match() { - let filter = TorrentFilter { - uploaded_before: Some(date!(2023 - 01 - 15)), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - !filter.matches(&torrent), - "2023-01-20 should be > 2023-01-15" - ); - } - - #[test] - fn test_date_parsing_failure() { - let filter = TorrentFilter { - uploaded_after: Some(date!(2023 - 01 - 25)), - ..TorrentFilter::default() - }; - let mut torrent = create_default_torrent(); - torrent.added = "INVALID_DATE".to_string(); - assert!( - !filter.matches(&torrent), - "Should fail if the torrent's 'added' date cannot be parsed when date filter is active." - ); - } - - // --- Seeder, Leecher, Snatched Filtering (Stats) --- - // Torrent defaults: seeders: 50, leechers: 5, times_completed: 10 - #[test] - fn test_min_seeders_match() { - let filter = TorrentFilter { - min_seeders: Some(40), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // seeders: 50 - assert!(filter.matches(&torrent), "50 seeders should be >= 40."); - } - - #[test] - fn test_min_seeders_no_match() { - let filter = TorrentFilter { - min_seeders: Some(60), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // seeders: 50 - assert!(!filter.matches(&torrent), "50 seeders should be < 60."); - } - - #[test] - fn test_max_seeders_match() { - let filter = TorrentFilter { - max_seeders: Some(60), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // seeders: 50 - assert!(filter.matches(&torrent), "50 seeders should be <= 60."); - } - - #[test] - fn test_max_seeders_no_match() { - let filter = TorrentFilter { - max_seeders: Some(40), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // seeders: 50 - assert!(!filter.matches(&torrent), "50 seeders should be > 40."); - } - - #[test] - fn test_min_leechers_match() { - let filter = TorrentFilter { - min_leechers: Some(5), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // leechers: 5 - assert!(filter.matches(&torrent), "5 leechers should be >= 5."); - } - - #[test] - fn test_max_leechers_no_match() { - let filter = TorrentFilter { - max_leechers: Some(4), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // leechers: 5 - assert!(!filter.matches(&torrent), "5 leechers should be > 4."); - } - - #[test] - fn test_min_snatched_match() { - let filter = TorrentFilter { - min_snatched: Some(10), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // times_completed: 10 - assert!(filter.matches(&torrent), "10 completions should be >= 10."); - } - - #[test] - fn test_max_snatched_no_match() { - let filter = TorrentFilter { - max_snatched: Some(9), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); // times_completed: 10 - assert!(!filter.matches(&torrent), "10 completions should be > 9."); - } - - // --- Combined Tests --- - #[test] - fn test_combined_success() { - let filter = TorrentFilter { - categories: Categories { - audio: Some(vec![AudiobookCategory::GeneralFiction]), - ebook: Some(vec![]), - musicology: Some(vec![]), - radio: Some(vec![]), - }, - languages: vec![Language::English], - flags: Flags { - violence: Some(true), - ..Default::default() - }, - min_size: Size::from_bytes(1_000_000_000), - max_size: Size::from_bytes(10_000_000_000), - exclude_uploader: vec!["OtherUploader".to_string()], - uploaded_after: Some(date!(2023 - 01 - 15)), - min_seeders: Some(40), - max_leechers: Some(10), - ..TorrentFilter::default() - }; - let torrent = create_default_torrent(); - assert!( - filter.matches(&torrent), - "Should pass when all filters are met." - ); - } - } - - mod filter_matches_lib { - use mlm_db::{MainCat, MediaType, MetadataSource, OldCategory}; - - use super::*; - - fn create_torrent_with_meta(meta: TorrentMeta) -> Torrent { - Torrent { - meta, - - id: "".to_string(), - id_is_hash: false, - mam_id: 0, - abs_id: None, - goodreads_id: None, - library_path: None, - library_files: vec![], - linker: None, - category: None, - selected_audio_format: None, - selected_ebook_format: None, - title_search: "".to_string(), - created_at: Timestamp::now(), - replaced_with: None, - request_matadata_update: false, - library_mismatch: None, - client_status: None, - } - } - - fn default_meta() -> TorrentMeta { - TorrentMeta { - mam_id: 0, - vip_status: None, - media_type: MediaType::Audiobook, - main_cat: Some(MainCat::Fiction), - categories: vec![], - cat: None, - language: None, - flags: None, - filetypes: vec![], - num_files: 0, - size: Size::from_bytes(0), - title: "".to_string(), - edition: None, - authors: vec![], - narrators: vec![], - series: vec![], - source: MetadataSource::Mam, - uploaded_at: Timestamp::now(), - } - } - - fn create_filter_with_audio_cats(cats: Option>) -> TorrentFilter { - TorrentFilter { - categories: Categories { - audio: cats, - ..Default::default() - }, - ..Default::default() - } - } - - // --- Language Filtering Tests --- - #[test] - fn test_lang_match_ok_true() { - let filter = TorrentFilter { - languages: vec![Language::English, Language::French], - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - language: Some(Language::English), - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_lang_mismatch_ok_false() { - let filter = TorrentFilter { - languages: vec![Language::French], - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - language: Some(Language::English), - ..default_meta() - }); - assert!(!filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_lang_filter_active_torrent_none_err() { - let filter = TorrentFilter { - languages: vec![Language::English], - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - language: None, - ..default_meta() - }); - // Should return Err due to `ensure!` failing - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has language selection and no stored language") - ); - } - - #[test] - fn test_lang_filter_inactive_torrent_none_ok_true() { - let filter = TorrentFilter { - languages: vec![], - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - language: None, - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).unwrap()); - } - - // --- Category Filtering Tests --- - #[test] - fn test_audio_cat_match_ok_true() { - let filter = - create_filter_with_audio_cats(Some(vec![AudiobookCategory::GeneralFiction])); - let torrent = create_torrent_with_meta(TorrentMeta { - cat: Some(OldCategory::Audio(AudiobookCategory::GeneralFiction)), - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_audio_cat_mismatch_ok_false() { - let filter = - create_filter_with_audio_cats(Some(vec![AudiobookCategory::GeneralNonFic])); - let torrent = create_torrent_with_meta(TorrentMeta { - cat: Some(OldCategory::Audio(AudiobookCategory::GeneralFiction)), - ..default_meta() - }); - assert!(!filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_cat_filter_inactive_torrent_has_cat_ok_true() { - let filter = TorrentFilter::default(); - let torrent = create_torrent_with_meta(TorrentMeta { - cat: Some(OldCategory::Audio(AudiobookCategory::GeneralNonFic)), - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_no_cat_and_filter_empty_set_audio_ok_false() { - let filter = create_filter_with_audio_cats(Some(vec![])); - let torrent = create_torrent_with_meta(TorrentMeta { - cat: None, - media_type: MediaType::Audiobook, // Main category matches - ..default_meta() - }); - assert!( - !filter.matches_lib(&torrent).unwrap(), - "Should return false for Audio main-cat with no sub-cat, when filter has an empty set of audio categories." - ); - } - - #[test] - fn test_no_cat_and_filter_active_err() { - let filter = - create_filter_with_audio_cats(Some(vec![AudiobookCategory::GeneralFiction])); - let torrent = create_torrent_with_meta(TorrentMeta { - cat: None, - media_type: MediaType::Audiobook, - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has advanced audio selection and no stored category") - ); - } - - // --- Flags Filtering Tests --- - #[test] - fn test_flags_match_ok_true() { - let filter = TorrentFilter { - flags: Flags { - violence: Some(true), - ..Default::default() - }, - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - flags: Some(FlagBits::new( - Flags { - violence: Some(true), - explicit: Some(true), - ..Default::default() - } - .as_bitfield(), - )), - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_flags_mismatch_ok_false() { - let filter = TorrentFilter { - flags: Flags { - explicit: Some(true), - ..Default::default() - }, - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - flags: Some(FlagBits::new( - Flags { - violence: Some(true), - ..Default::default() - } - .as_bitfield(), - )), - ..default_meta() - }); - assert!(!filter.matches_lib(&torrent).unwrap()); - } - - #[test] - fn test_flags_filter_active_torrent_none_err() { - let filter = TorrentFilter { - flags: Flags { - violence: Some(true), - ..Default::default() - }, - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - flags: None, - ..default_meta() - }); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has flags selection and no stored flags") - ); - } - - // --- Disallowed Filter Checks (Ensure) --- - #[test] - fn test_disallowed_min_size_err() { - let filter = TorrentFilter { - min_size: Size::from_bytes(1), - ..Default::default() - }; - let torrent = create_torrent_with_meta(default_meta()); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has min_size") - ); - } - - #[test] - fn test_disallowed_exclude_uploader_err() { - let filter = TorrentFilter { - exclude_uploader: vec!["test".to_string()], - ..Default::default() - }; - let torrent = create_torrent_with_meta(default_meta()); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has exclude_uploader") - ); - } - - #[test] - fn test_disallowed_uploaded_after_err() { - let filter = TorrentFilter { - uploaded_after: Some(date!(2023 - 01 - 15)), - ..Default::default() - }; - let torrent = create_torrent_with_meta(default_meta()); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has uploaded_after") - ); - } - - #[test] - fn test_disallowed_max_seeders_err() { - let filter = TorrentFilter { - max_seeders: Some(100), - ..Default::default() - }; - let torrent = create_torrent_with_meta(default_meta()); - assert!(filter.matches_lib(&torrent).is_err()); - assert!( - filter - .matches_lib(&torrent) - .unwrap_err() - .to_string() - .contains("has max_seeders") - ); - } - - // --- Full Success Case --- - #[test] - fn test_full_success_match() { - let filter = TorrentFilter { - languages: vec![Language::English], - categories: Categories { - audio: Some(vec![AudiobookCategory::GeneralFiction]), - ebook: None, - musicology: None, - radio: None, - }, - flags: Flags { - crude_language: Some(true), - ..Default::default() - }, - ..Default::default() - }; - let torrent = create_torrent_with_meta(TorrentMeta { - language: Some(Language::English), - cat: Some(OldCategory::Audio(AudiobookCategory::GeneralFiction)), - media_type: MediaType::Audiobook, - flags: Some(FlagBits::new( - Flags { - crude_language: Some(true), - explicit: Some(true), - ..Default::default() - } - .as_bitfield(), - )), - ..default_meta() - }); - assert!( - filter.matches_lib(&torrent).unwrap(), - "Torrent should pass all checks when all allowed filter criteria match." - ); - } - } -} diff --git a/server/src/exporter.rs b/server/src/exporter.rs deleted file mode 100644 index 3b05639c..00000000 --- a/server/src/exporter.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::{ - fs::File, - io::{BufWriter, Write as _}, -}; - -use anyhow::Result; -use native_db::{Database, db_type}; -use serde::{Deserialize, Serialize}; - -#[allow(unused)] -#[derive(Serialize, Deserialize, Debug)] -struct ExportV1 { - config: Vec, - torrents: Vec, - selected_torrents: Vec, - duplicate_torrents: Vec, - errored_torrents: Vec, -} - -#[allow(unused)] -pub fn export_db(db: &Database<'_>) -> Result<()> { - let r = db.r_transaction()?; - let export = ExportV1 { - config: r - .scan() - .primary()? - .all()? - .collect::>()?, - torrents: r - .scan() - .primary()? - .all()? - .collect::>()?, - selected_torrents: r - .scan() - .primary()? - .all()? - .collect::>()?, - duplicate_torrents: r - .scan() - .primary()? - .all()? - .collect::>()?, - errored_torrents: r - .scan() - .primary()? - .all()? - .collect::>()?, - }; - - let file = File::create("export_v1.json")?; - let mut writer = BufWriter::new(file); - serde_json::to_writer_pretty(&mut writer, &export)?; - writer.flush()?; - - Ok(()) -} diff --git a/server/src/linker.rs b/server/src/linker.rs deleted file mode 100644 index a27710cc..00000000 --- a/server/src/linker.rs +++ /dev/null @@ -1,947 +0,0 @@ -#[cfg(target_family = "unix")] -use std::os::unix::fs::MetadataExt as _; -#[cfg(target_family = "windows")] -use std::os::windows::fs::MetadataExt as _; -use std::{ - collections::BTreeMap, - fs::{self, File, Metadata}, - io::{BufWriter, ErrorKind, Write}, - ops::Deref, - path::{Component, Path, PathBuf}, - sync::Arc, -}; - -use anyhow::{Context, Result, anyhow, bail}; -use file_id::get_file_id; -use log::error; -use mlm_db::{ - ClientStatus, DatabaseExt as _, ErroredTorrentId, Event, EventType, LibraryMismatch, - SelectedTorrent, SelectedTorrentKey, Size, Timestamp, Torrent, TorrentKey, TorrentMeta, -}; -use mlm_mam::{api::MaM, meta::MetaError, search::MaMTorrent}; -use mlm_parse::normalize_title; -use native_db::Database; -use once_cell::sync::Lazy; -use qbit::{ - models::{Torrent as QbitTorrent, TorrentContent}, - parameters::TorrentListParams, -}; -use regex::Regex; -use tokio::fs::create_dir_all; -use tracing::{Level, debug, instrument, span, trace, warn}; - -use crate::{ - audiobookshelf::{self as abs}, - autograbber::update_torrent_meta, - cleaner::remove_library_files, - config::{Config, Library, LibraryLinkMethod, QbitConfig}, - logging::{TorrentMetaError, update_errored_torrent, write_event}, - qbittorrent::ensure_category_exists, -}; - -pub static DISK_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"(?:CD|Disc|Disk)\s*(\d+)").unwrap()); - -#[instrument(skip_all)] -pub async fn link_torrents_to_library( - config: Arc, - db: Arc>, - qbit: (&QbitConfig, &qbit::Api), - mam: Arc>, -) -> Result<()> { - let torrents = qbit - .1 - .torrents(Some(TorrentListParams::default())) - .await - .context("qbit main data")?; - - for torrent in torrents { - if torrent.progress < 1.0 { - continue; - } - let library = find_library(&config, &torrent); - let r = db.r_transaction()?; - let mut existing_torrent: Option = r.get().primary(torrent.hash.clone())?; - { - let selected_torrent: Option = r.get().secondary::( - SelectedTorrentKey::hash, - Some(torrent.hash.clone()), - )?; - if let Some(selected_torrent) = selected_torrent { - debug!( - "Finished Downloading torrent {} {}", - selected_torrent.mam_id, selected_torrent.meta.title - ); - let (_guard, rw) = db.rw_async().await?; - rw.remove(selected_torrent)?; - rw.commit()?; - } - } - if let Some(t) = &mut existing_torrent { - let library_name = library.and_then(|l| l.tag_filters().name.as_ref()); - if t.linker.as_ref() != library_name { - let (_guard, rw) = db.rw_async().await?; - t.linker = library_name.map(ToOwned::to_owned); - rw.upsert(t.clone())?; - rw.commit()?; - } - let category = if torrent.category.is_empty() { - None - } else { - Some(torrent.category.as_str()) - }; - if t.category.as_deref() != category { - let (_guard, rw) = db.rw_async().await?; - t.category = category.map(ToOwned::to_owned); - rw.upsert(t.clone())?; - rw.commit()?; - } - if t.client_status.is_none() { - let trackers = qbit.1.trackers(&torrent.hash).await?; - if let Some(mam_tracker) = trackers.last() - && mam_tracker.msg == "torrent not registered with this tracker" - { - { - let (_guard, rw) = db.rw_async().await?; - t.client_status = Some(ClientStatus::RemovedFromMam); - rw.upsert(t.clone())?; - rw.commit()?; - } - write_event( - &db, - Event::new( - Some(torrent.hash.clone()), - Some(t.mam_id), - EventType::RemovedFromMam, - ), - ) - .await; - } - } - if let Some(library_path) = &t.library_path { - let Some(library) = find_library(&config, &torrent) else { - if t.library_mismatch != Some(LibraryMismatch::NoLibrary) { - debug!("no library: {library_path:?}",); - t.library_mismatch = Some(LibraryMismatch::NoLibrary); - let (_guard, rw) = db.rw_async().await?; - rw.upsert(t.clone())?; - rw.commit()?; - } - continue; - }; - if !library_path.starts_with(library.library_dir()) { - let wanted = Some(LibraryMismatch::NewLibraryDir( - library.library_dir().clone(), - )); - if t.library_mismatch != wanted { - debug!( - "library differs: {library_path:?} != {:?}", - library.library_dir() - ); - t.library_mismatch = wanted; - let (_guard, rw) = db.rw_async().await?; - rw.upsert(t.clone())?; - rw.commit()?; - } - } else { - let dir = library_dir(config.exclude_narrator_in_library_dir, library, &t.meta); - let mut is_wrong = Some(library_path) != dir.as_ref(); - let wanted = match dir { - Some(dir) => Some(LibraryMismatch::NewPath(dir)), - None => Some(LibraryMismatch::NoLibrary), - }; - - if t.library_mismatch != wanted { - if is_wrong { - // Try another attempt at matching with exclude_narrator flipped - let dir_2 = library_dir( - !config.exclude_narrator_in_library_dir, - library, - &t.meta, - ); - if Some(library_path) == dir_2.as_ref() { - is_wrong = false - } - } - if is_wrong { - debug!("path differs: {library_path:?} != {:?}", wanted); - t.library_mismatch = wanted; - let (_guard, rw) = db.rw_async().await?; - rw.upsert(t.clone())?; - rw.commit()?; - } else if t.library_mismatch.is_some() { - t.library_mismatch = None; - let (_guard, rw) = db.rw_async().await?; - rw.upsert(t.clone())?; - rw.commit()?; - } - } - } - continue; - } - if t.replaced_with.is_some() { - continue; - } - } - - let Some(library) = library else { - trace!( - "Could not find matching library for torrent \"{}\", save_path {}", - torrent.name, torrent.save_path - ); - continue; - }; - - if library.method() == LibraryLinkMethod::NoLink && existing_torrent.is_some() { - continue; - } - - let result = match_torrent( - config.clone(), - db.clone(), - qbit, - mam.clone(), - &torrent.hash, - &torrent, - library, - existing_torrent, - ) - .await - .context("match_torrent"); - update_errored_torrent( - &db, - ErroredTorrentId::Linker(torrent.hash.clone()), - torrent.name, - result, - ) - .await; - } - - Ok(()) -} - -#[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -async fn match_torrent( - config: Arc, - db: Arc>, - qbit: (&QbitConfig, &qbit::Api), - mam: Arc>, - hash: &str, - torrent: &QbitTorrent, - library: &Library, - existing_torrent: Option, -) -> Result<()> { - let mut existing_torrent = existing_torrent; - let files = qbit.1.files(hash, None).await?; - let selected_audio_format = select_format( - &library.tag_filters().audio_types, - &config.audio_types, - &files, - ); - let selected_ebook_format = select_format( - &library.tag_filters().ebook_types, - &config.ebook_types, - &files, - ); - - if selected_audio_format.is_none() && selected_ebook_format.is_none() { - bail!("Could not find any wanted formats in torrent"); - } - let Some(mam_torrent) = mam.get_torrent_info(hash).await.context("get_mam_info")? else { - bail!("Could not find torrent on mam"); - }; - if existing_torrent.is_none() - && let Some(old_torrent) = db - .r_transaction()? - .get() - .secondary::(TorrentKey::mam_id, mam_torrent.id)? - { - if old_torrent.id != hash { - let (_guard, rw) = db.rw_async().await?; - rw.remove(old_torrent.clone())?; - rw.commit()?; - } - existing_torrent = Some(old_torrent); - } - let meta = match mam_torrent.as_meta() { - Ok(meta) => meta, - Err(err) => { - if let MetaError::UnknownMediaType(_) = err { - if let Some(on_invalid_torrent) = &qbit.0.on_invalid_torrent { - let qbit_url = qbit.0.url.clone(); - let qbit = qbit::Api::new_login_username_password( - &qbit.0.url, - &qbit.0.username, - &qbit.0.password, - ) - .await?; - - if let Some(category) = &on_invalid_torrent.category { - ensure_category_exists(&qbit, &qbit_url, category).await?; - qbit.set_category(Some(vec![&torrent.hash]), category) - .await?; - } - - if !on_invalid_torrent.tags.is_empty() { - qbit.add_tags( - Some(vec![&torrent.hash]), - on_invalid_torrent.tags.iter().map(Deref::deref).collect(), - ) - .await?; - } - } - trace!("qbit updated"); - } - return Err(err).context("as_meta"); - } - }; - - link_torrent( - &config, - qbit.0, - &db, - hash, - torrent, - files, - selected_audio_format, - selected_ebook_format, - library, - mam_torrent, - existing_torrent.as_ref(), - &meta, - ) - .await - .context("link_torrent") - .map_err(|err| anyhow::Error::new(TorrentMetaError(meta, err))) -} - -#[instrument(skip_all)] -pub async fn refresh_metadata( - config: &Config, - db: &Database<'_>, - mam: &MaM<'_>, - id: String, -) -> Result<(Torrent, MaMTorrent)> { - let Some(mut torrent): Option = db.r_transaction()?.get().primary(id)? else { - bail!("Could not find torrent id"); - }; - debug!("refreshing metadata for torrent {}", torrent.meta.mam_id); - let Some(mam_torrent) = mam - .get_torrent_info_by_id(torrent.mam_id) - .await - .context("get_mam_info")? - else { - bail!("Could not find torrent \"{}\" on mam", torrent.meta.title); - }; - let meta = mam_torrent.as_meta().context("as_meta")?; - - if torrent.meta != meta { - update_torrent_meta( - config, - db, - db.rw_async().await?, - &mam_torrent, - torrent.clone(), - meta.clone(), - true, - false, - ) - .await?; - torrent.meta = meta; - } - Ok((torrent, mam_torrent)) -} - -#[instrument(skip_all)] -pub async fn refresh_metadata_relink( - config: &Config, - db: &Database<'_>, - mam: &MaM<'_>, - hash: String, -) -> Result<()> { - let mut torrent = None; - for qbit_conf in &config.qbittorrent { - let qbit = match qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await - { - Ok(qbit) => qbit, - Err(err) => { - error!("Error logging in to qbit {}: {err}", qbit_conf.url); - continue; - } - }; - let mut torrents = match qbit - .torrents(Some(TorrentListParams { - hashes: Some(vec![hash.clone()]), - ..TorrentListParams::default() - })) - .await - { - Ok(torrents) => torrents, - Err(err) => { - error!("Error getting torrents from qbit {}: {err}", qbit_conf.url); - continue; - } - }; - let Some(t) = torrents.pop() else { - continue; - }; - torrent.replace((qbit_conf, qbit, t)); - break; - } - let Some((qbit_conf, qbit, qbit_torrent)) = torrent else { - bail!("Could not find torrent in qbit"); - }; - let Some(library) = find_library(config, &qbit_torrent) else { - bail!("Could not find matching library for torrent"); - }; - let files = qbit.files(&hash, None).await?; - let selected_audio_format = select_format( - &library.tag_filters().audio_types, - &config.audio_types, - &files, - ); - let selected_ebook_format = select_format( - &library.tag_filters().ebook_types, - &config.ebook_types, - &files, - ); - - if selected_audio_format.is_none() && selected_ebook_format.is_none() { - bail!("Could not find any wanted formats in torrent"); - } - let (torrent, mam_torrent) = refresh_metadata(config, db, mam, hash.clone()).await?; - let library_path_changed = torrent.library_path - != library_dir( - config.exclude_narrator_in_library_dir, - library, - &torrent.meta, - ); - remove_library_files(config, &torrent, library_path_changed).await?; - link_torrent( - config, - qbit_conf, - db, - &hash, - &qbit_torrent, - files, - selected_audio_format, - selected_ebook_format, - library, - mam_torrent, - Some(&torrent), - &torrent.meta, - ) - .await - .context("link_torrent") - .map_err(|err| anyhow::Error::new(TorrentMetaError(torrent.meta, err))) -} - -#[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -async fn link_torrent( - config: &Config, - qbit_config: &QbitConfig, - db: &Database<'_>, - hash: &str, - torrent: &QbitTorrent, - files: Vec, - selected_audio_format: Option, - selected_ebook_format: Option, - library: &Library, - mam_torrent: MaMTorrent, - existing_torrent: Option<&Torrent>, - meta: &TorrentMeta, -) -> Result<()> { - let mut library_files = vec![]; - - let library_path = if library.tag_filters().method != LibraryLinkMethod::NoLink { - let Some(mut dir) = library_dir(config.exclude_narrator_in_library_dir, library, meta) - else { - bail!("Torrent has no author"); - }; - if config.exclude_narrator_in_library_dir && !meta.narrators.is_empty() && dir.exists() { - dir = library_dir(false, library, meta).unwrap(); - } - let metadata = abs::create_metadata(&mam_torrent, meta); - - create_dir_all(&dir).await?; - for file in files { - let span = span!(Level::TRACE, "file: {:?}", file.name); - let _s = span.enter(); - if !(selected_audio_format - .as_ref() - .is_some_and(|ext| file.name.ends_with(ext)) - || selected_ebook_format - .as_ref() - .is_some_and(|ext| file.name.ends_with(ext))) - { - debug!("Skiping \"{}\"", file.name); - continue; - } - let torrent_path = qbit_file_path(&file.name); - let mut path_components = torrent_path.components(); - let file_name = path_components.next_back().unwrap(); - let dir_name = path_components.next_back().and_then(|dir_name| { - if let Component::Normal(dir_name) = dir_name { - let dir_name = dir_name.to_string_lossy().to_string(); - if let Some(disc) = DISK_PATTERN.captures(&dir_name).and_then(|c| c.get(1)) { - return Some(format!("Disc {}", disc.as_str())); - } - } - None - }); - let file_path = if let Some(dir_name) = dir_name { - let sub_dir = PathBuf::from(dir_name); - create_dir_all(dir.join(&sub_dir)).await?; - sub_dir.join(file_name) - } else { - PathBuf::from(&file_name) - }; - let library_path = dir.join(&file_path); - library_files.push(file_path.clone()); - let download_path = map_path(&qbit_config.path_mapping, &torrent.save_path) - .join(&torrent_path); - match library.method() { - LibraryLinkMethod::Hardlink => { - hard_link(&download_path, &library_path, &file_path)? - } - LibraryLinkMethod::HardlinkOrCopy => { - hard_link(&download_path, &library_path, &file_path) - .or_else(|_| copy(&download_path, &library_path))? - } - LibraryLinkMethod::Copy => copy(&download_path, &library_path)?, - LibraryLinkMethod::HardlinkOrSymlink => { - hard_link(&download_path, &library_path, &file_path) - .or_else(|_| symlink(&download_path, &library_path))? - } - LibraryLinkMethod::Symlink => symlink(&download_path, &library_path)?, - LibraryLinkMethod::NoLink => {} - }; - } - library_files.sort(); - - let file = File::create(dir.join("metadata.json"))?; - let mut writer = BufWriter::new(file); - serde_json::to_writer(&mut writer, &metadata)?; - writer.flush()?; - Some(dir.clone()) - } else { - None - }; - - { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(Torrent { - id: hash.to_owned(), - id_is_hash: true, - mam_id: meta.mam_id, - abs_id: existing_torrent.and_then(|t| t.abs_id.clone()), - goodreads_id: existing_torrent.and_then(|t| t.goodreads_id), - library_path: library_path.clone(), - library_files, - linker: library.tag_filters().name.clone(), - category: if torrent.category.is_empty() { - None - } else { - Some(torrent.category.clone()) - }, - selected_audio_format, - selected_ebook_format, - title_search: normalize_title(&meta.title), - meta: meta.clone(), - created_at: existing_torrent - .map(|t| t.created_at) - .unwrap_or_else(Timestamp::now), - replaced_with: existing_torrent.and_then(|t| t.replaced_with.clone()), - request_matadata_update: false, - library_mismatch: None, - client_status: existing_torrent.and_then(|t| t.client_status.clone()), - })?; - rw.commit()?; - } - - if let Some(library_path) = library_path { - write_event( - db, - Event::new( - Some(hash.to_owned()), - Some(meta.mam_id), - EventType::Linked { - linker: library.tag_filters().name.clone(), - library_path, - }, - ), - ) - .await; - } - - Ok(()) -} - -pub fn map_path(path_mapping: &BTreeMap, save_path: &str) -> PathBuf { - let mut path = PathBuf::from(save_path); - for (from, to) in path_mapping.iter().rev() { - if path.starts_with(from) { - let mut components = path.components(); - for _ in from { - components.next(); - } - path = to.join(components.as_path()); - break; - } - } - path -} - -pub fn find_library<'a>(config: &'a Config, torrent: &QbitTorrent) -> Option<&'a Library> { - config - .libraries - .iter() - .filter(|l| match l { - Library::ByDir(l) => PathBuf::from(&torrent.save_path).starts_with(&l.download_dir), - Library::ByCategory(l) => torrent.category == l.category, - }) - .find(|l| { - let filters = l.tag_filters(); - if filters - .deny_tags - .iter() - .any(|tag| torrent.tags.split(", ").any(|t| t == tag.as_str())) - { - return false; - } - if filters.allow_tags.is_empty() { - return true; - } - filters - .allow_tags - .iter() - .any(|tag| torrent.tags.split(", ").any(|t| t == tag.as_str())) - }) -} - -pub fn library_dir( - exclude_narrator_in_library_dir: bool, - library: &Library, - meta: &TorrentMeta, -) -> Option { - let author = meta.authors.first()?; - let mut dir = match meta - .series - .iter() - .find(|s| !s.entries.0.is_empty()) - .or(meta.series.first()) - { - Some(series) => PathBuf::from(sanitize_filename::sanitize(author).to_string()) - .join(sanitize_filename::sanitize(&series.name).to_string()) - .join( - sanitize_filename::sanitize(if series.entries.0.is_empty() { - meta.title.clone() - } else { - format!("{} #{} - {}", series.name, series.entries, meta.title) - }) - .to_string(), - ), - None => PathBuf::from(sanitize_filename::sanitize(author).to_string()) - .join(sanitize_filename::sanitize(&meta.title).to_string()), - }; - if let Some((edition, _)) = &meta.edition { - dir.set_file_name( - sanitize_filename::sanitize(format!( - "{}, {}", - dir.file_name().unwrap().to_string_lossy(), - edition - )) - .to_string(), - ); - } - if let Some(narrator) = meta.narrators.first() - && !exclude_narrator_in_library_dir - { - dir.set_file_name( - sanitize_filename::sanitize(format!( - "{} {{{}}}", - dir.file_name().unwrap().to_string_lossy(), - narrator - )) - .to_string(), - ); - } - let dir = library.library_dir().join(dir); - Some(dir) -} - -fn select_format( - overridden_wanted_formats: &Option>, - wanted_formats: &[String], - files: &[TorrentContent], -) -> Option { - overridden_wanted_formats - .as_deref() - .unwrap_or(wanted_formats) - .iter() - .map(|ext| { - let ext = ext.to_lowercase(); - if ext.starts_with(".") { - ext.clone() - } else { - format!(".{ext}") - } - }) - .find(|ext| files.iter().any(|f| f.name.to_lowercase().ends_with(ext))) -} - -#[instrument(skip_all)] -fn hard_link(download_path: &Path, library_path: &Path, file_path: &Path) -> Result<()> { - debug!("linking: {:?} -> {:?}", download_path, library_path); - let download_fs_path = windows_fs_path(download_path); - let library_fs_path = windows_fs_path(library_path); - fs::hard_link(&download_fs_path, &library_fs_path) - .map_err(|err| link_not_found_diagnostics(err, "hardlink", download_path, library_path)) - .or_else(|err| { - if err.kind() == ErrorKind::AlreadyExists { - trace!("AlreadyExists: {}", err); - let download_id = get_file_id(&download_fs_path); - trace!("got 1: {download_id:?}"); - let library_id = get_file_id(&library_fs_path); - trace!("got 2: {library_id:?}"); - if let (Ok(download_id), Ok(library_id)) = (download_id, library_id) { - trace!("got both"); - if download_id == library_id { - trace!("both match"); - return Ok(()); - } else { - trace!("no match"); - bail!( - "File \"{:?}\" already exists, torrent file size: {}, library file size: {}", - file_path, - fs::metadata(&download_fs_path).map_or("?".to_string(), |s| Size::from_bytes(file_size(&s)).to_string()), - fs::metadata(&library_fs_path).map_or("?".to_string(), |s| Size::from_bytes(file_size(&s)).to_string()) - ); - } - } - } - Err(err.into()) - })?; - Ok(()) -} - -#[instrument(skip_all)] -fn copy(download_path: &Path, library_path: &Path) -> Result<()> { - debug!("copying: {:?} -> {:?}", download_path, library_path); - let download_fs_path = windows_fs_path(download_path); - let library_fs_path = windows_fs_path(library_path); - fs::copy(&download_fs_path, &library_fs_path) - .map_err(|err| link_not_found_diagnostics(err, "copy", download_path, library_path))?; - Ok(()) -} - -#[instrument(skip_all)] -fn symlink(download_path: &Path, library_path: &Path) -> Result<()> { - debug!("symlinking: {:?} -> {:?}", download_path, library_path); - #[cfg(target_family = "unix")] - std::os::unix::fs::symlink(download_path, library_path) - .map_err(|err| link_not_found_diagnostics(err, "symlink", download_path, library_path))?; - #[cfg(target_family = "windows")] - bail!("symlink is not supported on Windows"); - #[allow(unreachable_code)] - Ok(()) -} - -fn link_not_found_diagnostics( - err: std::io::Error, - operation: &str, - download_path: &Path, - library_path: &Path, -) -> std::io::Error { - let is_not_found = err.kind() == ErrorKind::NotFound - || matches!(err.raw_os_error(), Some(2) | Some(3)); - if !is_not_found { - return err; - } - - let details = [ - format!( - "{operation} failed with not-found error while linking files: {err}" - ), - probe_path("download_path", Some(download_path)), - probe_path("download_parent", download_path.parent()), - probe_path("library_path", Some(library_path)), - probe_path("library_parent", library_path.parent()), - ] - .join(" | "); - - warn!("{details}"); - std::io::Error::new(err.kind(), anyhow!(details)) -} - -fn probe_path(label: &str, path: Option<&Path>) -> String { - let Some(path) = path else { - return format!("{label}="); - }; - - let symlink_meta = fs::symlink_metadata(path); - let exists = symlink_meta.is_ok(); - let meta_state = match symlink_meta { - Ok(meta) if meta.is_dir() => "dir", - Ok(meta) if meta.is_file() => "file", - Ok(meta) if meta.file_type().is_symlink() => "symlink", - Ok(_) => "other", - Err(_) => "missing", - }; - let first_missing = first_missing_component(path) - .map(|p| format!(", first_missing={}", p.display())) - .unwrap_or_default(); - - format!( - "{label}={} (exists={exists}, type={meta_state}{first_missing})", - path.display() - ) -} - -fn first_missing_component(path: &Path) -> Option { - let mut current = PathBuf::new(); - for component in path.components() { - current.push(component.as_os_str()); - if fs::symlink_metadata(¤t).is_err() { - return Some(current); - } - } - None -} - -fn qbit_file_path(file_name: &str) -> PathBuf { - let mut path = PathBuf::new(); - for part in file_name.split(['/', '\\']) { - if part.is_empty() || part == "." { - continue; - } - path.push(part); - } - path -} - -#[cfg(target_family = "windows")] -fn windows_fs_path(path: &Path) -> PathBuf { - if !path.is_absolute() { - return path.to_path_buf(); - } - let path_str = path.as_os_str().to_string_lossy(); - if path_str.starts_with(r"\\?\") { - return path.to_path_buf(); - } - if let Some(without_unc) = path_str.strip_prefix(r"\\") { - return PathBuf::from(format!(r"\\?\UNC\{without_unc}")); - } - PathBuf::from(format!(r"\\?\{path_str}")) -} - -#[cfg(not(target_family = "windows"))] -fn windows_fs_path(path: &Path) -> PathBuf { - path.to_path_buf() -} - -pub fn file_size(m: &Metadata) -> u64 { - #[cfg(target_family = "unix")] - return m.size(); - #[cfg(target_family = "windows")] - return m.file_size(); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_map_path() { - let mut mappings = BTreeMap::new(); - mappings.insert(PathBuf::from("/downloads"), PathBuf::from("/books")); - mappings.insert( - PathBuf::from("/downloads/audiobooks"), - PathBuf::from("/audiobooks"), - ); - mappings.insert(PathBuf::from("/audiobooks"), PathBuf::from("/audiobooks")); - - assert_eq!( - map_path(&mappings, "/downloads/torrent"), - PathBuf::from("/books/torrent") - ); - assert_eq!( - map_path(&mappings, "/downloads/audiobooks/torrent"), - PathBuf::from("/audiobooks/torrent") - ); - assert_eq!( - map_path(&mappings, "/downloads/audiobooks/torrent/deep"), - PathBuf::from("/audiobooks/torrent/deep") - ); - assert_eq!( - map_path(&mappings, "/audiobooks/torrent"), - PathBuf::from("/audiobooks/torrent") - ); - assert_eq!( - map_path(&mappings, "/ebooks/torrent"), - PathBuf::from("/ebooks/torrent") - ); - } - - #[test] - fn test_qbit_file_path_mixed_separators() { - assert_eq!( - qbit_file_path(r"Isaac Asimov AudioBook Collection/Book 01\CD 07/Track01.mp3"), - PathBuf::from("Isaac Asimov AudioBook Collection") - .join("Book 01") - .join("CD 07") - .join("Track01.mp3") - ); - } - - #[test] - fn test_qbit_file_path_ignores_empty_and_dot_segments() { - assert_eq!( - qbit_file_path(r"/Book 01//./CD 07\.\Track01.mp3"), - PathBuf::from("Book 01") - .join("CD 07") - .join("Track01.mp3") - ); - } - - #[cfg(target_family = "windows")] - #[test] - fn test_windows_fs_path_drive_prefix() { - let path = PathBuf::from(r"F:\Sharon\Media\Audio\Hardlinks\file.m4b"); - assert_eq!( - windows_fs_path(&path), - PathBuf::from(r"\\?\F:\Sharon\Media\Audio\Hardlinks\file.m4b") - ); - } - - #[cfg(target_family = "windows")] - #[test] - fn test_windows_fs_path_unc_prefix() { - let path = PathBuf::from(r"\\server\share\Audio\file.m4b"); - assert_eq!( - windows_fs_path(&path), - PathBuf::from(r"\\?\UNC\server\share\Audio\file.m4b") - ); - } - - #[cfg(target_family = "windows")] - #[test] - fn test_windows_fs_path_preserves_existing_extended_prefix() { - let path = PathBuf::from(r"\\?\F:\Sharon\Media\Audio\Hardlinks\file.m4b"); - assert_eq!(windows_fs_path(&path), path); - } - - #[cfg(not(target_family = "windows"))] - #[test] - fn test_windows_fs_path_noop_on_non_windows() { - let path = PathBuf::from("/tmp/test/file.m4b"); - assert_eq!(windows_fs_path(&path), path); - } -} diff --git a/server/src/lists/goodreads.rs b/server/src/lists/goodreads.rs deleted file mode 100644 index 949b21b2..00000000 --- a/server/src/lists/goodreads.rs +++ /dev/null @@ -1,435 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use anyhow::{Context, Result}; -use mlm_db::{ - DatabaseExt as _, List, ListItem, ListItemTorrent, OldDbMainCat, OldMainCat, Timestamp, - TorrentMeta, TorrentStatus, -}; -use mlm_mam::{api::MaM, search::MaMTorrent}; -use mlm_parse::clean_value; -use native_db::Database; -use once_cell::sync::Lazy; -use quick_xml::de::from_reader; -use regex::Regex; -use scraper::{Html, Selector}; -use serde::Deserialize; -use tokio::time::sleep; -use tracing::{debug, instrument, trace, warn}; - -use crate::{ - autograbber::select_torrents, - config::{Config, Cost, GoodreadsList, Grab}, - lists::{search_grab, search_library}, -}; - -pub static SERIES_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"(.*?) \(([^)]*?),? #?(\d+(?:\.\d+)?)\)$").unwrap()); - -static IMPORT_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -#[instrument(skip_all)] -pub async fn run_goodreads_import( - config: Arc, - db: Arc>, - mam: Arc>, - list: &GoodreadsList, - max_torrents: u64, -) -> Result<()> { - // Make sure we are only running one import at a time - let _guard = IMPORT_MUTEX.lock().await; - - let content = reqwest::Client::new() - .get(&list.url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36") - .send() - .await? - .bytes() - .await?; - - let mut rss: Rss = from_reader(&content[..])?; - trace!("Scanning Goodreads list {}", rss.channel.title); - - let list_id = list.list_id()?; - - if !list.dry_run { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(List { - id: list_id.clone(), - title: rss.channel.title, - updated_at: Some(Timestamp::now()), - // TODO: Parse - build_date: Some(Timestamp::now()), - })?; - rw.commit()?; - } - - for item in rss.channel.items.iter_mut() { - if let Some((_, [title, series_name, series_num])) = - SERIES_PATTERN.captures(&item.title).map(|c| c.extract()) - { - item.series = Some((series_name.to_owned(), series_num.parse()?)); - item.title = title.to_owned(); - } - } - - for mut item in rss.channel.items.into_iter() { - if let Ok(title) = clean_value(&item.title) { - item.title = title; - } - if let Some(author_name) = &item.author_name - && let Ok(author_name) = clean_value(author_name) - { - item.author_name = Some(author_name); - } - if let Some((series_name, num)) = &item.series - && let Ok(series_name) = clean_value(series_name) - { - item.series = Some((series_name, *num)); - } - let db_item = match db - .r_transaction()? - .get() - .primary::((list_id.clone(), item.guid.clone()))? - { - Some(mut db_item) => { - if db_item.prefer_format != list.prefer_format - || db_item.allow_audio != list.allow_audio() - || db_item.allow_ebook != list.allow_ebook() - || db_item.title != item.title - || db_item.series.first() != item.series.as_ref() - { - db_item.prefer_format = list.prefer_format; - db_item.allow_audio = list.allow_audio(); - db_item.allow_ebook = list.allow_ebook(); - db_item.title = item.title.clone(); - db_item.series = item.series.iter().cloned().collect(); - if !list.dry_run { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(db_item.clone())?; - rw.commit()?; - } - } - if (db_item.audio_torrent.is_some() && db_item.ebook_torrent.is_some()) - || (list.prefer_format == Some(OldDbMainCat::Audio) - && db_item.audio_torrent.is_some()) - || (list.prefer_format == Some(OldDbMainCat::Ebook) - && db_item.ebook_torrent.is_some()) - { - continue; - } - db_item - } - None => { - let db_item = item.as_list_item(&list_id, &list); - if !list.dry_run { - let (_guard, rw) = db.rw_async().await?; - rw.insert(db_item.clone())?; - rw.commit()?; - } - db_item - } - }; - trace!("Searching for book {} from Goodreads list", item.title); - search_item(&config, &db, &mam, &list, &item, db_item, max_torrents) - .await - .context("search goodreads book")?; - sleep(Duration::from_millis(400)).await; - } - - Ok(()) -} - -#[instrument(skip_all)] -async fn search_item( - config: &Config, - db: &Database<'_>, - mam: &MaM<'_>, - list: &GoodreadsList, - item: &Item, - mut db_item: ListItem, - max_torrents: u64, -) -> Result { - if !db_item.want_audio() && !db_item.want_ebook() { - return Ok(0); - } - - let has_updates = search_library(config, db, &mut db_item).context("search_library")?; - if !list.dry_run && has_updates { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(db_item.clone())?; - rw.commit()?; - } - if !db_item.want_audio() && !db_item.want_ebook() { - return Ok(0); - } - - let mut torrents = vec![]; - for grab in &list.grab { - let results = search_grab(config, mam, &db_item, grab) - .await - .context("search_grab")?; - torrents.push(results); - } - let mut audiobook = select_torrent(&torrents, OldMainCat::Audio); - let mut ebook = select_torrent(&torrents, OldMainCat::Ebook); - - let mut has_updates = false; - if audiobook.is_some() && ebook.is_some() { - match list.prefer_format { - Some(OldDbMainCat::Audio) => { - let updated = not_wanted(&mut db_item.ebook_torrent, &mut ebook); - has_updates = updated || has_updates; - } - Some(OldDbMainCat::Ebook) => { - let updated = not_wanted(&mut db_item.audio_torrent, &mut audiobook); - has_updates = updated || has_updates; - } - None => {} - } - } - if !list.dry_run && has_updates { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(db_item.clone())?; - rw.commit()?; - } - - let mut has_updates = false; - if check_cost(&mut db_item.audio_torrent, &mut audiobook) { - has_updates = true; - } - if check_cost(&mut db_item.ebook_torrent, &mut ebook) { - has_updates = true; - } - if !list.dry_run && has_updates { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(db_item.clone())?; - rw.commit()?; - } - - let mut has_updates = false; - if let Some(found) = audiobook - && db_item - .audio_torrent - .as_ref() - .is_none_or(|t| !(t.status == TorrentStatus::Selected && t.mam_id == found.0.id)) - { - db_item.audio_torrent = Some(ListItemTorrent { - mam_id: found.0.id, - status: TorrentStatus::Selected, - at: Timestamp::now(), - }); - has_updates = true; - } - if let Some(found) = ebook - && db_item - .ebook_torrent - .as_ref() - .is_none_or(|t| !(t.status == TorrentStatus::Selected && t.mam_id == found.0.id)) - { - db_item.ebook_torrent = Some(ListItemTorrent { - mam_id: found.0.id, - status: TorrentStatus::Selected, - at: Timestamp::now(), - }); - has_updates = true; - } - if !list.dry_run && has_updates { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(db_item.clone())?; - rw.commit()?; - } - - let mut selected_torrents = 0; - if let Some(audiobook) = audiobook { - selected_torrents += select_torrents( - config, - db, - mam, - [audiobook.0.clone()].into_iter(), - &audiobook.3.filter, - audiobook.3.cost, - list.unsat_buffer, - list.wedge_buffer, - None, - list.dry_run, - max_torrents, - item.book_id, - ) - .await - .context("select_torrents")?; - } - if let Some(ebook) = ebook { - selected_torrents += select_torrents( - config, - db, - mam, - [ebook.0.clone()].into_iter(), - &ebook.3.filter, - ebook.3.cost, - list.unsat_buffer, - list.wedge_buffer, - None, - list.dry_run, - max_torrents, - item.book_id, - ) - .await - .context("select_torrents")?; - } - - Ok(selected_torrents) -} - -#[derive(Debug, Deserialize)] -struct Rss { - channel: Channel, -} - -#[derive(Debug, Deserialize)] -struct Channel { - title: String, - #[serde(rename = "item")] - items: Vec, -} - -#[derive(Debug, Deserialize)] -struct Item { - guid: String, - book_id: Option, - title: String, - author_name: Option, - series: Option<(String, f64)>, - book_large_image_url: Option, - // book_description: String, - isbn: Option, - description: String, -} - -impl Item { - fn as_list_item(&self, list_id: &str, list: &GoodreadsList) -> ListItem { - let fragment = Html::parse_fragment(&self.description); - - let cover_url = if let Some(cover) = &self.book_large_image_url { - cover.clone() - } else { - let cover_selector = Selector::parse(".cover img").unwrap(); - fragment - .select(&cover_selector) - .next() - .and_then(|e| e.attr("src")) - .map(|s| s.to_string()) - .unwrap_or_default() - }; - - let user_list_selector = - Selector::parse("a[href^=\"https://www.goodreads.com/book/show/\"]").unwrap(); - let group_list_selector = Selector::parse("a[href^=\"/book/show/\"]").unwrap(); - let book_url = fragment - .select(&user_list_selector) - .next() - .and_then(|e| e.attr("href")) - .map(|s| s.to_string()) - .or_else(|| { - fragment - .select(&group_list_selector) - .next() - .and_then(|e| e.attr("href")) - .map(|url| format!("https://www.goodreads.com{url}")) - }); - - let authors = if let Some(author_name) = &self.author_name { - vec![author_name.clone().replace('.', " ")] - } else { - let fragment = Html::parse_fragment(&self.description); - let author_selector = - Selector::parse("[itemprop=\"author\"] [itemprop=\"name\"]").unwrap(); - fragment - .select(&author_selector) - .map(|e| e.text().collect::().replace('.', " ")) - .collect() - }; - - ListItem { - guid: (list_id.to_owned(), self.guid.clone()), - list_id: list_id.to_owned(), - title: self.title.clone(), - authors, - series: self.series.iter().cloned().collect(), - cover_url, - book_url, - isbn: self.isbn.as_ref().and_then(|isbn| isbn.parse().ok()), - prefer_format: list.prefer_format, - allow_audio: list.allow_audio(), - audio_torrent: None, - allow_ebook: list.allow_ebook(), - ebook_torrent: None, - created_at: Timestamp::now(), - marked_done_at: None, - } - } -} - -fn select_torrent( - torrents: &[Vec<(MaMTorrent, TorrentMeta, usize, Grab)>], - main_cat: OldMainCat, -) -> Option<&(MaMTorrent, TorrentMeta, usize, Grab)> { - torrents - .iter() - .flatten() - .filter(|t| t.1.media_type.matches(main_cat.into())) - .find(|t| t.3.cost != Cost::Free || t.0.is_free()) - .or_else(|| { - torrents - .iter() - .flatten() - .find(|t| t.1.media_type.matches(main_cat.into())) - }) -} - -fn not_wanted( - field: &mut Option, - unwanted: &mut Option<&(MaMTorrent, TorrentMeta, usize, Grab)>, -) -> bool { - let found = unwanted.take().unwrap(); - if field - .as_ref() - .is_none_or(|t| t.status != TorrentStatus::NotWanted) - { - debug!("Skipped {:?} torrent as is not wanted", found.1.main_cat); - field.replace(ListItemTorrent { - mam_id: found.0.id, - status: TorrentStatus::NotWanted, - at: Timestamp::now(), - }); - true - } else { - false - } -} -fn check_cost( - field: &mut Option, - selected: &mut Option<&(MaMTorrent, TorrentMeta, usize, Grab)>, -) -> bool { - let take = - selected.is_some_and(|selected| selected.3.cost == Cost::Free && !selected.0.is_free()); - if take { - let found = selected.take().unwrap(); - warn!("Skipped {:?} torrent as it is not free", found.1.main_cat); - if field - .as_ref() - .is_none_or(|t| !(t.status == TorrentStatus::Wanted && t.mam_id == found.0.id)) - { - field.replace(ListItemTorrent { - mam_id: found.0.id, - status: TorrentStatus::Wanted, - at: Timestamp::now(), - }); - true - } else { - false - } - } else { - false - } -} diff --git a/server/src/lists/mod.rs b/server/src/lists/mod.rs deleted file mode 100644 index edc5810e..00000000 --- a/server/src/lists/mod.rs +++ /dev/null @@ -1,357 +0,0 @@ -mod goodreads; -mod notion; - -use std::{borrow::Cow, sync::Arc}; - -use anyhow::{Context, Result}; -use itertools::Itertools; -use matchr::score; -use mlm_db::{ - ListItem, ListItemTorrent, OldMainCat, Torrent, TorrentKey, TorrentMeta, TorrentStatus, -}; -use mlm_mam::{ - api::MaM, - enums::SearchIn, - search::{MaMTorrent, SearchFields, SearchQuery, SearchResult, Tor}, - serde::DATE_FORMAT, -}; -use mlm_parse::normalize_title; -use native_db::Database; -use once_cell::sync::Lazy; -use regex::Regex; -use serde_json::Value; -use tokio::sync::watch::Sender; -use tracing::{debug, instrument, trace}; - -use crate::{ - config::{Config, GoodreadsList, Grab, NotionList}, - lists::{goodreads::run_goodreads_import, notion::run_notion_import}, -}; - -pub enum List { - Goodreads(GoodreadsList), - Notion(NotionList), -} - -impl List { - pub fn list_type(&self) -> &'static str { - match self { - List::Goodreads(_) => "Goodreads", - List::Notion(_) => "Notion", - } - } - - pub fn display_name(&self, index: usize) -> String { - match self { - List::Goodreads(list) => list.name.clone().unwrap_or_else(|| index.to_string()), - List::Notion(list) => list.name.clone(), - } - } - - pub fn search_interval(&self) -> Option { - match self { - List::Goodreads(list) => list.search_interval, - List::Notion(list) => list.search_interval, - } - } - - fn unsat_buffer(&self) -> Option { - match self { - List::Goodreads(list) => list.unsat_buffer, - List::Notion(list) => list.unsat_buffer, - } - } -} - -pub fn get_lists(config: &Config) -> Vec { - let mut lists = vec![]; - for goodreads in &config.goodreads_lists { - lists.push(List::Goodreads(goodreads.clone())); - } - for notion in &config.notion_lists { - lists.push(List::Notion(notion.clone())); - } - lists -} - -#[instrument(skip_all)] -pub async fn run_list_import( - config: Arc, - db: Arc>, - mam: Arc>, - list: Arc, - index: usize, - autograb_trigger: Sender<()>, -) -> Result<()> { - let user_info = mam.user_info().await?; - let max_torrents = user_info.unsat.limit.saturating_sub(user_info.unsat.count); - debug!( - "{} import, name: {}, unsats: {:#?}; max_torrents: {max_torrents}", - list.list_type(), - list.display_name(index), - user_info.unsat - ); - - let max_torrents = - max_torrents.saturating_sub(list.unsat_buffer().unwrap_or(config.unsat_buffer)); - - if max_torrents > 0 { - match list.as_ref() { - List::Goodreads(list) => { - run_goodreads_import(config, db, mam, list, max_torrents).await?; - } - List::Notion(list) => { - run_notion_import(config, db, mam, list, max_torrents).await?; - } - } - } - - autograb_trigger.send(())?; - - Ok(()) -} - -static BAD_CHARATERS: Lazy = - Lazy::new(|| Regex::new(r"['`/?]|\s+[\(\[][^\)\]]+[\)\]]").unwrap()); - -static AND: Lazy = Lazy::new(|| Regex::new(r"&|\band\b").unwrap()); - -#[instrument(skip_all)] -fn search_library(config: &Config, db: &Database<'_>, db_item: &mut ListItem) -> Result { - let r = db.r_transaction()?; - let title_search = normalize_title(&db_item.title); - let mut library = { - r.scan() - .secondary::(TorrentKey::title_search)? - .start_with(title_search.as_str())? - .filter(|t| t.as_ref().is_ok_and(|t| db_item.matches(&t.meta))) - .collect::, _>>() - }?; - - library.sort_by_key(|torrent| { - let preferred_types = config.preferred_types(&torrent.meta.media_type); - preferred_types - .iter() - .position(|t| torrent.meta.filetypes.contains(t)) - }); - - let audiobook = library - .iter() - .find(|t| t.meta.media_type.matches(OldMainCat::Audio.into())); - let ebook = library - .iter() - .find(|t| t.meta.media_type.matches(OldMainCat::Ebook.into())); - - let mut updated_any = false; - if let Some(audiobook) = audiobook { - let updated = set_existing(&mut db_item.audio_torrent, audiobook); - trace!( - "Found old audiobook torrent matching RSS item: {}", - audiobook.meta.title - ); - updated_any = updated || updated_any; - } - if let Some(ebook) = ebook { - let updated = set_existing(&mut db_item.ebook_torrent, ebook); - trace!( - "Found old ebook torrent matching RSS item: {}", - ebook.meta.title - ); - updated_any = updated || updated_any; - } - - Ok(updated_any) -} - -fn set_existing(field: &mut Option, torrent: &Torrent) -> bool { - if let Some(field) = field { - if field.status == TorrentStatus::Selected { - return false; - } - if field.status == TorrentStatus::Existing && field.mam_id == torrent.meta.mam_id { - return false; - } - } - field.replace(ListItemTorrent { - mam_id: torrent.meta.mam_id, - status: TorrentStatus::Existing, - at: torrent.created_at, - }); - true -} - -#[instrument(skip_all)] -async fn search_grab( - config: &Config, - mam: &MaM<'_>, - db_item: &ListItem, - grab: &Grab, -) -> Result> { - let (flags_is_hide, flags) = grab.filter.flags.as_search_bitfield(); - - let title_query = db_item.title.replace("*", "\"*\""); - let title_query = BAD_CHARATERS.replace_all(&title_query, " "); - let mut title_query = AND.replace_all(&title_query, "(and|&)"); - if let Some((primary_title, _)) = title_query.split_once(':') { - title_query = Cow::from(format!("(\"{primary_title}\"|\"{title_query}\")")); - } - let query = format!( - "@title {} @author ({})", - title_query, - db_item.authors.iter().map(|a| format!("\"{a}\"")).join("|") - ); - - let mut categories = grab.filter.categories.clone(); - if db_item.audio_torrent.is_some() { - categories.audio = Some(vec![]) - } - if db_item.ebook_torrent.is_some() { - categories.ebook = Some(vec![]) - } - - let mut results: Option = None; - loop { - let mut page_results = mam - .search(&SearchQuery { - fields: SearchFields { - dl_link: true, - ..Default::default() - }, - perpage: 100, - tor: Tor { - start_number: results.as_ref().map_or(0, |r| r.data.len() as u64), - text: query.clone(), - srch_in: vec![SearchIn::Title, SearchIn::Author], - main_cat: categories.get_main_cats(), - cat: categories.get_cats(), - browse_lang: grab.filter.languages.iter().map(|l| l.to_id()).collect(), - browse_flags_hide_vs_show: if flags.is_empty() { - None - } else { - Some(if flags_is_hide { 0 } else { 1 }) - }, - browse_flags: flags.clone(), - start_date: grab - .filter - .uploaded_after - .map_or_else(|| Ok(String::new()), |d| d.format(&DATE_FORMAT))?, - end_date: grab - .filter - .uploaded_before - .map_or_else(|| Ok(String::new()), |d| d.format(&DATE_FORMAT))?, - min_size: grab.filter.min_size.bytes(), - max_size: grab.filter.max_size.bytes(), - unit: grab.filter.min_size.unit().max(grab.filter.max_size.unit()), - min_seeders: grab.filter.min_seeders, - max_seeders: grab.filter.max_seeders, - min_leechers: grab.filter.min_leechers, - max_leechers: grab.filter.max_leechers, - min_snatched: grab.filter.min_snatched, - max_snatched: grab.filter.max_snatched, - ..Default::default() - }, - - ..Default::default() - }) - .await - .context("search")?; - - debug!( - "result: perpage: {}, start: {}, data: {}, total: {}, found: {}", - page_results.perpage, - page_results.start, - page_results.data.len(), - page_results.total, - page_results.found - ); - - if page_results.data.is_empty() { - if results.is_none() { - results = Some(page_results); - } - break; - } - - if let Some(results) = &mut results { - results.data.append(&mut page_results.data); - } else { - results = Some(page_results); - } - - let results = results.as_ref().unwrap(); - if results.data.len() >= results.found { - break; - } - } - - let mut torrents = results - .unwrap() - .data - .into_iter() - .filter(|t| grab.filter.matches(t)) - .map(|t| { - let title_score = score(&db_item.title, &t.title); - let author_score = t - .author_info - .values() - .map(|author| { - db_item - .authors - .iter() - .map(|author_name| score(author_name, author)) - .max() - .unwrap_or_default() - }) - .max() - .unwrap_or_default(); - let series_score: usize = db_item - .series - .iter() - .map(|(i_name, i_num)| { - t.series_info - .values() - .map(|series| { - let Value::String(t_name) = series.first().unwrap_or(&Value::Null) - else { - return 0; - }; - let Value::String(t_num) = series.get(1).unwrap_or(&Value::Null) else { - return 0; - }; - score(i_name, t_name) + score(&i_num.to_string(), t_num) - }) - .max() - .unwrap_or_default() - }) - .sum(); - (t, title_score * 2 + author_score * 2 + series_score) - }) - .collect::>(); - torrents.sort_by_key(|t| -(t.1 as i64)); - - if torrents.is_empty() { - return Ok(Default::default()); - } - let max_score = torrents[0].1; - let mut torrents = torrents - .into_iter() - .take_while(|t| t.1 > max_score - 100) - .map(|(t, _)| { - let meta = t.as_meta()?; - let preferred_types = config.preferred_types(&meta.media_type); - let preference = preferred_types - .iter() - .position(|t| meta.filetypes.contains(t)); - Ok((t, meta, preference.unwrap_or_default(), grab.clone())) - }) - .collect::>>()?; - torrents.sort_by(|a, b| { - a.2.cmp(&b.2).then( - a.0.numfiles - .cmp(&b.0.numfiles) - .then(a.1.size.bytes().cmp(&b.1.size.bytes()).reverse()), - ) - }); - - Ok(torrents) -} diff --git a/server/src/lists/notion.rs b/server/src/lists/notion.rs deleted file mode 100644 index c47e9dd7..00000000 --- a/server/src/lists/notion.rs +++ /dev/null @@ -1,229 +0,0 @@ -use std::{collections::BTreeMap, sync::Arc, time::Duration}; - -use anyhow::{Context, Result}; -use mlm_db::{Torrent, TorrentKey}; -use mlm_mam::api::MaM; -use native_db::Database; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::time::sleep; -use tracing::{instrument, trace}; - -use crate::{ - autograbber::select_torrents, - config::{Config, NotionList}, -}; - -static IMPORT_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -#[instrument(skip_all)] -pub async fn run_notion_import( - config: Arc, - db: Arc>, - mam: Arc>, - list: &NotionList, - max_torrents: u64, -) -> Result<()> { - // Make sure we are only running one import at a time - let _guard = IMPORT_MUTEX.lock().await; - - let content: DatasourceResponse = reqwest::ClientBuilder::new() - .build()? - .post(format!( - "https://api.notion.com/v1/data_sources/{}/query", - list.data_source, - )) - .header("Notion-Version", "2025-09-03") - .header("Authorization", format!("Bearer {}", list.token)) - .send() - .await? - .json() - .await?; - - trace!("Scanning Notion list {}", list.name); - - for item in content.results.into_iter() { - let mam_ids = list - .mam_fields - .iter() - .filter_map(|field| item.properties.get(field)) - .filter_map(|propery| match propery { - Property::Url(url) => url.url.as_ref().and_then(|url| { - url.split('/') - .next_back() - .and_then(|id| id.parse::().ok()) - }), - _ => None, - }) - .collect::>(); - - 'torrent: for mam_id in mam_ids { - let torrent = db - .r_transaction()? - .get() - .secondary::(TorrentKey::mam_id, mam_id)?; - if torrent.is_some() { - continue; - } - - let mam_torrent = mam.get_torrent_info_by_id(mam_id).await?; - if let Some(torrent) = mam_torrent { - for grab in &list.grab { - if !grab.filter.matches(&torrent) { - continue; - } - select_torrents( - &config, - &db, - &mam, - [torrent].into_iter(), - &grab.filter, - grab.cost, - list.unsat_buffer, - list.wedge_buffer, - None, - list.dry_run, - max_torrents, - None, - ) - .await - .context("select_torrents")?; - continue 'torrent; - } - } - sleep(Duration::from_millis(400)).await; - } - } - - Ok(()) -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DatasourceResponse { - pub results: Vec, - pub next_cursor: Option, - pub has_more: bool, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Item { - pub id: String, - pub created_time: String, - pub last_edited_time: String, - pub archived: bool, - pub in_trash: bool, - pub properties: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Property { - Date(Date), - Files(Files), - Number(Number), - Relation(Relation), - Select(Select), - MultiSelect(MultiSelect), - Status(Status), - RichText(RichText), - Title(Title), - Url(Url), -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Date { - pub id: String, - pub date: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct DateValue { - pub start: Option, - pub end: Option, - pub time_zone: Value, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Files { - pub id: String, - pub files: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct FileValue { - pub name: String, - pub external: Option, - pub file: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct External { - pub url: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct File { - pub url: String, - pub expiry_time: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Number { - pub id: String, - pub number: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Relation { - pub id: String, - pub relation: Vec, - pub has_more: bool, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RelationValue { - pub id: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Select { - pub id: String, - pub select: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct MultiSelect { - pub id: String, - pub multi_select: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SelectValue { - pub id: String, - pub name: String, - pub color: String, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Status { - pub id: String, - pub status: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RichText { - pub id: String, - pub rich_text: Vec, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Url { - pub id: String, - pub url: Option, -} - -#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Title { - pub id: String, - pub title: Vec, -} diff --git a/server/src/logging.rs b/server/src/logging.rs deleted file mode 100644 index 3ffabf69..00000000 --- a/server/src/logging.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::fmt::Display; - -use anyhow::{Error, Result}; -use native_db::Database; -use tracing::{error, warn}; - -use mlm_db::{DatabaseExt, ErroredTorrent, ErroredTorrentId, Event, Timestamp, TorrentMeta}; - -#[derive(Debug)] -pub struct TorrentMetaError(pub TorrentMeta, pub anyhow::Error); -impl Display for TorrentMetaError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.1.fmt(f) - } -} -impl std::error::Error for TorrentMetaError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.1.source() - } -} - -pub async fn update_errored_torrent( - db: &Database<'_>, - id: ErroredTorrentId, - torrent: String, - result: Result<(), Error>, -) { - let is_ok = result.is_ok(); - if let Err(err) = db.rw_async().await.and_then(|(_guard, rw)| { - if let Err(err) = result { - let name = match id { - ErroredTorrentId::Grabber(_) => "Autograbber", - ErroredTorrentId::Linker(_) => "Linker", - ErroredTorrentId::Cleaner(_) => "Cleaner", - }; - warn!("{name} error for {torrent}: {err:?}"); - let (err, meta) = match err.downcast::() { - Ok(TorrentMetaError(meta, err)) => (err, Some(meta)), - Err(err) => (err, None), - }; - rw.upsert(ErroredTorrent { - id, - title: torrent, - error: format!("{err:?}"), - meta, - created_at: Timestamp::now(), - })?; - } else if let Some(error) = rw.get().primary::(id)? { - rw.remove(error)?; - } - rw.commit()?; - Ok(()) - }) { - if is_ok { - error!("Error clearing error from db: {err:?}"); - } else { - error!("Error writing error to db: {err:?}"); - } - } -} - -pub async fn write_event(db: &Database<'_>, event: Event) { - if let Err(err) = db.rw_async().await.and_then(|(_guard, rw)| { - rw.upsert(event.clone())?; - rw.commit()?; - Ok(()) - }) { - error!("Error writing event: {err:?}, event: {event:?}"); - } -} diff --git a/server/src/main.rs b/server/src/main.rs deleted file mode 100644 index 48133cee..00000000 --- a/server/src/main.rs +++ /dev/null @@ -1,665 +0,0 @@ -#![windows_subsystem = "windows"] - -mod audiobookshelf; -mod autograbber; -mod cleaner; -mod config; -mod config_impl; -mod exporter; -mod linker; -mod lists; -mod logging; -mod qbittorrent; -mod snatchlist; -mod stats; -mod torrent_downloader; -mod web; -#[cfg(target_family = "windows")] -mod windows; - -use std::{ - collections::BTreeMap, - env, - fs::{self, create_dir_all}, - io, - path::PathBuf, - process, - sync::Arc, - time::Duration, -}; - -use anyhow::{Context as _, Result}; -use audiobookshelf::match_torrents_to_abs; -use autograbber::run_autograbber; -use cleaner::run_library_cleaner; -use dirs::{config_dir, data_local_dir}; -use exporter::export_db; -use figment::{ - Figment, - providers::{Env, Format, Toml}, -}; -use mlm_mam::api::MaM; -use stats::{Stats, Triggers}; -use time::OffsetDateTime; -use tokio::{ - select, - sync::{Mutex, watch}, - time::sleep, -}; -use torrent_downloader::grab_selected_torrents; -use tracing::error; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; -use tracing_subscriber::{ - EnvFilter, Layer as _, fmt::time::LocalTime, layer::SubscriberExt as _, - util::SubscriberInitExt as _, -}; -use web::start_webserver; - -use crate::{ - config::Config, - linker::link_torrents_to_library, - lists::{get_lists, run_list_import}, - snatchlist::run_snatchlist_search, - stats::Context, -}; - -#[tokio::main] -async fn main() { - if let Err(err) = app_main().await { - #[cfg(target_family = "windows")] - windows::error_window::ErrorWindow::create_and_run( - "MLM App Error".to_string(), - err.to_string(), - None, - ) - .unwrap(); - error!("AppError: {err:?}"); - eprintln!("{:?}", err); - process::exit(1); - } -} - -async fn app_main() -> Result<()> { - let log_dir = env::var("MLM_LOG_DIR") - .map(|path| { - if path.is_empty() { - None - } else { - Some(PathBuf::from(path)) - } - }) - .unwrap_or_else(|_| { - #[cfg(all(debug_assertions, not(windows)))] - return None; - #[allow(unused)] - Some( - data_local_dir() - .map(|d| d.join("MLM").join("logs")) - .unwrap_or_else(|| "logs".into()), - ) - }); - - let stderr_layer = tracing_subscriber::fmt::layer() - .pretty() - .with_writer(io::stderr); - - let file_layer = log_dir - .as_ref() - .map(|log_dir| { - Result::<_, anyhow::Error>::Ok( - tracing_subscriber::fmt::layer().pretty().with_writer( - RollingFileAppender::builder() - .rotation(Rotation::DAILY) - .filename_prefix("mlm") - .filename_suffix("log") - .build(log_dir)?, - ), - ) - }) - .transpose()?; - - tracing_subscriber::registry() - .with( - stderr_layer.with_timer(LocalTime::rfc_3339()).with_filter( - EnvFilter::builder() - .with_default_directive("mlm=debug".parse()?) - .with_env_var("MLM_LOG") - .from_env_lossy(), - ), - ) - .with(file_layer.map(|file_layer| { - file_layer - .with_timer(LocalTime::rfc_3339()) - .with_ansi(false) - .with_filter( - EnvFilter::builder() - .with_default_directive("mlm=debug".parse().unwrap()) - .with_env_var("MLM_LOG") - .from_env_lossy(), - ) - })) - .try_init()?; - #[cfg(target_family = "windows")] - std::panic::set_hook(Box::new(tracing_panic::panic_hook)); - - let config_file = env::var("MLM_CONFIG_FILE") - .map(PathBuf::from) - .unwrap_or_else(|_| { - #[cfg(debug_assertions)] - return "config.toml".into(); - #[allow(unused)] - config_dir() - .map(|d| d.join("MLM").join("config.toml")) - .unwrap_or_else(|| "config.toml".into()) - }); - let database_file = env::var("MLM_DB_FILE") - .map(PathBuf::from) - .unwrap_or_else(|_| { - #[cfg(debug_assertions)] - return "data.db".into(); - #[allow(unused)] - data_local_dir() - .map(|d| d.join("MLM").join("data.db")) - .unwrap_or_else(|| "data.db".into()) - }); - if !config_file.exists() { - if let Some(dir) = config_file.parent() { - create_dir_all(dir)?; - } - let default_config = r#"mam_id = """#; - fs::write(&config_file, default_config)?; - } - if !database_file.exists() - && let Some(dir) = database_file.parent() - { - create_dir_all(dir)?; - } - let config: Result = Figment::new() - .merge(Toml::file_exact(&config_file)) - .merge(Env::prefixed("MLM_CONF_")) - .extract(); - #[cfg(target_family = "windows")] - if let Err(err) = &config { - windows::error_window::ErrorWindow::create_and_run( - "MLM Config Error".to_string(), - err.to_string(), - Some(config_file.clone()), - ) - .unwrap(); - return Ok(()); - } - let config = config?; - let config = Arc::new(config); - - let db = native_db::Builder::new().create(&mlm_db::MODELS, database_file)?; - mlm_db::migrate(&db)?; - - if env::args().any(|arg| arg == "--update-search-title") { - mlm_db::update_search_title(&db)?; - return Ok(()); - } - - // export_db(&db)?; - // return Ok(()); - let db = Arc::new(db); - - #[cfg(target_family = "windows")] - let _tray = windows::tray::start_tray_icon(log_dir, config_file.clone(), config.clone())?; - - let stats = Stats::new(); - - let (mut search_tx, mut search_rx) = (BTreeMap::new(), BTreeMap::new()); - let (mut import_tx, mut import_rx) = (BTreeMap::new(), BTreeMap::new()); - let (linker_tx, linker_rx) = watch::channel(()); - let (downloader_tx, mut downloader_rx) = watch::channel(()); - let (audiobookshelf_tx, mut audiobookshelf_rx) = watch::channel(()); - - let mam = if config.mam_id.is_empty() { - Err(anyhow::Error::msg("No mam_id set")) - } else { - MaM::new(&config.mam_id, db.clone()).await.map(Arc::new) - }; - if let Ok(mam) = &mam { - { - let config = config.clone(); - let db = db.clone(); - let mam = mam.clone(); - let stats = stats.clone(); - tokio::spawn(async move { - if let Some(qbit_conf) = config.qbittorrent.first() { - loop { - let mut qbit: Option = None; - if downloader_rx.changed().await.is_err() { - break; - } - if qbit.is_none() { - match qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await - { - Ok(q) => qbit = Some(q), - Err(err) => { - error!("Error logging in to qbit {}: {err}", qbit_conf.url); - stats - .update(|stats| { - stats.downloader_run_at = - Some(OffsetDateTime::now_utc()); - stats.downloader_result = Some(Err(err.into())); - }) - .await; - } - }; - } - let Some(qbit) = &qbit else { - continue; - }; - { - stats - .update(|stats| { - stats.downloader_run_at = Some(OffsetDateTime::now_utc()); - stats.downloader_result = None; - }) - .await; - } - let result = - grab_selected_torrents(&config, &db, qbit, &qbit_conf.url, &mam) - .await - .context("grab_selected_torrents"); - - if let Err(err) = &result { - error!("Error grabbing selected torrents: {err:?}"); - } - { - stats - .update(|stats| { - stats.downloader_result = Some(result); - }) - .await; - } - } - } - }); - } - - for (i, grab) in config.autograbs.iter().enumerate() { - let config = config.clone(); - let db = db.clone(); - let mam = mam.clone(); - let downloader_tx = downloader_tx.clone(); - let (tx, mut rx) = watch::channel(()); - search_tx.insert(i, tx); - search_rx.insert(i, rx.clone()); - let stats = stats.clone(); - let grab = Arc::new(grab.clone()); - tokio::spawn(async move { - loop { - let interval = grab.search_interval.unwrap_or(config.search_interval); - if interval > 0 { - select! { - () = sleep(Duration::from_secs(60 * grab.search_interval.unwrap_or(config.search_interval))) => {}, - result = rx.changed() => { - if let Err(err) = result { - error!("Error listening on search_rx: {err:?}"); - stats.update(|stats| { - stats.autograbber_result.insert(i, Err(err.into())); - }).await; - } - }, - } - } else { - let result = rx.changed().await; - if let Err(err) = result { - error!("Error listening on search_rx: {err:?}"); - stats - .update(|stats| { - stats.autograbber_result.insert(i, Err(err.into())); - }) - .await; - } - } - { - stats - .update(|stats| { - stats - .autograbber_run_at - .insert(i, OffsetDateTime::now_utc()); - stats.autograbber_result.remove(&i); - }) - .await; - } - let result = run_autograbber( - config.clone(), - db.clone(), - mam.clone(), - downloader_tx.clone(), - i, - grab.clone(), - ) - .await - .context("autograbbers"); - if let Err(err) = &result { - error!("Error running autograbbers: {err:?}"); - } - { - stats - .update(|stats| { - stats.autograbber_result.insert(i, result); - }) - .await; - } - } - }); - } - - for (i, grab) in config.snatchlist.iter().enumerate() { - let i = i + config.autograbs.len(); - let config = config.clone(); - let db = db.clone(); - let mam = mam.clone(); - let (tx, mut rx) = watch::channel(()); - search_tx.insert(i, tx); - search_rx.insert(i, rx.clone()); - let stats = stats.clone(); - let grab = Arc::new(grab.clone()); - tokio::spawn(async move { - loop { - let interval = grab.search_interval.unwrap_or(config.search_interval); - if interval > 0 { - select! { - () = sleep(Duration::from_secs(60 * interval)) => {}, - result = rx.changed() => { - if let Err(err) = result { - error!("Error listening on search_rx for snatchlist: {err:?}"); - stats.update(|stats| { - stats.autograbber_result.insert(i, Err(err.into())); - }).await; - } - }, - } - } else { - let result = rx.changed().await; - if let Err(err) = result { - error!("Error listening on search_rx for snatchlist: {err:?}"); - stats - .update(|stats| { - stats.autograbber_result.insert(i, Err(err.into())); - }) - .await; - } - } - { - stats - .update(|stats| { - stats - .autograbber_run_at - .insert(i, OffsetDateTime::now_utc()); - stats.autograbber_result.remove(&i); - }) - .await; - } - let result = run_snatchlist_search( - config.clone(), - db.clone(), - mam.clone(), - i, - grab.clone(), - ) - .await - .context("snatchlist_search"); - if let Err(err) = &result { - error!("Error running snatchlist_search: {err:?}"); - } - { - stats - .update(|stats| { - stats.autograbber_result.insert(i, result); - }) - .await; - } - } - }); - } - - for (i, list) in get_lists(&config).into_iter().enumerate() { - let config = config.clone(); - let db = db.clone(); - let mam = mam.clone(); - let downloader_tx = downloader_tx.clone(); - let (tx, mut rx) = watch::channel(()); - import_tx.insert(i, tx); - import_rx.insert(i, rx.clone()); - let stats = stats.clone(); - let list = Arc::new(list); - tokio::spawn(async move { - loop { - let interval = list.search_interval().unwrap_or(config.import_interval); - if interval > 0 { - select! { - () = sleep(Duration::from_secs(60 * interval)) => {}, - result = rx.changed() => { - if let Err(err) = result { - error!("Error listening on import_rx: {err:?}"); - stats.update(|stats| { - stats.import_result.insert(i, Err(err.into())); - }).await; - } - }, - } - } else { - let result = rx.changed().await; - if let Err(err) = result { - error!("Error listening on import_rx: {err:?}"); - stats - .update(|stats| { - stats.import_result.insert(i, Err(err.into())); - }) - .await; - } - } - { - stats - .update(|stats| { - stats.import_run_at.insert(i, OffsetDateTime::now_utc()); - stats.import_result.remove(&i); - }) - .await; - } - let result = run_list_import( - config.clone(), - db.clone(), - mam.clone(), - list.clone(), - i, - downloader_tx.clone(), - ) - .await - .context("import"); - if let Err(err) = &result { - error!("Error running import: {err:?}"); - } - { - stats - .update(|stats| { - stats.import_result.insert(i, result); - }) - .await; - } - } - }); - } - - { - for qbit_conf in config.qbittorrent.clone() { - let config = config.clone(); - let db = db.clone(); - let mam = mam.clone(); - let stats = stats.clone(); - let mut linker_rx = linker_rx.clone(); - tokio::spawn(async move { - loop { - select! { - () = sleep(Duration::from_secs(60 * config.link_interval)) => {}, - result = linker_rx.changed() => { - if let Err(err) = result { - error!("Error listening on link_rx: {err:?}"); - stats - .update(|stats| { - stats.linker_run_at = Some(OffsetDateTime::now_utc()); - stats.linker_result = Some(Err(err.into())); - }).await; - } - }, - } - { - stats - .update(|stats| { - stats.linker_run_at = Some(OffsetDateTime::now_utc()); - stats.linker_result = None; - }) - .await; - } - let qbit = match qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await - { - Ok(qbit) => qbit, - Err(err) => { - error!("Error logging in to qbit {}: {err}", qbit_conf.url); - stats - .update(|stats| { - stats.linker_run_at = Some(OffsetDateTime::now_utc()); - stats.linker_result = - Some(Err(anyhow::Error::msg(format!( - "Error logging in to qbit {}: {err}", - qbit_conf.url, - )))); - }) - .await; - continue; - } - }; - let result = link_torrents_to_library( - config.clone(), - db.clone(), - (&qbit_conf, &qbit), - mam.clone(), - ) - .await - .context("link_torrents_to_library"); - if let Err(err) = &result { - error!("Error running linker: {err:?}"); - } - { - stats - .update(|stats| { - stats.linker_result = Some(result); - stats.cleaner_run_at = Some(OffsetDateTime::now_utc()); - stats.cleaner_result = None; - }) - .await; - } - let result = run_library_cleaner(config.clone(), db.clone()) - .await - .context("library_cleaner"); - if let Err(err) = &result { - error!("Error running library_cleaner: {err:?}"); - } - { - stats - .update(|stats| { - stats.cleaner_result = Some(result); - }) - .await; - } - } - }); - } - } - } - - if let Some(config) = &config.audiobookshelf { - let config = config.clone(); - let db = db.clone(); - let stats = stats.clone(); - tokio::spawn(async move { - loop { - select! { - () = sleep(Duration::from_secs(60 * config.interval)) => {}, - result = audiobookshelf_rx.changed() => { - if let Err(err) = result { - error!("Error listening on audiobookshelf_rx: {err:?}"); - stats - .update(|stats| { - stats.audiobookshelf_result = Some(Err(err.into())); - }) - .await; - } - }, - } - { - stats - .update(|stats| { - stats.audiobookshelf_run_at = Some(OffsetDateTime::now_utc()); - stats.audiobookshelf_result = None; - }) - .await; - } - let result = match_torrents_to_abs(&config, db.clone()) - .await - .context("audiobookshelf_matcher"); - if let Err(err) = &result { - error!("Error running audiobookshelf matcher: {err:?}"); - } - { - stats - .update(|stats| { - stats.audiobookshelf_result = Some(result); - }) - .await; - } - } - }); - } - - let triggers = Triggers { - search_tx, - import_tx, - linker_tx, - downloader_tx, - audiobookshelf_tx, - }; - #[cfg(target_family = "windows")] - let web_port = config.web_port; - let context = Context { - config: Arc::new(Mutex::new(config)), - db, - mam: Arc::new(mam), - stats, - triggers, - }; - - let result = start_webserver(context).await; - - #[cfg(target_family = "windows")] - if let Err(err) = &result { - windows::error_window::ErrorWindow::create_and_run( - "MLM Webserver Error".to_string(), - format!( - "{err}\r\n\r\nThis usually mean that your port is in use.\r\nConfigured port: {}", - web_port - ), - Some(config_file.clone()), - ) - .unwrap(); - return Ok(()); - } - result?; - - Ok(()) -} diff --git a/server/src/qbittorrent.rs b/server/src/qbittorrent.rs deleted file mode 100644 index 89e3fa63..00000000 --- a/server/src/qbittorrent.rs +++ /dev/null @@ -1,126 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use anyhow::Result; -use once_cell::sync::Lazy; -use qbit::{ - models::Torrent, - parameters::{AddTorrent, TorrentListParams}, -}; -use tokio::sync::RwLock; - -use crate::config::{Config, QbitConfig}; - -const CATEGORY_CACHE_TTL_SECS: u64 = 60; - -#[derive(Clone)] -pub struct CategoryCache { - cache: Arc, Instant)>>>, -} - -impl CategoryCache { - pub fn new() -> Self { - Self { - cache: Arc::new(RwLock::new(HashMap::new())), - } - } - - async fn get_or_fetch(&self, qbit: &qbit::Api, url: &str) -> Result> { - let now = Instant::now(); - let cache = self.cache.read().await; - - if let Some((categories, cached_at)) = cache.get(url) { - if now.duration_since(*cached_at) < Duration::from_secs(CATEGORY_CACHE_TTL_SECS) { - return Ok(categories.clone()); - } - } - drop(cache); - - let categories: HashMap = qbit.categories().await?.into_keys().map(|k| (k, ())).collect(); - let mut cache = self.cache.write().await; - cache.insert(url.to_string(), (categories.clone(), now)); - - Ok(categories) - } - - pub async fn invalidate(&self, url: &str) { - let mut cache = self.cache.write().await; - cache.remove(url); - } -} - -impl Default for CategoryCache { - fn default() -> Self { - Self::new() - } -} - -static CATEGORY_CACHE: Lazy = Lazy::new(CategoryCache::new); - -pub async fn ensure_category_exists( - qbit: &qbit::Api, - url: &str, - category: &str, -) -> Result<()> { - if category.is_empty() { - return Ok(()); - } - - let categories = CATEGORY_CACHE.get_or_fetch(qbit, url).await?; - - if categories.contains_key(category) { - return Ok(()); - } - - if let Err(e) = qbit.create_category(category, "").await { - qbit.create_category(category, "").await.map_err(|_| e)?; - } - - CATEGORY_CACHE.invalidate(url).await; - Ok(()) -} - -pub async fn add_torrent_with_category( - qbit: &qbit::Api, - url: &str, - add_torrent: AddTorrent, -) -> Result<()> { - if let Some(ref category) = add_torrent.category { - if !category.is_empty() { - ensure_category_exists(qbit, url, category).await?; - } - } - - qbit.add_torrent(add_torrent).await.map_err(|e| anyhow::Error::new(e)) -} - -pub async fn get_torrent<'a, 'b>( - config: &'a Config, - hash: &'b str, -) -> Result> { - for qbit_conf in config.qbittorrent.iter() { - let Ok(qbit) = qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await - else { - continue; - }; - let Some(torrent) = qbit - .torrents(Some(TorrentListParams { - hashes: Some(vec![hash.to_string()]), - ..TorrentListParams::default() - })) - .await? - .into_iter() - .next() - else { - continue; - }; - return Ok(Some((torrent, qbit, qbit_conf))); - } - Ok(None) -} diff --git a/server/src/snatchlist.rs b/server/src/snatchlist.rs deleted file mode 100644 index d2600432..00000000 --- a/server/src/snatchlist.rs +++ /dev/null @@ -1,309 +0,0 @@ -use std::{sync::Arc, time::Duration}; - -use anyhow::{Context, Result, bail}; -use itertools::Itertools as _; -use mlm_db::{ - DatabaseExt, Event, EventType, MetadataSource, Timestamp, Torrent, TorrentKey, TorrentMeta, - VipStatus, -}; -use mlm_mam::{api::MaM, meta::MetaError, user_torrent::UserDetailsTorrent}; -use mlm_parse::normalize_title; -use native_db::{Database, db_type, transaction::RwTransaction}; -use time::UtcDateTime; -use tokio::{sync::MutexGuard, time::sleep}; -use tracing::{Level, debug, enabled, info, instrument, trace, warn}; -use uuid::Uuid; - -use crate::{ - config::{Config, Cost, SnatchlistSearch, TorrentFilter}, - logging::write_event, -}; - -#[instrument(skip_all)] -pub async fn run_snatchlist_search( - config: Arc, - db: Arc>, - mam: Arc>, - index: usize, - snatchlist_config: Arc, -) -> Result<()> { - if !snatchlist_config.filter.languages.is_empty() { - bail!("Language filtering is not supported in snatchlist searches"); - } - if snatchlist_config.filter.uploaded_after.is_some() - || snatchlist_config.filter.uploaded_before.is_some() - { - bail!("Uploaded date filtering is not supported in snatchlist searches"); - } - if snatchlist_config.cost != Cost::MetadataOnly - && snatchlist_config.cost != Cost::MetadataOnlyAdd - { - bail!("Only metadata costs are supported in snatchlist searches"); - } - - let name = snatchlist_config - .filter - .name - .clone() - .unwrap_or_else(|| index.to_string()); - debug!("snatchlist {}", name); - - search_and_update_torrents(&config, &db, &snatchlist_config, &mam) - .await - .context("search_torrents")?; - - Ok(()) -} - -#[instrument(skip_all)] -async fn search_and_update_torrents( - config: &Config, - db: &Database<'_>, - torrent_search: &SnatchlistSearch, - mam: &MaM<'_>, -) -> Result<()> { - let max_pages = torrent_search.max_pages.unwrap_or(100); - let now = UtcDateTime::now(); - - for page in 0.. { - let page_results = mam - .snatchlist(torrent_search.kind, page, now) - .await - .context("search")?; - - let row_count = page_results.rows.len(); - debug!( - "result: rows: {}, success: {}", - row_count, page_results.success, - ); - - if page_results.rows.is_empty() { - break; - } - - if enabled!(Level::TRACE) { - trace!( - "torrents in result: {:?}", - page_results.rows.iter().map(|t| t.id).collect::>() - ) - } - let torrents = page_results - .rows - .into_iter() - .filter(|t| torrent_search.filter.matches_user(t)); - - update_torrents( - config, - db, - torrents, - &torrent_search.filter, - torrent_search.cost, - torrent_search.dry_run, - ) - .await - .context("update_torrents")?; - - if page >= max_pages as u64 || row_count < 250 || row_count > 250 { - break; - } - sleep(Duration::from_millis(400)).await; - } - - Ok(()) -} - -#[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -async fn update_torrents>( - config: &Config, - db: &Database<'_>, - torrents: T, - grabber: &TorrentFilter, - cost: Cost, - dry_run: bool, -) -> Result<()> { - 'torrent: for torrent in torrents { - if config.ignore_torrents.contains(&torrent.id) { - trace!("Torrent {} is ignored", torrent.id); - continue; - } - - let meta = match torrent.as_meta() { - Ok(it) => it, - Err(err) => match err { - MetaError::UnknownMediaType(_) => { - warn!("{err} for torrent {} {}", torrent.id, torrent.title); - continue; - } - _ => return Err(err.into()), - }, - }; - if grabber.matches_meta(&meta).is_ok_and(|matches| !matches) { - continue; - } - let rw_opt = if dry_run { - None - } else { - Some(db.rw_async().await?) - }; - if let Some((_, rw)) = &rw_opt { - let old_library = rw - .get() - .secondary::(TorrentKey::mam_id, meta.mam_id)?; - if let Some(old) = old_library { - if old.meta != meta { - update_torrent_meta( - db, - rw_opt.unwrap(), - &torrent, - old, - meta, - cost == Cost::MetadataOnlyAdd, - ) - .await?; - } - trace!("Torrent {} is already in library", torrent.id); - continue 'torrent; - } - } - if cost == Cost::MetadataOnlyAdd { - let mam_id = meta.mam_id; - add_metadata_only_torrent(rw_opt.unwrap(), torrent, meta) - .await - .or_else(|err| { - let err = err.downcast::()?; - if let db_type::Error::DuplicateKey { .. } = err { - warn!("Got dup key when adding torrent {}", mam_id); - Result::<(), anyhow::Error>::Ok(()) - } else { - Err(err.into()) - } - })?; - continue 'torrent; - } - if cost != Cost::MetadataOnly { - warn!( - "Ignoring cost {:?} for snatchlist torrent, only metadata updates supported", - cost - ); - } - continue 'torrent; - } - Ok(()) -} - -#[instrument(skip_all)] -async fn add_metadata_only_torrent( - (_guard, rw): (MutexGuard<'_, ()>, RwTransaction<'_>), - torrent: UserDetailsTorrent, - meta: TorrentMeta, -) -> Result<()> { - info!("Adding metadata only torrent \"{}\"", meta.title); - let id = Uuid::new_v4().to_string(); - - let mam_id = torrent.id; - { - rw.insert(Torrent { - id, - id_is_hash: false, - mam_id, - abs_id: None, - goodreads_id: None, - library_path: None, - library_files: Default::default(), - linker: if torrent.uploader_name.is_empty() { - None - } else { - Some(torrent.uploader_name) - }, - category: None, - selected_audio_format: None, - selected_ebook_format: None, - title_search: normalize_title(&meta.title), - meta, - created_at: Timestamp::now(), - replaced_with: None, - request_matadata_update: false, - library_mismatch: None, - client_status: None, - })?; - rw.commit()?; - } - - Ok(()) -} - -async fn update_torrent_meta( - db: &Database<'_>, - (guard, rw): (MutexGuard<'_, ()>, RwTransaction<'_>), - mam_torrent: &UserDetailsTorrent, - mut torrent: Torrent, - mut meta: TorrentMeta, - linker_is_owner: bool, -) -> Result<()> { - // These are missing in user details torrent response, so keep the old values - meta.media_type = torrent.meta.media_type; - meta.main_cat = torrent.meta.main_cat; - meta.language = torrent.meta.language; - meta.num_files = torrent.meta.num_files; - meta.uploaded_at = torrent.meta.uploaded_at; - - if torrent.meta.source != MetadataSource::Mam { - // Update VIP status still - if torrent.meta.vip_status != meta.vip_status { - torrent.meta.vip_status = meta.vip_status; - rw.upsert(torrent.clone())?; - rw.commit()?; - } - return Ok(()); - } - - // Check expiring VIP - if torrent.meta.vip_status != meta.vip_status - && torrent - .meta - .vip_status - .as_ref() - .is_some_and(|s| !s.is_vip()) - && meta.vip_status == Some(VipStatus::NotVip) - { - torrent.meta.vip_status = meta.vip_status.clone(); - // If expiring VIP was the only change, just silently update the database - if torrent.meta == meta { - rw.upsert(torrent.clone())?; - rw.commit()?; - return Ok(()); - } - } - - if linker_is_owner && torrent.linker.is_none() { - torrent.linker = Some(mam_torrent.uploader_name.clone()); - } else if meta == torrent.meta { - return Ok(()); - } - - let id = torrent.id.clone(); - let mam_id = meta.mam_id; - let diff = torrent.meta.diff(&meta); - debug!( - "Updating meta for torrent {}, diff:\n{}", - mam_id, - diff.iter() - .map(|field| format!(" {}: {} → {}", field.field, field.from, field.to)) - .join("\n") - ); - torrent.meta = meta.clone(); - torrent.title_search = normalize_title(&meta.title); - rw.upsert(torrent.clone())?; - rw.commit()?; - drop(guard); - - if !diff.is_empty() { - write_event( - db, - Event::new(Some(id), Some(mam_id), EventType::Updated { fields: diff }), - ) - .await; - } - Ok(()) -} diff --git a/server/src/stats.rs b/server/src/stats.rs deleted file mode 100644 index 186aa6b1..00000000 --- a/server/src/stats.rs +++ /dev/null @@ -1,92 +0,0 @@ -use std::{collections::BTreeMap, sync::Arc}; - -use anyhow::Result; -use mlm_db::Event; -use mlm_mam::api::MaM; -use native_db::Database; -use time::{OffsetDateTime, UtcDateTime}; -use tokio::sync::{ - Mutex, - watch::{self, Receiver, Sender}, -}; - -use crate::config::Config; - -#[derive(Default)] -pub struct StatsValues { - pub autograbber_run_at: BTreeMap, - pub autograbber_result: BTreeMap>, - pub import_run_at: BTreeMap, - pub import_result: BTreeMap>, - pub linker_run_at: Option, - pub linker_result: Option>, - pub cleaner_run_at: Option, - pub cleaner_result: Option>, - pub downloader_run_at: Option, - pub downloader_result: Option>, - pub audiobookshelf_run_at: Option, - pub audiobookshelf_result: Option>, -} - -#[derive(Clone)] -pub struct Stats { - pub values: Arc>, - values_updated: (Sender, Receiver), -} - -impl Stats { - pub fn new() -> Self { - Self { - values: Arc::new(Mutex::new(StatsValues::default())), - values_updated: watch::channel(UtcDateTime::now()), - } - } - - pub async fn update(&self, f: impl FnOnce(&mut StatsValues)) { - let mut data = self.values.lock().await; - f(&mut data); - self.values_updated.0.send(UtcDateTime::now()).unwrap(); - } - - pub fn updates(&self) -> Receiver { - self.values_updated.1.clone() - } -} - -#[derive(Clone)] -pub struct Events { - pub event: (Sender>, Receiver>), -} - -#[derive(Clone)] -pub struct Triggers { - pub search_tx: BTreeMap>, - pub import_tx: BTreeMap>, - pub linker_tx: Sender<()>, - pub downloader_tx: Sender<()>, - pub audiobookshelf_tx: Sender<()>, -} - -#[derive(Clone)] -pub struct Context { - pub config: Arc>>, - pub db: Arc>, - pub mam: Arc>>>, - pub stats: Stats, - // pub events: Events, - pub triggers: Triggers, -} - -impl Context { - pub async fn config(&self) -> Arc { - self.config.lock().await.clone() - } - - pub fn mam(&self) -> Result>> { - let Ok(mam) = self.mam.as_ref() else { - return Err(anyhow::Error::msg("mam_id error")); - }; - - Ok(mam.clone()) - } -} diff --git a/server/src/torrent_downloader.rs b/server/src/torrent_downloader.rs deleted file mode 100644 index b404ed12..00000000 --- a/server/src/torrent_downloader.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::time::Duration; - -use anyhow::{Result, anyhow}; -use bytes::Bytes; -use lava_torrent::torrent::v1::Torrent; -use mlm_db::{ - DatabaseExt as _, ErroredTorrentId, Event, EventType, SelectedTorrent, Size, Timestamp, - TorrentCost, -}; -use mlm_mam::api::{MaM, RateLimitError, WedgeBuyError}; -use native_db::Database; -use qbit::{ - models::Torrent as QbitTorrent, - parameters::{AddTorrent, AddTorrentType, TorrentFile, TorrentListParams, TorrentState}, -}; -use tokio::time::sleep; -use tracing::{debug, info, instrument, trace, warn}; - -use crate::{ - config::Config, - logging::{TorrentMetaError, update_errored_torrent, write_event}, - qbittorrent::add_torrent_with_category, -}; - -#[instrument(skip_all)] -pub async fn grab_selected_torrents( - config: &Config, - db: &Database<'_>, - qbit: &qbit::Api, - qbit_url: &str, - mam: &MaM<'_>, -) -> Result<()> { - let selected_torrents = { - let r = db.r_transaction()?; - r.scan() - .primary::()? - .all()? - .filter(|t| t.as_ref().is_ok_and(|t| t.removed_at.is_none())) - .collect::, native_db::db_type::Error>>() - }?; - if selected_torrents.is_empty() { - trace!("no selected torrents"); - return Ok(()); - } - - let user_info = mam.user_info().await?; - let max_torrents = user_info.unsat.limit.saturating_sub(user_info.unsat.count); - - let downloading_size: f64 = selected_torrents - .iter() - .filter(|t| t.started_at.is_some()) - .map(|t| t.meta.size.bytes() as f64) - .sum(); - - let mut remaining_buffer = (user_info.uploaded_bytes - user_info.downloaded_bytes - downloading_size) - / config.min_ratio; - debug!( - "downloader, unsats: {:#?}; max_torrents: {max_torrents}; buffer: {}", - user_info.unsat, - Size::from_bytes(remaining_buffer as u64) - ); - - let mut snatched_torrents = 0; - for torrent in selected_torrents - .into_iter() - .filter(|t| t.started_at.is_none()) - { - let max_torrents = max_torrents - .saturating_sub(torrent.unsat_buffer.unwrap_or(config.unsat_buffer)) - .saturating_sub(snatched_torrents); - if max_torrents == 0 { - continue; - } - let buffer_after = remaining_buffer - torrent.meta.size.bytes() as f64; - if buffer_after <= 0.0 { - continue; - } - - let result = grab_torrent(config, db, qbit, qbit_url, mam, torrent.clone()) - .await - .map_err(|err| anyhow::Error::new(TorrentMetaError(torrent.meta.clone(), err))); - - if result.is_ok() { - snatched_torrents += 1; - remaining_buffer = buffer_after; - } - - update_errored_torrent( - db, - ErroredTorrentId::Grabber(torrent.mam_id), - torrent.meta.title, - result, - ) - .await; - - sleep(Duration::from_millis(1000)).await; - } - Ok(()) -} - -#[instrument(skip_all)] -async fn grab_torrent( - config: &Config, - db: &Database<'_>, - qbit: &qbit::Api, - qbit_url: &str, - mam: &MaM<'_>, - torrent: SelectedTorrent, -) -> Result<()> { - info!( - "Grabbing torrent \"{}\", with category {:?} and tags {:?}", - torrent.meta.title, torrent.category, torrent.tags, - ); - - let user_info = mam.user_info().await?; - let torrent_file_bytes = get_mam_torrent_file(mam, &torrent.dl_link).await?; - let torrent_file = Torrent::read_from_bytes(torrent_file_bytes.clone())?; - let hash = torrent_file.info_hash(); - - if let Some(qbit_torrent) = get_existing_qbit_torrent(config, qbit, qbit_url, &hash).await { - let is_completed = matches!( - qbit_torrent.state, - TorrentState::Uploading - | TorrentState::StoppedUploading - | TorrentState::QueuedUploading - | TorrentState::StalledUploading - | TorrentState::CheckingUploading - | TorrentState::ForcedUploading - ); - - let (_guard, rw) = db.rw_async().await?; - - if is_completed { - if rw.get().primary::(hash.clone())?.is_none() { - rw.upsert(mlm_db::Torrent { - id: hash.clone(), - id_is_hash: true, - mam_id: torrent.meta.mam_id, - abs_id: None, - goodreads_id: torrent.goodreads_id, - library_path: None, - library_files: Default::default(), - linker: None, - category: torrent.category.clone(), - selected_audio_format: None, - selected_ebook_format: None, - title_search: torrent.title_search.clone(), - meta: torrent.meta.clone(), - created_at: Timestamp::now(), - replaced_with: None, - request_matadata_update: false, - library_mismatch: None, - client_status: None, - })?; - } - rw.remove(torrent)?; - } else { - let mut t = torrent; - t.started_at = Some(Timestamp::now()); - rw.upsert(t)?; - } - rw.commit()?; - - return Ok(()); - } - - let library_existing = { - let r = db.r_transaction()?; - r.get().primary::(hash.clone())? - }; - - if let Some(existing) = library_existing - && existing.library_path.is_some() - { - let (_guard, rw) = db.rw_async().await?; - rw.remove(torrent)?; - rw.commit()?; - return Ok(()); - } - - let wedge_buffer = torrent.wedge_buffer.unwrap_or(config.wedge_buffer); - let mut wedged = false; - if torrent.cost == TorrentCost::UseWedge || torrent.cost == TorrentCost::TryWedge { - if user_info.wedges <= wedge_buffer { - return Err(anyhow::Error::msg(format!( - "Fewer wedges ({}) than wedge_buffer ({})", - user_info.wedges, wedge_buffer - ))); - } - info!("Using wedge on torrent \"{}\"", torrent.meta.title); - match mam.wedge_torrent(torrent.mam_id).await { - Ok(_) => { - wedged = true; - if let Some((_, user_info)) = mam.user.lock().await.as_mut() { - user_info.wedges = user_info.wedges.saturating_sub(1); - } - } - Err(err) => { - warn!( - "Failed applying wedge for torrent {}: {}", - torrent.mam_id, err - ); - match err.downcast::() { - Ok( - WedgeBuyError::IsVip - | WedgeBuyError::IsGlobalFreeleech - | WedgeBuyError::IsPersonalFreeleech, - ) => {} - _ => { - if torrent.cost == TorrentCost::UseWedge { - return Err(anyhow!("Failed to apply wedge for torrent")); - } - } - } - } - } - } else if torrent.cost != TorrentCost::Ratio { - let Some(torrent_info) = mam.get_torrent_info(&hash).await? else { - return Err(anyhow!("Could not get torrent from MaM")); - }; - if !torrent_info.is_free() { - return Err(anyhow!("Torrent is no longer free, expected: {:?}", torrent.cost)); - } - } - - mam.add_unsats(1).await; - add_torrent_with_category( - qbit, - qbit_url, - AddTorrent { - torrents: AddTorrentType::Files(vec![TorrentFile { - filename: format!("{}.torrent", torrent.mam_id), - data: torrent_file_bytes.iter().copied().collect(), - }]), - stopped: config.add_torrents_stopped, - category: torrent.category.clone(), - tags: if torrent.tags.is_empty() { - None - } else { - Some(torrent.tags.clone()) - }, - ..Default::default() - }, - ) - .await?; - - let mam_id = torrent.mam_id; - let cost = Some(torrent.cost); - let grabber = torrent.grabber.clone(); - { - let (_guard, rw) = db.rw_async().await?; - rw.upsert(mlm_db::Torrent { - id: hash.clone(), - id_is_hash: true, - mam_id: torrent.meta.mam_id, - abs_id: None, - goodreads_id: torrent.goodreads_id, - library_path: None, - library_files: Default::default(), - linker: None, - category: torrent.category.clone(), - selected_audio_format: None, - selected_ebook_format: None, - title_search: torrent.title_search.clone(), - meta: torrent.meta.clone(), - created_at: Timestamp::now(), - replaced_with: None, - request_matadata_update: false, - library_mismatch: None, - client_status: None, - })?; - let mut torrent = torrent; - torrent.hash = Some(hash.clone()); - torrent.started_at = Some(Timestamp::now()); - rw.upsert(torrent)?; - rw.commit()?; - } - - write_event( - db, - Event::new( - Some(hash), - Some(mam_id), - EventType::Grabbed { - grabber, - cost, - wedged, - }, - ), - ) - .await; - - Ok(()) -} - -async fn get_existing_qbit_torrent( - config: &Config, - qbit: &qbit::Api, - qbit_url: &str, - hash: &str, -) -> Option { - if let Ok(Some(qbit_torrent)) = qbit - .torrents(Some(TorrentListParams { - hashes: Some(vec![hash.to_string()]), - ..TorrentListParams::default() - })) - .await - .map(|t| t.into_iter().next()) - { - return Some(qbit_torrent); - } - - for qbit_conf in config.qbittorrent.iter().filter(|q| q.url != qbit_url) { - let Ok(qbit) = qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await - else { - continue; - }; - - if let Ok(Some(qbit_torrent)) = qbit - .torrents(Some(TorrentListParams { - hashes: Some(vec![hash.to_string()]), - ..TorrentListParams::default() - })) - .await - .map(|t| t.into_iter().next()) - { - return Some(qbit_torrent); - } - } - - None -} - -pub(crate) async fn get_mam_torrent_file(mam: &MaM<'_>, dl_link: &str) -> Result { - loop { - let result = mam.get_torrent_file(dl_link).await; - - match result { - Ok(v) => return Ok(v), - Err(e) => match e.downcast::() { - Ok(_) => { - sleep(Duration::from_millis(30_000)).await; - } - Err(e) => return Err(e), - }, - }; - } -} diff --git a/server/src/web/api/mod.rs b/server/src/web/api/mod.rs deleted file mode 100644 index a4d3f987..00000000 --- a/server/src/web/api/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod search; -pub mod torrent; diff --git a/server/src/web/api/search.rs b/server/src/web/api/search.rs deleted file mode 100644 index f5d17c07..00000000 --- a/server/src/web/api/search.rs +++ /dev/null @@ -1,138 +0,0 @@ -use std::{fs::File, path::PathBuf}; - -use anyhow::Result; -use axum::{ - Json, - extract::{Query, State}, -}; -use axum_extra::extract::Form; -use mlm_mam::search::{MaMTorrent, SearchFields}; -use serde::{Deserialize, Serialize}; -use tokio::fs::create_dir_all; - -use crate::{ - autograbber::{mark_removed_torrents, search_torrents, select_torrents}, - config::{Cost, TorrentSearch}, - stats::Context, - web::{AppError, MaMState}, -}; - -pub async fn search_api( - State(mam): State, - Query(query): Query, -) -> std::result::Result, AppError> { - let Ok(mam) = mam.as_ref() else { - return Err(anyhow::Error::msg("mam_id error").into()); - }; - let search: TorrentSearch = toml::from_str(&query.toml)?; - let torrents = search_torrents( - &search, - SearchFields { - description: true, - isbn: true, - media_info: true, - ..Default::default() - }, - mam, - ) - .await? - .collect::>(); - - Ok::<_, AppError>(Json(SearchApiResponse { - torrents: Some(torrents), - ..Default::default() - })) -} - -pub async fn search_api_post( - State(context): State, - Form(form): Form, -) -> Result, AppError> { - let config = context.config().await; - let mam = context.mam()?; - let search: TorrentSearch = toml::from_str(&form.toml)?; - let torrents = search_torrents( - &search, - SearchFields { - description: true, - isbn: true, - media_info: true, - dl_link: form.add - && search.cost != Cost::MetadataOnly - && search.cost != Cost::MetadataOnlyAdd, - ..Default::default() - }, - &mam, - ) - .await? - .collect::>(); - if form.write_json { - for torrent in &torrents { - let id_str = torrent.id.to_string(); - let first = id_str.get(0..1).unwrap_or_default(); - let second = id_str.get(1..2).unwrap_or_default(); - let third = id_str.get(3..4).unwrap_or_default(); - let path = PathBuf::from("/data/torrents") - .join(first) - .join(second) - .join(third); - create_dir_all(&path).await.unwrap(); - let file_path = path.join(format!("{}.json", torrent.id)); - let file = File::create(file_path).unwrap(); - serde_json::to_writer(file, torrent).unwrap(); - } - } - - if form.mark_removed { - mark_removed_torrents(&context.db, &mam, &torrents).await?; - } - - if form.add { - select_torrents( - &config, - &context.db, - &mam, - torrents.into_iter(), - &search.filter, - search.cost, - search.unsat_buffer, - search.wedge_buffer, - search.category.clone(), - search.dry_run, - u64::MAX, - None, - ) - .await?; - return Ok::<_, AppError>(Json(SearchApiResponse { - added: Some(true), - ..Default::default() - })); - } - - Ok::<_, AppError>(Json(SearchApiResponse { - torrents: Some(torrents), - ..Default::default() - })) -} - -#[derive(Debug, Deserialize)] -pub struct SearchApiForm { - toml: String, - #[serde(default)] - add: bool, - #[serde(default)] - mark_removed: bool, - #[serde(default)] - write_json: bool, -} - -#[derive(Deserialize)] -pub struct SearchApiQuery { - toml: String, -} - -#[derive(Default, Serialize)] -pub struct SearchApiResponse { - torrents: Option>, - added: Option, -} diff --git a/server/src/web/api/torrent.rs b/server/src/web/api/torrent.rs deleted file mode 100644 index 2f83c69b..00000000 --- a/server/src/web/api/torrent.rs +++ /dev/null @@ -1,78 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use mlm_db::{Torrent, TorrentKey}; -use serde_json::json; - -use crate::{ - qbittorrent::{self}, - stats::Context, - web::AppError, -}; - -pub async fn torrent_api( - State(context): State, - Path(id_or_mam_id): Path, -) -> std::result::Result, AppError> { - if let Ok(id) = id_or_mam_id.parse() { - torrent_api_mam_id(State(context), Path(id)).await - } else { - torrent_api_id(State(context), Path(id_or_mam_id)).await - } -} - -async fn torrent_api_mam_id( - State(context): State, - Path(mam_id): Path, -) -> std::result::Result, AppError> { - if let Some(torrent) = context - .db - .r_transaction()? - .get() - .secondary::(TorrentKey::mam_id, mam_id)? - { - return torrent_api_id(State(context), Path(torrent.id)).await; - }; - - let mam = context.mam()?; - let Some(mam_torrent) = mam.get_torrent_info_by_id(mam_id).await? else { - return Err(AppError::NotFound); - }; - let meta = mam_torrent.as_meta()?; - - Ok::<_, AppError>(Json(json!({ - "mam_torrent": mam_torrent, - "meta": meta, - }))) -} - -async fn torrent_api_id( - State(context): State, - Path(id): Path, -) -> std::result::Result, AppError> { - let config = context.config().await; - let Some(torrent) = context.db.r_transaction()?.get().primary::(id)? else { - return Err(AppError::NotFound); - }; - let mut qbit_torrent = None; - let mut qbit_files = vec![]; - if torrent.id_is_hash - && let Some((qbit_torrent_, qbit, _)) = - qbittorrent::get_torrent(&config, &torrent.id).await? - { - qbit_torrent = Some(qbit_torrent_); - qbit_files = qbit.files(&torrent.id, None).await?; - } - - Ok::<_, AppError>(Json(json!({ - "abs_url": config - .audiobookshelf - .as_ref() - .map(|abs| abs.url.clone()) - .unwrap_or_default(), - "torrent": torrent, - "qbit_torrent": qbit_torrent, - "qbit_files": qbit_files, - }))) -} diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs deleted file mode 100644 index 23d928fb..00000000 --- a/server/src/web/mod.rs +++ /dev/null @@ -1,503 +0,0 @@ -mod api; -mod pages; -mod tables; - -use std::{path::PathBuf, sync::Arc}; - -use anyhow::Result; -use askama::{Template, filters::HtmlSafe}; -use axum::{ - Router, - body::Body, - http::{HeaderValue, Request, StatusCode}, - middleware::{self, Next}, - response::{Html, IntoResponse, Response}, - routing::{get, post}, -}; -use itertools::Itertools; -use mlm_db::{ - AudiobookCategory, Category, EbookCategory, Flags, SelectedTorrent, Series, Timestamp, Torrent, - TorrentMeta, -}; -use mlm_mam::{api::MaM, meta::MetaError, search::MaMTorrent, serde::DATE_FORMAT}; -use once_cell::sync::Lazy; -use pages::{ - config::{config_page, config_page_post}, - duplicate::{duplicate_page, duplicate_torrents_page_post}, - errors::{errors_page, errors_page_post}, - events::event_page, - index::{index_page, index_page_post}, - list::{list_page, list_page_post}, - lists::lists_page, - replaced::{replaced_torrents_page, replaced_torrents_page_post}, - selected::{selected_page, selected_torrents_page_post}, - torrent::{torrent_file, torrent_page, torrent_page_post}, - torrent_edit::{torrent_edit_page, torrent_edit_page_post}, - torrents::{torrents_page, torrents_page_post}, -}; -use reqwest::header; -use serde::Serialize; -use tables::{ItemFilter, ItemFilters, Key}; -use time::{ - Date, UtcDateTime, UtcOffset, - format_description::{self, OwnedFormatItem}, -}; -use tokio::sync::watch::error::SendError; -use tower::ServiceBuilder; -use tracing::warn; -#[allow(unused)] -use tower_http::services::{ServeDir, ServeFile}; - -use crate::{ - config::{SearchConfig, TorrentFilter}, - stats::Context, - web::{ - api::{ - search::{search_api, search_api_post}, - torrent::torrent_api, - }, - pages::{ - index::stats_updates, - search::{search_page, search_page_post}, - }, - }, -}; - -pub type MaMState = Arc>>>; - -pub async fn start_webserver(context: Context) -> Result<()> { - let config = context.config().await; - let assets_dir = resolve_assets_dir(); - if !assets_dir.exists() { - warn!("Static assets directory does not exist: {}", assets_dir.display()); - } - - let app = Router::new() - .route("/", get(index_page).with_state(context.clone())) - .route("/", post(index_page_post).with_state(context.clone())) - .route( - "/stats-updates", - get(stats_updates).with_state(context.clone()), - ) - .route("/torrents", get(torrents_page).with_state(context.clone())) - .route( - "/torrents", - post(torrents_page_post).with_state(context.clone()), - ) - .route( - "/torrents/{id}", - get(torrent_page).with_state(context.clone()), - ) - .route( - "/torrents/{id}", - post(torrent_page_post).with_state(context.clone()), - ) - .route( - "/torrents/{id}/edit", - get(torrent_edit_page).with_state(context.db.clone()), - ) - .route( - "/torrents/{id}/edit", - post(torrent_edit_page_post).with_state(context.clone()), - ) - .route( - "/torrents/{id}/{filename}", - get(torrent_file).with_state(context.clone()), - ) - .route("/events", get(event_page).with_state(context.db.clone())) - .route("/search", get(search_page).with_state(context.clone())) - .route( - "/search", - post(search_page_post).with_state(context.clone()), - ) - .route("/lists", get(lists_page).with_state(context.clone())) - .route( - "/lists/{list_id}", - get(list_page).with_state(context.db.clone()), - ) - .route( - "/lists/{list_id}", - post(list_page_post).with_state(context.db.clone()), - ) - .route("/errors", get(errors_page).with_state(context.db.clone())) - .route( - "/errors", - post(errors_page_post).with_state(context.db.clone()), - ) - .route("/selected", get(selected_page).with_state(context.clone())) - .route( - "/selected", - post(selected_torrents_page_post).with_state(context.db.clone()), - ) - .route( - "/replaced", - get(replaced_torrents_page).with_state(context.clone()), - ) - .route( - "/replaced", - post(replaced_torrents_page_post).with_state(context.clone()), - ) - .route( - "/duplicate", - get(duplicate_page).with_state(context.clone()), - ) - .route( - "/duplicate", - post(duplicate_torrents_page_post).with_state(context.clone()), - ) - .route("/config", get(config_page).with_state(config.clone())) - .route( - "/config", - post(config_page_post).with_state(context.clone()), - ) - .route( - "/api/search", - get(search_api).with_state(context.mam.clone()), - ) - .route( - "/api/search", - post(search_api_post).with_state(context.clone()), - ) - .route( - "/api/torrents/{id}", - get(torrent_api).with_state(context.clone()), - ) - .nest_service( - "/assets", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeDir::new(&assets_dir)), - ); - - #[cfg(debug_assertions)] - let app = app.nest_service( - "/assets/favicon.png", - ServiceBuilder::new() - .layer(middleware::from_fn(set_static_cache_control)) - .service(ServeFile::new( - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/favicon_dev.png"), - )), - ); - - let listener = - tokio::net::TcpListener::bind((config.web_host.clone(), config.web_port)).await?; - axum::serve(listener, app).await?; - - Ok(()) -} - -fn resolve_assets_dir() -> PathBuf { - if let Ok(path) = std::env::var("MLM_ASSETS_DIR") - && !path.is_empty() - { - return PathBuf::from(path); - } - - if let Some(data_dir) = dirs::data_local_dir() { - let packaged = data_dir.join("Myanonamouse Library Manager").join("assets"); - if packaged.exists() { - return packaged; - } - let legacy = data_dir.join("MLM").join("assets"); - if legacy.exists() { - return legacy; - } - } - - if let Ok(exe) = std::env::current_exe() - && let Some(exe_dir) = exe.parent() - { - let alongside_exe = exe_dir.join("assets"); - if alongside_exe.exists() { - return alongside_exe; - } - } - - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets") -} - -pub trait Page { - fn build_date(&self) -> &'static str { - env!("DATE") - } - - fn item_path(&self) -> &'static str { - "" - } - - fn item<'a, T: Key>(&'a self, field: T, label: &'a str) -> ItemFilter<'a, T> { - ItemFilter { - field, - label, - value: None, - path: self.item_path(), - } - } - - fn item_v<'a, T: Key>(&self, field: T, label: &'a str, value: &'a str) -> ItemFilter<'a, T> { - ItemFilter { - field, - label, - value: Some(value.to_string()), - path: self.item_path(), - } - } - - fn items<'a, T: Key>(&self, field: T, labels: &'a [String]) -> ItemFilters<'a, T> { - ItemFilters { - field, - labels, - path: self.item_path(), - } - } - - fn cats<'a, T: Key>(&'a self, field: T, cats: &'a Vec) -> CatsTmpl<'a, T> { - CatsTmpl { - field, - cats, - path: self.item_path(), - } - } - - fn series<'a, T: Key>(&'a self, field: T, series: &'a Vec) -> SeriesTmpl<'a, T> { - SeriesTmpl { - field, - series, - path: self.item_path(), - } - } -} - -async fn set_static_cache_control(request: Request, next: Next) -> Response { - let mut response = next.run(request).await; - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("must-revalidate"), - ); - response -} - -/// ```askama -/// {% if values.len() > 5 %} -/// [
-/// {% for v in values %} -///   {{ v | json }},
-/// {% endfor %} -/// ] -/// {% else %} -/// [ {% for v in values %}{{ v | json }}{% if !loop.last %}, {% endif %}{% endfor %} ] -/// {% endif %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -struct YamlItems<'a, V: Serialize> { - values: &'a [V], - class: &'static str, -} - -impl<'a, V: Serialize> HtmlSafe for YamlItems<'a, V> {} - -fn yaml_items<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { - YamlItems { - values, - class: "string", - } -} - -fn yaml_nums<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { - YamlItems { - values, - class: "num", - } -} - -fn date(date: &Date) -> String { - date.format(&DATE_FORMAT).unwrap_or_default() -} - -pub static TIME_FORMAT: Lazy = Lazy::new(|| { - format_description::parse_owned::<2>("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap() -}); - -fn time(time: &Timestamp) -> String { - time.0 - .to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)) - .replace_nanosecond(0) - .unwrap() - .format(&TIME_FORMAT) - .unwrap_or_default() -} - -/// ```askama -/// {% for s in series %} -/// {{ item(*field, s.name) | safe }}{% if !s.entries.0.is_empty() %} #{{ s.entries }}{% endif %}{% if !loop.last %}, {% endif %} -/// {% endfor %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct SeriesTmpl<'a, T: Key> { - field: T, - series: &'a Vec, - path: &'a str, -} - -impl<'a, T: Key> SeriesTmpl<'a, T> { - fn item(&'a self, field: T, label: &'a str) -> ItemFilter<'a, T> { - ItemFilter { - field, - label, - value: None, - path: self.path, - } - } -} - -/// ```askama -/// {% for c in cats %} -/// {{ item(*field, **c) | safe }}{% if !loop.last %}, {% endif %} -/// {% endfor %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct CatsTmpl<'a, T: Key> { - field: T, - cats: &'a Vec, - path: &'a str, -} - -impl<'a, T: Key> HtmlSafe for CatsTmpl<'a, T> {} - -impl<'a, T: Key> CatsTmpl<'a, T> { - fn item(&'a self, field: T, cat: Category) -> ItemFilter<'a, T> { - ItemFilter { - field, - label: cat.as_str(), - value: Some(cat.as_id().to_string()), - path: self.path, - } - } -} - -impl<'a, T: Key> HtmlSafe for SeriesTmpl<'a, T> {} - -#[derive(Template)] -#[template(path = "partials/filter.html")] -struct FilterTemplate<'a> { - filter: &'a TorrentFilter, -} -impl<'a> HtmlSafe for FilterTemplate<'a> {} - -fn filter<'a>(filter: &'a TorrentFilter) -> FilterTemplate<'a> { - FilterTemplate { filter } -} - -#[derive(Template)] -#[template(path = "partials/flag_icons.html")] -pub struct FlagIconsTemplate { - flags: Flags, -} -impl HtmlSafe for FlagIconsTemplate {} - -pub fn flag_icons(meta: &TorrentMeta) -> FlagIconsTemplate { - FlagIconsTemplate { - flags: Flags::from_bitfield(meta.flags.map_or(0, |f| f.0)), - } -} - -#[derive(Template)] -#[template(path = "partials/cost_icon.html")] -pub struct CostIconTemplate<'a> { - mam_torrent: &'a MaMTorrent, -} -impl<'a> HtmlSafe for CostIconTemplate<'a> {} - -pub fn cost_icon(mam_torrent: &MaMTorrent) -> CostIconTemplate<'_> { - CostIconTemplate { mam_torrent } -} -pub fn vip_expire(mam_torrent: &MaMTorrent) -> Date { - UtcDateTime::from_unix_timestamp(mam_torrent.vip_expire as i64) - .unwrap_or(UtcDateTime::UNIX_EPOCH) - .date() -} - -#[derive(Template)] -#[template(path = "partials/mam_torrents.html")] -struct MaMTorrentsTemplate { - config: SearchConfig, - torrents: Vec<( - MaMTorrent, - TorrentMeta, - Option, - Option, - )>, -} -impl HtmlSafe for MaMTorrentsTemplate {} - -/// ```askama -/// {% match template %} -/// {% when Some(template) %}{{ template | safe }} -/// {% when None %}{% endmatch %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -struct Conditional { - template: Option, -} - -impl HtmlSafe for Conditional {} - -/// ```askama -/// {{ title }} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -struct TorrentLink<'a> { - id: &'a str, - title: &'a str, -} - -#[derive(Debug, thiserror::Error)] -enum AppError { - #[error("Could not query db: {0}")] - Db(#[from] native_db::db_type::Error), - #[error("Could not render template: {0}")] - Render(#[from] askama::Error), - #[error("Qbit Error: {0:?}")] - QbitError(#[from] qbit::Error), - #[error("Send Error: {0:?}")] - SendError(#[from] SendError<()>), - #[error("Send Error: {0:?}")] - SendError2(#[from] SendError), - #[error("Meta Error: {0:?}")] - MetaError(#[from] MetaError), - #[error("Toml Parse Error: {0:?}")] - Toml(#[from] toml::de::Error), - #[error("Error: {0:?}")] - Generic(#[from] anyhow::Error), - #[error("Page Not Found")] - NotFound, -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - #[derive(Debug, Template)] - #[template(source = "

{{error}}

", ext = "html")] - struct Tmpl { - #[allow(dead_code)] - error: AppError, - } - match self { - AppError::Db(ref error) => eprintln!("{:?}", error), - AppError::Render(ref error) => eprintln!("{:?}", error), - _ => {} - } - - let status = StatusCode::INTERNAL_SERVER_ERROR; - let tmpl = Tmpl { error: self }; - if let Ok(body) = tmpl.render() { - (status, Html(body)).into_response() - } else { - (status, "Something went wrong").into_response() - } - } -} diff --git a/server/src/web/pages/config.rs b/server/src/web/pages/config.rs deleted file mode 100644 index eb34e51e..00000000 --- a/server/src/web/pages/config.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::{ops::Deref, sync::Arc}; - -use anyhow::Result; -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{AudiobookCategory, DatabaseExt as _, EbookCategory, Torrent}; -use mlm_mam::serde::DATE_FORMAT; -use reqwest::Url; -use serde::Deserialize; -use tracing::{info, warn}; - -use crate::{ - autograbber::update_torrent_meta, - config::{Config, Cost, Library, TorrentSearch, Type}, - qbittorrent::ensure_category_exists, - stats::Context, - web::{AppError, Page, filter, yaml_items, yaml_nums}, -}; - -pub async fn config_page( - State(config): State>, - Query(query): Query, -) -> std::result::Result, AppError> { - let template = ConfigPageTemplate { - config, - show_apply_tags: query.show_apply_tags.unwrap_or_default(), - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn config_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let config = context.config().await; - match form.action.as_str() { - "apply" => { - let tag_filter = form - .tag_filter - .ok_or(anyhow::Error::msg("apply requires tag_filter"))?; - let tag_filter = config - .tags - .get(tag_filter) - .ok_or(anyhow::Error::msg("invalid tag_filter"))?; - let qbit_conf = config - .qbittorrent - .get(form.qbit_index.unwrap_or_default()) - .ok_or(anyhow::Error::msg("requires a qbit config"))?; - let qbit = qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await?; - let torrents = context.db.r_transaction()?.scan().primary::()?; - for torrent in torrents.all()? { - let torrent = torrent?; - match tag_filter.filter.matches_lib(&torrent) { - Ok(matches) => { - if !matches { - continue; - } - } - Err(err) => { - info!("need to ask mam due to: {err}"); - let mam = context.mam()?; - let Some(mam_torrent) = mam.get_torrent_info_by_id(torrent.mam_id).await? - else { - warn!("could not get torrent from mam"); - continue; - }; - let new_meta = mam_torrent.as_meta()?; - if new_meta != torrent.meta { - update_torrent_meta( - &config, - &context.db, - context.db.rw_async().await?, - &mam_torrent, - torrent.clone(), - new_meta, - false, - false, - ) - .await?; - } - if !tag_filter.filter.matches(&mam_torrent) { - continue; - } - } - }; - if let Some(category) = &tag_filter.category { - ensure_category_exists(&qbit, &qbit_conf.url, category).await?; - qbit.set_category(Some(vec![torrent.id.as_str()]), category) - .await?; - info!( - "set category {} on torrent {}", - category, torrent.meta.title - ); - } - - if !tag_filter.tags.is_empty() { - qbit.add_tags( - Some(vec![torrent.id.as_str()]), - tag_filter.tags.iter().map(Deref::deref).collect(), - ) - .await?; - info!( - "set tags {:?} on torrent {}", - tag_filter.tags, torrent.meta.title - ); - } - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct ConfigPageQuery { - show_apply_tags: Option, -} - -#[derive(Debug, Deserialize)] -pub struct ConfigPageForm { - action: String, - qbit_index: Option, - tag_filter: Option, -} - -#[derive(Template)] -#[template(path = "pages/config.html")] -struct ConfigPageTemplate { - config: Arc, - show_apply_tags: bool, -} - -impl Page for ConfigPageTemplate {} - -impl TorrentSearch { - fn mam_search(&self) -> String { - let mut url: Url = "https://www.myanonamouse.net/tor/browse.php?thumbnail=true" - .parse() - .unwrap(); - - { - let mut query = url.query_pairs_mut(); - if let Some(text) = &self.query { - query.append_pair("tor[text]", text); - } - - for srch_in in &self.search_in { - query.append_pair(&format!("tor[srchIn][{}]", srch_in.as_str()), "true"); - } - let search_in = match self.kind { - Type::Bookmarks => "bookmarks", - _ => "torrents", - }; - query.append_pair("tor[searchIn]", search_in); - let search_type = match (self.kind, self.cost) { - (Type::Freeleech, _) => "fl", - (_, Cost::Free) => "fl-VIP", - _ => "all", - }; - query.append_pair("tor[searchType]", search_type); - let sort_type = match self.kind { - Type::New => "dateDesc", - _ => "", - }; - if !sort_type.is_empty() { - query.append_pair("tor[sort_type]", search_type); - } - for cat in self - .filter - .categories - .audio - .clone() - .unwrap_or_else(AudiobookCategory::all) - .into_iter() - .map(AudiobookCategory::to_id) - { - query.append_pair("tor[cat][]", &cat.to_string()); - } - for cat in self - .filter - .categories - .ebook - .clone() - .unwrap_or_else(EbookCategory::all) - .into_iter() - .map(EbookCategory::to_id) - { - query.append_pair("tor[cat][]", &cat.to_string()); - } - for lang in &self.filter.languages { - query.append_pair("tor[browse_lang][]", &lang.to_id().to_string()); - } - - let (flags_is_hide, flags) = self.filter.flags.as_search_bitfield(); - if !flags.is_empty() { - query.append_pair( - "tor[browseFlagsHideVsShow]", - if flags_is_hide { "0" } else { "1" }, - ); - } - for flag in flags { - query.append_pair("tor[browseFlags][]", &flag.to_string()); - } - - if self.filter.min_size.bytes() > 0 || self.filter.max_size.bytes() > 0 { - query.append_pair("tor[unit]", "1"); - } - if self.filter.min_size.bytes() > 0 { - query.append_pair("tor[minSize]", &self.filter.min_size.bytes().to_string()); - } - if self.filter.max_size.bytes() > 0 { - query.append_pair("tor[maxSize]", &self.filter.max_size.bytes().to_string()); - } - - if let Some(uploaded_after) = self.filter.uploaded_after { - query.append_pair( - "tor[startDate]", - &uploaded_after.format(&DATE_FORMAT).unwrap(), - ); - } - if let Some(uploaded_before) = self.filter.uploaded_before { - query.append_pair( - "tor[endDate]", - &uploaded_before.format(&DATE_FORMAT).unwrap(), - ); - } - if let Some(min_seeders) = self.filter.min_seeders { - query.append_pair("tor[minSeeders]", &min_seeders.to_string()); - } - if let Some(max_seeders) = self.filter.max_seeders { - query.append_pair("tor[maxSeeders]", &max_seeders.to_string()); - } - if let Some(min_leechers) = self.filter.min_leechers { - query.append_pair("tor[minLeechers]", &min_leechers.to_string()); - } - if let Some(max_leechers) = self.filter.max_leechers { - query.append_pair("tor[maxLeechers]", &max_leechers.to_string()); - } - if let Some(min_snatched) = self.filter.min_snatched { - query.append_pair("tor[minSnatched]", &min_snatched.to_string()); - } - if let Some(max_snatched) = self.filter.max_snatched { - query.append_pair("tor[maxSnatched]", &max_snatched.to_string()); - } - } - - url.to_string() - } -} diff --git a/server/src/web/pages/duplicate.rs b/server/src/web/pages/duplicate.rs deleted file mode 100644 index a8dc298e..00000000 --- a/server/src/web/pages/duplicate.rs +++ /dev/null @@ -1,255 +0,0 @@ -use anyhow::{Error, Result}; -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{ - DatabaseExt as _, DuplicateTorrent, SelectedTorrent, Timestamp, Torrent, TorrentCost, -}; -use mlm_parse::normalize_title; -use serde::{Deserialize, Serialize}; -use tracing::info; - -use crate::{ - cleaner::clean_torrent, - stats::Context, - web::{ - AppError, Page, - tables::{Key, SortOn, Sortable, table_styles_rows}, - time, - }, -}; - -pub async fn duplicate_page( - State(context): State, - Query(sort): Query>, - Query(filter): Query>, -) -> std::result::Result, AppError> { - let config = context.config().await; - let mut duplicate_torrents = context - .db - .r_transaction()? - .scan() - .primary::()? - .all()? - .filter(|t| { - let Ok(t) = t else { - return true; - }; - for (field, value) in filter.iter() { - let ok = match field { - DuplicatePageFilter::Kind => t.meta.media_type.as_str() == value, - DuplicatePageFilter::Title => &t.meta.title == value, - DuplicatePageFilter::Author => t.meta.authors.contains(value), - DuplicatePageFilter::Narrator => t.meta.narrators.contains(value), - DuplicatePageFilter::Series => t.meta.series.iter().any(|s| &s.name == value), - DuplicatePageFilter::Filetype => t.meta.filetypes.contains(value), - DuplicatePageFilter::SortBy => true, - DuplicatePageFilter::Asc => true, - }; - if !ok { - return false; - } - } - true - }) - .collect::, native_db::db_type::Error>>()?; - if let Some(sort_by) = &sort.sort_by { - duplicate_torrents.sort_by(|a, b| { - let ord = match sort_by { - DuplicatePageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), - DuplicatePageSort::Title => a.meta.title.cmp(&b.meta.title), - DuplicatePageSort::Authors => a.meta.authors.cmp(&b.meta.authors), - DuplicatePageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), - DuplicatePageSort::Series => a.meta.series.cmp(&b.meta.series), - DuplicatePageSort::Size => a.meta.size.cmp(&b.meta.size), - DuplicatePageSort::CreatedAt => a.created_at.cmp(&b.created_at), - }; - if sort.asc { ord.reverse() } else { ord } - }); - } - let mut torrents = vec![]; - for torrent in duplicate_torrents { - let Some(with) = &torrent.duplicate_of else { - continue; - }; - let Some(duplicate) = context.db.r_transaction()?.get().primary(with.clone())? else { - continue; - }; - torrents.push((torrent, duplicate)); - } - let template = DuplicatePageTemplate { - abs_url: config.audiobookshelf.as_ref().map(|abs| abs.url.clone()), - sort, - torrents, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn duplicate_torrents_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let config = context.config().await; - match form.action.as_str() { - "replace" => { - let mam = context.mam()?; - for torrent in form.torrents { - let r = context.db.r_transaction()?; - let Some(duplicate_torrent) = r.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - let Some(hash) = duplicate_torrent.duplicate_of.clone() else { - return Err(anyhow::Error::msg("No duplicate_of set").into()); - }; - let Some(duplicate_of) = r.get().primary::(hash)? else { - return Err(anyhow::Error::msg("Could not find original torrent").into()); - }; - - let Some(mam_torrent) = mam - .get_torrent_info_by_id(duplicate_torrent.mam_id) - .await - .ok() - .flatten() - else { - return Err( - anyhow::Error::msg("Could not find duplicate torrent on MaM").into(), - ); - }; - - let meta = mam_torrent.as_meta()?; - let title_search = normalize_title(&meta.title); - let tags: Vec<_> = config - .tags - .iter() - .filter(|t| t.filter.matches(&mam_torrent)) - .collect(); - let category = tags.iter().find_map(|t| t.category.clone()); - let tags = tags.iter().flat_map(|t| t.tags.clone()).collect(); - let cost = if mam_torrent.vip { - TorrentCost::Vip - } else if mam_torrent.personal_freeleech { - TorrentCost::PersonalFreeleech - } else if mam_torrent.free { - TorrentCost::GlobalFreeleech - // TODO: Allow select - // } else if cost == Cost::Wedge { - // TorrentCost::UseWedge - // } else if cost == Cost::TryWedge { - // TorrentCost::TryWedge - // } else { - // TorrentCost::Ratio - } else { - TorrentCost::TryWedge - }; - info!( - "Selecting torrent \"{}\" in format {}, cost: {:?}, with category {:?} and tags {:?}", - mam_torrent.title, mam_torrent.filetype, cost, category, tags - ); - - { - let (_guard, rw) = context.db.rw_async().await?; - rw.insert(SelectedTorrent { - mam_id: mam_torrent.id, - goodreads_id: None, - hash: None, - dl_link: mam_torrent - .dl - .clone() - .or_else(|| duplicate_torrent.dl_link.clone()) - .ok_or_else(|| { - Error::msg(format!("no dl field for torrent {}", mam_torrent.id)) - })?, - unsat_buffer: None, - wedge_buffer: None, - cost, - category, - tags, - title_search, - meta, - grabber: None, - created_at: Timestamp::now(), - started_at: None, - removed_at: None, - })?; - rw.remove(duplicate_torrent)?; - rw.commit()?; - } - clean_torrent(&config, &context.db, duplicate_of, false).await?; - } - } - "remove" => { - for torrent in form.torrents { - let (_guard, rw) = context.db.rw_async().await?; - let Some(torrent) = rw.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - rw.remove(torrent)?; - rw.commit()?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentsPageForm { - action: String, - #[serde(default, rename = "torrent")] - torrents: Vec, -} - -#[derive(Template)] -#[template(path = "pages/duplicate.html")] -struct DuplicatePageTemplate { - abs_url: Option, - sort: SortOn, - torrents: Vec<(DuplicateTorrent, Torrent)>, -} - -impl Page for DuplicatePageTemplate {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum DuplicatePageSort { - Kind, - Title, - Authors, - Narrators, - Series, - Size, - CreatedAt, -} - -impl Key for DuplicatePageSort {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DuplicatePageFilter { - Kind, - Title, - Author, - Narrator, - Series, - Filetype, - // Workaround sort decode failure - SortBy, - Asc, -} - -impl Key for DuplicatePageFilter {} - -impl Sortable for DuplicatePageTemplate { - type SortKey = DuplicatePageSort; - - fn get_current_sort(&self) -> SortOn { - self.sort - } -} diff --git a/server/src/web/pages/errors.rs b/server/src/web/pages/errors.rs deleted file mode 100644 index 3bd360a9..00000000 --- a/server/src/web/pages/errors.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::{ - cell::{Ref, RefCell}, - sync::Arc, -}; - -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{DatabaseExt as _, ErroredTorrent, ErroredTorrentId, ErroredTorrentKey}; -use native_db::Database; -use serde::{Deserialize, Serialize}; - -use crate::web::{ - AppError, Page, - tables::{self, Flex, HidableColumns, Key, SortOn, Sortable}, - time, -}; - -pub async fn errors_page( - State(db): State>>, - Query(sort): Query>, - Query(filter): Query>, -) -> std::result::Result, AppError> { - let mut errored_torrents = db - .r_transaction()? - .scan() - .secondary::(ErroredTorrentKey::created_at)? - .all()? - .rev() - .filter(|t| { - let Ok(t) = t else { - return true; - }; - for (field, value) in filter.iter() { - let ok = match field { - ErrorsPageFilter::Step => step(&t.id) == value, - ErrorsPageFilter::Title => &t.title == value, - ErrorsPageFilter::SortBy => true, - ErrorsPageFilter::Asc => true, - }; - if !ok { - return false; - } - } - true - }) - .collect::, native_db::db_type::Error>>()?; - if let Some(sort_by) = &sort.sort_by { - errored_torrents.sort_by(|a, b| { - let ord = match sort_by { - ErrorsPageSort::Step => step(&a.id).cmp(step(&b.id)), - ErrorsPageSort::Title => a.title.cmp(&b.title), - ErrorsPageSort::Error => a.error.cmp(&b.error), - ErrorsPageSort::CreatedAt => a.created_at.cmp(&b.created_at), - }; - if sort.asc { ord.reverse() } else { ord } - }); - } - let template = ErrorsPageTemplate { - sort, - cols: Default::default(), - errors: errored_torrents, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn errors_page_post( - State(db): State>>, - uri: OriginalUri, - Form(form): Form, -) -> Result { - match form.action.as_str() { - "remove" => { - for error in form.errors { - let Ok(error) = serde_json::from_str::(&error) else { - return Err(anyhow::Error::msg("Could not parse error").into()); - }; - let (_guard, rw) = db.rw_async().await?; - let Some(error) = rw.get().primary::(error)? else { - return Err(anyhow::Error::msg("Could not find error").into()); - }; - rw.remove(error)?; - rw.commit()?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct ErrorPageForm { - action: String, - #[serde(default, rename = "error")] - errors: Vec, -} - -#[derive(Template)] -#[template(path = "pages/errors.html")] -struct ErrorsPageTemplate { - sort: SortOn, - cols: RefCell>>, - errors: Vec, -} - -impl Page for ErrorsPageTemplate {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ErrorsPageSort { - Step, - Title, - Error, - CreatedAt, -} - -impl Key for ErrorsPageSort {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ErrorsPageFilter { - Step, - Title, - // Workaround sort decode failure - SortBy, - Asc, -} - -impl Key for ErrorsPageFilter {} - -impl Sortable for ErrorsPageTemplate { - type SortKey = ErrorsPageSort; - - fn get_current_sort(&self) -> SortOn { - self.sort - } -} -impl HidableColumns for ErrorsPageTemplate { - fn add_column(&self, size: Box) { - self.cols.borrow_mut().push(size); - } - - fn get_columns(&self) -> Ref<'_, Vec>> { - self.cols.borrow() - } -} - -pub fn step(id: &ErroredTorrentId) -> &str { - match id { - ErroredTorrentId::Grabber(_) => "auto grabber", - ErroredTorrentId::Linker(_) => "library linker", - ErroredTorrentId::Cleaner(_) => "library cleaner", - } -} diff --git a/server/src/web/pages/events.rs b/server/src/web/pages/events.rs deleted file mode 100644 index 8a401931..00000000 --- a/server/src/web/pages/events.rs +++ /dev/null @@ -1,209 +0,0 @@ -use std::sync::Arc; - -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, IntoResponse, Response}, -}; -use mlm_db::{Event, EventKey, EventType, Torrent, TorrentCost, TorrentKey}; -use native_db::Database; -use serde::{Deserialize, Serialize}; - -use crate::web::{ - AppError, Conditional, Page, TorrentLink, - tables::{Key, Pagination, PaginationParams, table_styles}, - time, -}; - -pub async fn event_page( - State(db): State>>, - uri: OriginalUri, - Query(filter): Query>, - Query(paging): Query, -) -> std::result::Result { - let r = db.r_transaction()?; - let events = r.scan().secondary::(EventKey::created_at)?; - let event_count = r.len().secondary::(EventKey::created_at)?; - let events = events.all()?.rev(); - let events = events.filter(|t| { - let Ok(t) = t else { - return true; - }; - for (field, value) in filter.iter() { - let ok = match field { - EventPageFilter::Show => match t.event { - EventType::Grabbed { .. } => value == "grabber", - EventType::Linked { .. } => value == "linker", - EventType::Cleaned { .. } => value == "cleaner", - EventType::Updated { .. } => value == "updated", - EventType::RemovedFromMam { .. } => value == "removed", - }, - EventPageFilter::Grabber => match t.event { - EventType::Grabbed { ref grabber, .. } => { - if value.is_empty() { - grabber.is_none() - } else { - grabber.as_ref() == Some(value) - } - } - _ => false, - }, - EventPageFilter::Linker => match t.event { - EventType::Linked { ref linker, .. } => { - if value.is_empty() { - linker.is_none() - } else { - linker.as_ref() == Some(value) - } - } - _ => true, - }, - EventPageFilter::Category => true, - EventPageFilter::HasUpdates => match t.event { - EventType::Updated { ref fields, .. } => { - fields.iter().any(|f| !f.from.is_empty()) - } - _ => false, - }, - EventPageFilter::Field => match t.event { - EventType::Updated { ref fields, .. } => { - fields.iter().any(|f| &f.field.to_string() == value) - } - _ => false, - }, - EventPageFilter::From => true, - EventPageFilter::PageSize => true, - }; - if !ok { - return false; - } - } - true - }); - let linker_filter = filter.iter().find(|f| f.0 == EventPageFilter::Linker); - let category_filter = filter.iter().find(|f| f.0 == EventPageFilter::Category); - let r = db.r_transaction()?; - let events = events - .map(|event| { - let event = event?; - let torrent: Option = if let Some(id) = &event.torrent_id { - r.get().primary(id.clone())? - } else if let Some(mam_id) = &event.mam_id { - r.get().secondary(TorrentKey::mam_id, *mam_id)? - } else { - None - }; - - if let Some(torrent) = torrent { - if let Some((_, linker)) = linker_filter - && torrent.linker.as_ref() != Some(linker) - { - return Ok(None); - } - if let Some((_, category)) = category_filter { - let matches = if category.is_empty() { - torrent.category.is_none() - } else { - torrent.category.as_ref() == Some(category) - }; - if !matches { - return Ok(None); - } - } - - let replaced_with = torrent - .replaced_with - .clone() - .and_then(|(id, _)| r.get().primary(id).ok()?); - Ok(Some((event, Some(torrent), replaced_with))) - } else { - if linker_filter.is_some() || category_filter.is_some() { - return Ok(None); - } - Ok(Some((event, None, None))) - } - }) - .filter_map(|e| match e { - Ok(Some((event, torrent, replaced_with))) => Some(Ok((event, torrent, replaced_with))), - Err(err) => Some(Err(err)), - _ => None, - }); - let mut paging = match paging.default_page_size(uri, 500, event_count as usize) { - Ok(paging) => paging, - Err(redirect) => return Ok(redirect.into_response()), - }; - let events: Result, native_db::db_type::Error> = - if let Some(paging) = &mut paging { - let mut count = 0; - let events = events - .inspect(|_| { - count += 1; - }) - .skip(paging.from) - .take(paging.page_size) - .collect(); - if count < paging.page_size + paging.from { - paging.total = count; - } - events - } else { - events.collect() - }; - let template = EventPageTemplate { - paging: paging.unwrap_or_default(), - show: filter.iter().find_map(|f| { - if f.0 == EventPageFilter::Show { - Some(f.1.as_str()) - } else { - None - } - }), - events: events?, - }; - Ok::<_, AppError>(Html(template.to_string()).into_response()) -} - -type EventWithTorrent = (Event, Option, Option); -#[derive(Template)] -#[template(path = "pages/events.html")] -struct EventPageTemplate<'a> { - paging: Pagination, - show: Option<&'a str>, - events: Vec, -} - -impl<'a> Page for EventPageTemplate<'a> {} - -impl<'a> EventPageTemplate<'a> { - fn torrent_title(&'a self, torrent: &'a Option) -> Conditional> { - Conditional { - template: torrent.as_ref().map(|t| TorrentLink { - id: &t.id, - title: &t.meta.title, - }), - } - } - - fn torrent_media_type(&'a self, torrent: &'a Option) -> &'a str { - torrent - .as_ref() - .map(|t| t.meta.media_type.as_str()) - .unwrap_or_default() - } -} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EventPageFilter { - Show, - Grabber, - Linker, - Category, - HasUpdates, - Field, - // Workaround sort decode failure - From, - PageSize, -} - -impl Key for EventPageFilter {} diff --git a/server/src/web/pages/index.rs b/server/src/web/pages/index.rs deleted file mode 100644 index d8ed1da9..00000000 --- a/server/src/web/pages/index.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::{collections::BTreeMap, convert::Infallible, sync::Arc, time::Duration}; - -use anyhow::Result; -use askama::Template; -use axum::{ - extract::{OriginalUri, State}, - response::{ - Html, Redirect, Sse, - sse::{Event, KeepAlive}, - }, -}; -use axum_extra::extract::Form; -use futures::Stream; -use mlm_db::Timestamp; -use serde::Deserialize; -use tokio_stream::{StreamExt as _, wrappers::WatchStream}; - -use crate::{ - config::{Config, TorrentFilter}, - lists::{List, get_lists}, - stats::Context, - web::{AppError, Page, time}, -}; - -pub async fn index_page( - State(context): State, -) -> std::result::Result, AppError> { - let stats = context.stats.values.lock().await; - let username = match context.mam.as_ref() { - Ok(mam) => mam.cached_user_info().await.map(|u| u.username), - Err(_) => None, - }; - let config = context.config.lock().await; - let template = IndexPageTemplate { - config: config.clone(), - lists: get_lists(&config), - mam_error: context.mam.as_ref().as_ref().err().map(|e| format!("{e}")), - has_no_qbits: config.qbittorrent.is_empty(), - username, - autograbber_run_at: stats - .autograbber_run_at - .iter() - .map(|(i, time)| (*i, Timestamp::from(*time))) - .collect(), - autograbber_result: stats - .autograbber_result - .iter() - .map(|(i, r)| (*i, r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}")))) - .collect(), - import_run_at: stats - .import_run_at - .iter() - .map(|(i, time)| (*i, Timestamp::from(*time))) - .collect(), - import_result: stats - .import_result - .iter() - .map(|(i, r)| (*i, r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}")))) - .collect(), - linker_run_at: stats.linker_run_at.map(Into::into), - linker_result: stats - .linker_result - .as_ref() - .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), - cleaner_run_at: stats.cleaner_run_at.map(Into::into), - cleaner_result: stats - .cleaner_result - .as_ref() - .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), - downloader_run_at: stats.downloader_run_at.map(Into::into), - downloader_result: stats - .downloader_result - .as_ref() - .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), - audiobookshelf_run_at: stats.audiobookshelf_run_at.map(Into::into), - audiobookshelf_result: stats - .audiobookshelf_result - .as_ref() - .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn stats_updates( - State(context): State, -) -> Sse>> { - let stream = WatchStream::new(context.stats.updates()) - .map(|time| Ok(Event::default().data(time.to_string()))); - Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) -} - -pub async fn index_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - match form.action.as_str() { - "run_linker" => { - context.triggers.linker_tx.send(())?; - } - "run_search" => { - if let Some(tx) = context.triggers.search_tx.get( - &form - .index - .ok_or_else(|| anyhow::Error::msg("Invalid index"))?, - ) { - tx.send(())?; - } else { - return Err(anyhow::Error::msg("Invalid index").into()); - } - } - "run_import" => { - if let Some(tx) = context.triggers.import_tx.get( - &form - .index - .ok_or_else(|| anyhow::Error::msg("Invalid index"))?, - ) { - tx.send(())?; - } else { - return Err(anyhow::Error::msg("Invalid index").into()); - } - } - "run_downloader" => { - context.triggers.downloader_tx.send(())?; - } - "run_abs_matcher" => { - context.triggers.audiobookshelf_tx.send(())?; - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Template)] -#[template(path = "pages/index.html")] -struct IndexPageTemplate { - config: Arc, - lists: Vec, - mam_error: Option, - has_no_qbits: bool, - username: Option, - autograbber_run_at: BTreeMap, - autograbber_result: BTreeMap>, - import_run_at: BTreeMap, - import_result: BTreeMap>, - linker_run_at: Option, - linker_result: Option>, - cleaner_run_at: Option, - cleaner_result: Option>, - downloader_run_at: Option, - downloader_result: Option>, - audiobookshelf_run_at: Option, - audiobookshelf_result: Option>, -} - -impl Page for IndexPageTemplate {} - -#[derive(Debug, Deserialize)] -pub struct IndexPageForm { - action: String, - index: Option, -} - -impl TorrentFilter { - fn display_name(&self, i: usize) -> String { - self.name.clone().unwrap_or_else(|| format!("{i}")) - } -} diff --git a/server/src/web/pages/list.rs b/server/src/web/pages/list.rs deleted file mode 100644 index 92348246..00000000 --- a/server/src/web/pages/list.rs +++ /dev/null @@ -1,143 +0,0 @@ -use std::sync::Arc; - -use askama::Template; -use axum::{ - extract::{OriginalUri, Path, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{ - AudiobookCategory, DatabaseExt as _, EbookCategory, List, ListItem, ListItemKey, Timestamp, - TorrentStatus, -}; -use native_db::Database; -use reqwest::Url; -use serde::{Deserialize, Serialize}; - -use crate::web::{AppError, Page, time}; - -pub async fn list_page( - State(db): State>>, - Path(list_id): Path, - Query(filter): Query>, -) -> std::result::Result, AppError> { - let Some(list) = db.r_transaction()?.get().primary::(list_id)? else { - return Err(AppError::NotFound); - }; - let items = db - .r_transaction()? - .scan() - .secondary::(ListItemKey::created_at)?; - let items = items - .all()? - .rev() - .filter(|t| t.as_ref().is_ok_and(|t| t.list_id == list.id)) - .filter(|t| { - let Ok(t) = t else { - return true; - }; - for (field, value) in filter.iter() { - let ok = match field { - ListPageFilter::Show => match value.as_str() { - "any" => t.want_audio() || t.want_ebook(), - "audio" => t.want_audio(), - "ebook" => t.want_ebook(), - _ => true, - }, - }; - if !ok { - return false; - } - } - true - }) - .collect::, native_db::db_type::Error>>()?; - let template = ListPageTemplate { - show: filter.iter().find_map(|f| { - if f.0 == ListPageFilter::Show { - Some(f.1.as_str()) - } else { - None - } - }), - list, - items, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn list_page_post( - State(db): State>>, - Path(list_id): Path, - uri: OriginalUri, - Form(form): Form, -) -> Result { - match form.action.as_str() { - "mark-done" => { - let (_guard, rw) = db.rw_async().await?; - let Some(mut item) = rw.get().primary::((list_id, form.item_id))? else { - return Err(anyhow::Error::msg("Could not find item").into()); - }; - item.marked_done_at = Some(Timestamp::now()); - rw.upsert(item)?; - rw.commit()?; - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct ListPageForm { - action: String, - item_id: String, -} - -#[derive(Template)] -#[template(path = "pages/list.html")] -struct ListPageTemplate<'a> { - show: Option<&'a str>, - list: List, - items: Vec, -} - -impl<'a> Page for ListPageTemplate<'a> {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ListPageFilter { - Show, -} - -fn mam_search(item: &ListItem) -> String { - let mut url: Url = "https://www.myanonamouse.net/tor/browse.php?thumbnail=true&tor[srchIn][title]=true&tor[srchIn][author]=true&tor[searchType]=all&tor[searchIn]=torrents" - .parse() - .unwrap(); - - { - let mut query = url.query_pairs_mut(); - query.append_pair( - "tor[text]", - &format!("{} {}", item.title, item.authors.join(" ")), - ); - - if item.allow_audio { - for cat in AudiobookCategory::all() - .into_iter() - .map(AudiobookCategory::to_id) - { - query.append_pair("tor[cat][]", &cat.to_string()); - } - } - if item.allow_ebook { - for cat in EbookCategory::all().into_iter().map(EbookCategory::to_id) { - query.append_pair("tor[cat][]", &cat.to_string()); - } - } - } - - url.to_string() -} diff --git a/server/src/web/pages/lists.rs b/server/src/web/pages/lists.rs deleted file mode 100644 index a480dff5..00000000 --- a/server/src/web/pages/lists.rs +++ /dev/null @@ -1,58 +0,0 @@ -use askama::Template; -use axum::{extract::State, response::Html}; -use itertools::Itertools as _; -use mlm_db::{List, ListKey}; - -use crate::{ - config::GoodreadsList, - stats::Context, - web::{AppError, Page, time}, -}; - -pub async fn lists_page( - State(context): State, -) -> std::result::Result, AppError> { - let config = context.config().await; - let db_lists = context - .db - .r_transaction()? - .scan() - .secondary::(ListKey::title)?; - let mut db_lists = db_lists - .all()? - .collect::, native_db::db_type::Error>>()?; - let mut lists = vec![]; - - for list in config.goodreads_lists.iter() { - let id = list.list_id()?; - if let Some((index, _)) = db_lists.iter().find_position(|db_list| db_list.id == id) { - let db_list = db_lists.remove(index); - lists.push((list.clone(), db_list)); - } else { - lists.push(( - list.clone(), - List { - id: id.clone(), - title: id, - updated_at: None, - build_date: None, - }, - )); - } - } - - let template = ListsPageTemplate { - lists, - inactive_lists: db_lists, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -#[derive(Template)] -#[template(path = "pages/lists.html")] -struct ListsPageTemplate { - lists: Vec<(GoodreadsList, List)>, - inactive_lists: Vec, -} - -impl Page for ListsPageTemplate {} diff --git a/server/src/web/pages/mod.rs b/server/src/web/pages/mod.rs deleted file mode 100644 index 7bb7658b..00000000 --- a/server/src/web/pages/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub mod config; -pub mod duplicate; -pub mod errors; -pub mod events; -pub mod index; -pub mod list; -pub mod lists; -pub mod replaced; -pub mod search; -pub mod selected; -pub mod torrent; -pub mod torrent_edit; -pub mod torrents; diff --git a/server/src/web/pages/replaced.rs b/server/src/web/pages/replaced.rs deleted file mode 100644 index af21364e..00000000 --- a/server/src/web/pages/replaced.rs +++ /dev/null @@ -1,307 +0,0 @@ -use std::cell::Ref; -use std::cell::RefCell; -use std::str::FromStr; - -use anyhow::Result; -use askama::Template; -use axum::response::{IntoResponse, Response}; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{Language, Torrent, TorrentKey}; -use serde::{Deserialize, Serialize}; - -use crate::stats::Context; -use crate::web::{Page, tables}; -use crate::{ - linker::{refresh_metadata, refresh_metadata_relink}, - web::{ - AppError, - tables::{Flex, HidableColumns, Key, Pagination, PaginationParams, SortOn, Sortable}, - time, - }, -}; -use mlm_db::DatabaseExt as _; - -pub async fn replaced_torrents_page( - State(context): State, - uri: OriginalUri, - Query(sort): Query>, - Query(filter): Query>, - Query(show): Query, - Query(paging): Query, -) -> std::result::Result { - let config = context.config().await; - let torrents = context - .db - .r_transaction()? - .scan() - .secondary::(TorrentKey::created_at)?; - let mut replaced_torrents = torrents - .all()? - .rev() - .filter(|t| { - let Ok(t) = t else { - return true; - }; - if t.replaced_with.is_none() { - return false; - } - for (field, value) in filter.iter() { - let ok = match field { - TorrentsPageFilter::Kind => t.meta.media_type.as_str() == value, - TorrentsPageFilter::Title => &t.meta.title == value, - TorrentsPageFilter::Author => t.meta.authors.contains(value), - TorrentsPageFilter::Narrator => t.meta.narrators.contains(value), - TorrentsPageFilter::Series => t.meta.series.iter().any(|s| &s.name == value), - TorrentsPageFilter::Language => { - t.meta.language == Language::from_str(value).ok() - } - TorrentsPageFilter::Filetype => t.meta.filetypes.contains(value), - TorrentsPageFilter::Linked => t.library_path.is_some() == (value == "true"), - TorrentsPageFilter::SortBy => true, - TorrentsPageFilter::Asc => true, - TorrentsPageFilter::Show => true, - TorrentsPageFilter::From => true, - TorrentsPageFilter::PageSize => true, - }; - if !ok { - return false; - } - } - true - }) - .collect::, _>>()?; - - let paging = match paging.default_page_size(uri, 500, replaced_torrents.len()) { - Ok(paging) => paging, - Err(redirect) => return Ok(redirect.into_response()), - }; - - if let Some(sort_by) = &sort.sort_by { - replaced_torrents.sort_by(|a, b| { - let ord = match sort_by { - TorrentsPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), - TorrentsPageSort::Title => a.meta.title.cmp(&b.meta.title), - TorrentsPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), - TorrentsPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), - TorrentsPageSort::Series => a.meta.series.cmp(&b.meta.series), - TorrentsPageSort::Language => a.meta.language.cmp(&b.meta.language), - TorrentsPageSort::Size => a.meta.size.cmp(&b.meta.size), - TorrentsPageSort::Linked => a.library_path.cmp(&b.library_path), - TorrentsPageSort::Replaced => a - .replaced_with - .as_ref() - .map(|r| r.1) - .cmp(&b.replaced_with.as_ref().map(|r| r.1)), - TorrentsPageSort::CreatedAt => a.created_at.cmp(&b.created_at), - }; - if sort.asc { ord.reverse() } else { ord } - }); - } - if let Some(paging) = &paging { - replaced_torrents = replaced_torrents - .into_iter() - .skip(paging.from) - .take(paging.page_size) - .collect(); - } - - let mut torrents = vec![]; - for torrent in replaced_torrents { - let Some((with, _)) = &torrent.replaced_with else { - continue; - }; - let Some(replacement) = context.db.r_transaction()?.get().primary(with.clone())? else { - continue; - }; - torrents.push((torrent, replacement)); - } - - let template = ReplacedTorrentsPageTemplate { - abs_url: config.audiobookshelf.as_ref().map(|abs| abs.url.clone()), - paging: paging.unwrap_or_default(), - sort, - show: show.show.unwrap_or_default(), - cols: Default::default(), - torrents, - }; - Ok::<_, AppError>(Html(template.to_string()).into_response()) -} - -pub async fn replaced_torrents_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let config = context.config().await; - match form.action.as_str() { - "refresh" => { - let mam = context.mam()?; - for torrent in form.torrents { - refresh_metadata(&config, &context.db, &mam, torrent).await?; - } - } - "refresh-relink" => { - let mam = context.mam()?; - for torrent in form.torrents { - refresh_metadata_relink(&config, &context.db, &mam, torrent).await?; - } - } - "remove" => { - for torrent in form.torrents { - let (_guard, rw) = context.db.rw_async().await?; - let Some(torrent) = rw.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - rw.remove(torrent)?; - rw.commit()?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentsPageForm { - action: String, - #[serde(default, rename = "torrent")] - torrents: Vec, -} - -#[derive(Template)] -#[template(path = "pages/replaced.html")] -struct ReplacedTorrentsPageTemplate { - abs_url: Option, - paging: Pagination, - sort: SortOn, - show: TorrentsPageColumns, - cols: RefCell>>, - torrents: Vec<(Torrent, Torrent)>, -} - -impl Page for ReplacedTorrentsPageTemplate {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum TorrentsPageSort { - Kind, - Title, - Authors, - Narrators, - Series, - Language, - Size, - Linked, - Replaced, - CreatedAt, -} - -impl Key for TorrentsPageSort {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TorrentsPageFilter { - Kind, - Title, - Author, - Narrator, - Series, - Language, - Filetype, - Linked, - // Workaround sort decode failure - SortBy, - Asc, - Show, - From, - PageSize, -} - -impl Key for TorrentsPageFilter {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "String")] -struct TorrentsPageColumns { - authors: bool, - narrators: bool, - series: bool, - language: bool, - size: bool, - filetypes: bool, - path: bool, -} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct TorrentsPageColumnsQuery { - show: Option, -} - -impl Default for TorrentsPageColumns { - fn default() -> Self { - TorrentsPageColumns { - authors: true, - narrators: true, - series: true, - language: false, - size: true, - filetypes: true, - path: false, - } - } -} - -impl TryFrom for TorrentsPageColumns { - type Error = String; - - fn try_from(value: String) -> std::result::Result { - let mut columns = TorrentsPageColumns { - authors: false, - narrators: false, - series: false, - language: false, - size: false, - filetypes: false, - path: false, - }; - for column in value.split(",") { - match column { - "author" => columns.authors = true, - "narrator" => columns.narrators = true, - "series" => columns.series = true, - "language" => columns.language = true, - "size" => columns.size = true, - "filetype" => columns.filetypes = true, - "path" => columns.path = true, - "" => {} - _ => { - return Err(format!("Unknown column {column}")); - } - } - } - Ok(columns) - } -} - -impl Sortable for ReplacedTorrentsPageTemplate { - type SortKey = TorrentsPageSort; - - fn get_current_sort(&self) -> SortOn { - self.sort - } -} -impl HidableColumns for ReplacedTorrentsPageTemplate { - fn add_column(&self, size: Box) { - self.cols.borrow_mut().push(size); - } - - fn get_columns(&self) -> Ref<'_, Vec>> { - self.cols.borrow() - } -} diff --git a/server/src/web/pages/search.rs b/server/src/web/pages/search.rs deleted file mode 100644 index 98a46d6c..00000000 --- a/server/src/web/pages/search.rs +++ /dev/null @@ -1,175 +0,0 @@ -use anyhow::{Error, Result}; -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{DatabaseExt as _, SelectedTorrent, Timestamp, Torrent, TorrentCost, TorrentKey}; -use mlm_mam::{ - enums::SearchTarget, - search::{SearchFields, SearchQuery, Tor}, -}; -use mlm_parse::normalize_title; -use serde::Deserialize; -use tracing::info; - -use crate::{ - stats::Context, - web::{AppError, MaMTorrentsTemplate, Page}, -}; - -pub async fn search_page( - State(context): State, - Query(query): Query, -) -> std::result::Result, AppError> { - let mam = context.mam()?; - let result = mam - .search(&SearchQuery { - fields: SearchFields { - media_info: true, - ..Default::default() - }, - tor: Tor { - target: query.uploader.map(SearchTarget::Uploader), - text: query.q.clone(), - ..Default::default() - }, - ..Default::default() - }) - .await?; - - let r = context.db.r_transaction()?; - let mut torrents = result - .data - .into_iter() - .map(|mam_torrent| { - let meta = mam_torrent.as_meta()?; - let torrent = r - .get() - .secondary::(TorrentKey::mam_id, meta.mam_id)?; - let selected_torrent = r.get().primary(mam_torrent.id)?; - - Ok((mam_torrent, meta, torrent, selected_torrent)) - }) - .collect::>>()?; - - if query.sort == "series" { - torrents.sort_by(|(_, a, _, _), (_, b, _, _)| { - a.series - .cmp(&b.series) - .then(a.media_type.cmp(&b.media_type)) - }) - } - - let template = SearchPageTemplate { - query, - torrents: MaMTorrentsTemplate { - config: context.config().await.search.clone(), - torrents, - }, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn search_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - match form.action.as_str() { - "select" | "wedge" => { - select_torrent(&context, form.mam_id, form.action == "wedge").await?; - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct SearchPageForm { - action: String, - mam_id: u64, -} - -#[derive(Template)] -#[template(path = "pages/search.html")] -struct SearchPageTemplate { - query: SearchPageQuery, - torrents: MaMTorrentsTemplate, -} - -impl Page for SearchPageTemplate {} - -#[derive(Deserialize)] -pub struct SearchPageQuery { - #[serde(default)] - q: String, - #[serde(default)] - sort: String, - #[serde(default)] - uploader: Option, -} - -pub async fn select_torrent(context: &Context, mam_id: u64, wedge: bool) -> Result<(), AppError> { - let mam = context.mam()?; - let Some(torrent) = mam.get_torrent_info_by_id(mam_id).await? else { - return Err(AppError::NotFound); - }; - - let meta = torrent.as_meta()?; - let config = context.config().await; - - let tags: Vec<_> = config - .tags - .iter() - .filter(|t| t.filter.matches(&torrent)) - .collect(); - let category = tags.iter().find_map(|t| t.category.clone()); - let tags: Vec = tags.iter().flat_map(|t| t.tags.clone()).collect(); - let cost = if torrent.vip { - TorrentCost::Vip - } else if torrent.personal_freeleech { - TorrentCost::PersonalFreeleech - } else if torrent.free { - TorrentCost::GlobalFreeleech - } else if wedge { - TorrentCost::UseWedge - } else { - TorrentCost::Ratio - }; - info!( - "Selecting torrent \"{}\" in format {}, cost: {:?}, with category {:?} and tags {:?}", - torrent.title, torrent.filetype, cost, category, tags - ); - { - let (_guard, rw) = context.db.rw_async().await?; - rw.insert(SelectedTorrent { - mam_id: torrent.id, - goodreads_id: None, - hash: None, - dl_link: torrent - .dl - .clone() - .ok_or_else(|| Error::msg(format!("no dl field for torrent {}", torrent.id)))?, - unsat_buffer: None, - wedge_buffer: None, - cost, - category, - tags, - title_search: normalize_title(&meta.title), - meta, - grabber: None, - created_at: Timestamp::now(), - started_at: None, - removed_at: None, - })?; - rw.commit()?; - } - context.triggers.downloader_tx.send(())?; - - Ok(()) -} diff --git a/server/src/web/pages/selected.rs b/server/src/web/pages/selected.rs deleted file mode 100644 index 4e025e87..00000000 --- a/server/src/web/pages/selected.rs +++ /dev/null @@ -1,396 +0,0 @@ -use std::{ - cell::{Ref, RefCell}, - str::FromStr as _, - sync::Arc, -}; - -use anyhow::Result; -use askama::Template; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{DatabaseExt as _, Flags, Language, OldCategory, SelectedTorrent, Size, Timestamp}; -use mlm_mam::user_data::UserResponse; -use native_db::Database; -use serde::{Deserialize, Serialize}; -use tracing::info; - -use crate::{ - stats::Context, - web::{ - AppError, Page, flag_icons, - tables::{self, Flex, HidableColumns, Key, SortOn, Sortable}, - time, - }, -}; - -pub async fn selected_page( - State(context): State, - Query(sort): Query>, - Query(filter): Query>, - Query(show): Query, -) -> std::result::Result, AppError> { - let config = context.config().await; - let show = show.show.unwrap_or_default(); - - let mut torrents = context - .db - .r_transaction()? - .scan() - .primary::()? - .all()? - .filter(|t| show.removed_at || t.as_ref().is_ok_and(|t| t.removed_at.is_none())) - .filter(|t| { - let Ok(t) = t else { - return true; - }; - for (field, value) in filter.iter() { - let ok = match field { - SelectedPageFilter::Kind => t.meta.media_type.as_str() == value, - SelectedPageFilter::Category => { - if value.is_empty() { - t.meta.cat.is_none() - } else if let Some(cat) = &t.meta.cat { - let cats = value - .split(",") - .filter_map(|id| id.parse().ok()) - .filter_map(OldCategory::from_one_id) - .collect::>(); - cats.contains(cat) || cat.as_str() == value - } else { - false - } - } - SelectedPageFilter::Flags => { - if value.is_empty() { - t.meta.flags.is_none_or(|f| f.0 == 0) - } else if let Some(flags) = &t.meta.flags { - let flags = Flags::from_bitfield(flags.0); - match value.as_str() { - "violence" => flags.violence == Some(true), - "explicit" => flags.explicit == Some(true), - "some_explicit" => flags.some_explicit == Some(true), - "language" => flags.crude_language == Some(true), - "abridged" => flags.abridged == Some(true), - "lgbt" => flags.lgbt == Some(true), - _ => false, - } - } else { - false - } - } - SelectedPageFilter::Title => &t.meta.title == value, - SelectedPageFilter::Author => t.meta.authors.contains(value), - SelectedPageFilter::Narrator => t.meta.narrators.contains(value), - SelectedPageFilter::Series => t.meta.series.iter().any(|s| &s.name == value), - SelectedPageFilter::Language => { - if value.is_empty() { - t.meta.language.is_none() - } else { - t.meta.language == Language::from_str(value).ok() - } - } - SelectedPageFilter::Filetype => t.meta.filetypes.contains(value), - SelectedPageFilter::Cost => t.cost.as_str() == value, - SelectedPageFilter::Grabber => { - if value.is_empty() { - t.grabber.is_none() - } else { - t.grabber.as_ref() == Some(value) - } - } - SelectedPageFilter::SortBy => true, - SelectedPageFilter::Asc => true, - SelectedPageFilter::Show => true, - }; - if !ok { - return false; - } - } - true - }) - .collect::, native_db::db_type::Error>>()?; - if let Some(sort_by) = &sort.sort_by { - torrents.sort_by(|a, b| { - let ord = match sort_by { - SelectedPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), - SelectedPageSort::Title => a.meta.title.cmp(&b.meta.title), - SelectedPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), - SelectedPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), - SelectedPageSort::Series => a.meta.series.cmp(&b.meta.series), - SelectedPageSort::Language => a.meta.language.cmp(&b.meta.language), - SelectedPageSort::Size => a.meta.size.cmp(&b.meta.size), - SelectedPageSort::Cost => a.cost.cmp(&b.cost), - SelectedPageSort::Buffer => a - .unsat_buffer - .unwrap_or(config.unsat_buffer) - .cmp(&b.unsat_buffer.unwrap_or(config.unsat_buffer)), - SelectedPageSort::Grabber => a.grabber.cmp(&b.grabber), - SelectedPageSort::CreatedAt => a.created_at.cmp(&b.created_at), - SelectedPageSort::StartedAt => a.started_at.cmp(&b.started_at), - }; - if sort.asc { ord.reverse() } else { ord } - }); - } - let downloading_size: f64 = context - .db - .r_transaction()? - .scan() - .primary::()? - .all()? - .filter_map(|t| { - let t = t.ok()?; - if t.removed_at.is_none() && t.started_at.is_some() { - Some(t) - } else { - None - } - }) - .map(|t| t.meta.size.bytes() as f64) - .sum(); - let user_info = match context.mam.as_ref() { - Ok(mam) => mam.user_info().await.ok(), - _ => None, - }; - - let remaining_buffer = user_info.as_ref().map(|user_info| { - Size::from_bytes( - ((user_info.uploaded_bytes - user_info.downloaded_bytes - downloading_size) - / config.min_ratio) as u64, - ) - }); - let template = SelectedPageTemplate { - user_info, - remaining_buffer, - unsat_buffer: config.unsat_buffer, - sort, - show, - cols: Default::default(), - torrents, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn selected_torrents_page_post( - State(db): State>>, - uri: OriginalUri, - Form(form): Form, -) -> Result { - match form.action.as_str() { - "remove" => { - for torrent in form.torrents { - let (_guard, rw) = db.rw_async().await?; - let Some(mut torrent) = rw.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - if torrent.removed_at.is_none() { - torrent.removed_at = Some(Timestamp::now()); - rw.upsert(torrent)?; - } else { - info!("Hard-removing selected torrent {}", torrent.mam_id); - rw.remove(torrent)?; - } - rw.commit()?; - } - } - "update" => { - for torrent in form.torrents { - let (_guard, rw) = db.rw_async().await?; - let Some(mut torrent) = rw.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - torrent.unsat_buffer = Some(form.unsats.unwrap_or_default()); - torrent.removed_at = None; - rw.upsert(torrent)?; - rw.commit()?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentsPageForm { - action: String, - unsats: Option, - #[serde(default, rename = "torrent")] - torrents: Vec, -} - -#[derive(Template)] -#[template(path = "pages/selected.html")] -struct SelectedPageTemplate { - user_info: Option, - remaining_buffer: Option, - unsat_buffer: u64, - sort: SortOn, - show: TorrentsPageColumns, - cols: RefCell>>, - torrents: Vec, -} - -impl SelectedPageTemplate { - fn queued(&self) -> usize { - self.torrents - .iter() - .filter(|t| t.started_at.is_none()) - .count() - } - - fn downloading(&self) -> usize { - self.torrents - .iter() - .filter(|t| t.started_at.is_some()) - .count() - } -} - -impl Page for SelectedPageTemplate {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SelectedPageSort { - Kind, - Title, - Authors, - Narrators, - Series, - Language, - Size, - Cost, - Buffer, - Grabber, - CreatedAt, - StartedAt, -} - -impl Key for SelectedPageSort {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SelectedPageFilter { - Kind, - Category, - Flags, - Title, - Author, - Narrator, - Series, - Language, - Filetype, - Cost, - Grabber, - // Workaround sort decode failure - SortBy, - Asc, - Show, -} - -impl Key for SelectedPageFilter {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "String")] -struct TorrentsPageColumns { - category: bool, - flags: bool, - authors: bool, - narrators: bool, - series: bool, - language: bool, - size: bool, - filetypes: bool, - grabber: bool, - created_at: bool, - started_at: bool, - removed_at: bool, -} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct TorrentsPageColumnsQuery { - show: Option, -} - -impl Default for TorrentsPageColumns { - fn default() -> Self { - TorrentsPageColumns { - category: false, - flags: false, - authors: true, - narrators: false, - series: true, - language: false, - size: true, - filetypes: true, - grabber: true, - created_at: true, - started_at: true, - removed_at: false, - } - } -} - -impl TryFrom for TorrentsPageColumns { - type Error = String; - - fn try_from(value: String) -> std::result::Result { - let mut columns = TorrentsPageColumns { - category: false, - flags: false, - authors: false, - narrators: false, - series: false, - language: false, - size: false, - filetypes: false, - grabber: false, - created_at: false, - started_at: false, - removed_at: false, - }; - for column in value.split(",") { - match column { - "category" => columns.category = true, - "flags" => columns.flags = true, - "author" => columns.authors = true, - "narrator" => columns.narrators = true, - "series" => columns.series = true, - "language" => columns.language = true, - "size" => columns.size = true, - "filetype" => columns.filetypes = true, - "grabber" => columns.grabber = true, - "created_at" => columns.created_at = true, - "started_at" => columns.started_at = true, - "removed_at" => columns.removed_at = true, - "" => {} - _ => { - return Err(format!("Unknown column {column}")); - } - } - } - Ok(columns) - } -} - -impl Sortable for SelectedPageTemplate { - type SortKey = SelectedPageSort; - - fn get_current_sort(&self) -> SortOn { - self.sort - } -} - -impl HidableColumns for SelectedPageTemplate { - fn add_column(&self, size: Box) { - self.cols.borrow_mut().push(size); - } - - fn get_columns(&self) -> Ref<'_, Vec>> { - self.cols.borrow() - } -} diff --git a/server/src/web/pages/torrent.rs b/server/src/web/pages/torrent.rs deleted file mode 100644 index cabaf0be..00000000 --- a/server/src/web/pages/torrent.rs +++ /dev/null @@ -1,581 +0,0 @@ -use std::{collections::BTreeSet, ops::Deref, path::PathBuf}; - -use anyhow::Result; -use askama::Template; -use axum::{ - body::Body, - extract::{OriginalUri, Path, State}, - response::{Html, IntoResponse, Redirect}, -}; -use axum_extra::extract::Form; -use itertools::Itertools; -use mlm_db::{ - ClientStatus, DatabaseExt as _, Event, EventKey, EventType, Size, Torrent, TorrentCost, - TorrentKey, TorrentMeta, -}; -use mlm_mam::{ - api::MaM, - enums::SearchIn, - search::{MaMTorrent, SearchFields, SearchQuery, Tor}, -}; -use native_db::Database; -use qbit::{ - models::{Category, Torrent as QbitTorrent, Tracker}, - parameters::TorrentState, -}; -use regex::Regex; -use reqwest::header; -use serde::Deserialize; -use time::UtcDateTime; -use tokio_util::io::ReaderStream; - -use crate::{ - audiobookshelf::{Abs, LibraryItemMinified}, - cleaner::clean_torrent, - config::Config, - linker::{find_library, library_dir, map_path, refresh_metadata, refresh_metadata_relink}, - qbittorrent::{self, ensure_category_exists}, - stats::Context, - web::{ - AppError, Conditional, MaMTorrentsTemplate, Page, TorrentLink, flag_icons, - pages::{search::select_torrent, torrents::TorrentsPageFilter}, - tables::table_styles, - time, - }, -}; - -pub async fn torrent_file( - State(context): State, - Path((id, filename)): Path<(String, String)>, -) -> impl IntoResponse { - let config = context.config().await; - let Some(torrent) = context.db.r_transaction()?.get().primary::(id)? else { - return Err(AppError::NotFound); - }; - let Some(path) = (if let (Some(library_path), Some(library_file)) = ( - &torrent.library_path, - torrent - .library_files - .iter() - .find(|f| f.to_string_lossy() == filename), - ) { - Some(library_path.join(library_file)) - } else if let Some((torrent, qbit, qbit_config)) = - qbittorrent::get_torrent(&config, &torrent.id).await? - { - qbit.files(&torrent.hash, None) - .await? - .into_iter() - .find(|f| f.name == filename) - .map(|file| map_path(&qbit_config.path_mapping, &torrent.save_path).join(&file.name)) - } else { - None - }) else { - return Err(AppError::NotFound); - }; - let file = match tokio::fs::File::open(path).await { - Ok(file) => file, - Err(_) => return Err(AppError::NotFound), - }; - let stream = ReaderStream::new(file); - let body = Body::from_stream(stream); - - let headers = [ - (header::CONTENT_TYPE, "text/toml; charset=utf-8".to_string()), - ( - header::CONTENT_DISPOSITION, - format!("attachment; filename=\"{}\"", filename), - ), - ]; - - Ok((headers, body)) -} - -pub async fn torrent_page( - State(context): State, - Path(id_or_mam_id): Path, -) -> std::result::Result, AppError> { - if let Ok(id) = id_or_mam_id.parse() { - torrent_page_mam_id(State(context), Path(id)).await - } else { - torrent_page_id(State(context), Path(id_or_mam_id)).await - } -} - -async fn torrent_page_mam_id( - State(context): State, - Path(mam_id): Path, -) -> std::result::Result, AppError> { - if let Some(torrent) = context - .db - .r_transaction()? - .get() - .secondary::(TorrentKey::mam_id, mam_id)? - { - return torrent_page_id(State(context), Path(torrent.id)).await; - }; - - let mam = context.mam()?; - let Some(mam_torrent) = mam.get_torrent_info_by_id(mam_id).await? else { - return Err(AppError::NotFound); - }; - let meta = mam_torrent.as_meta()?; - - println!("mam_torrent: {:?}", mam_torrent); - println!("mam_meta: {:?}", meta); - let config = context.config.lock().await.clone(); - let other_torrents = other_torrents(&config, &context.db, &mam, &meta).await?; - - let template = TorrentMamPageTemplate { - mam_torrent, - meta, - other_torrents, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -async fn torrent_page_id( - State(context): State, - Path(id): Path, -) -> std::result::Result, AppError> { - let config = context.config().await; - let abs = config.audiobookshelf.as_ref().map(Abs::new); - let Some(mut torrent) = context.db.r_transaction()?.get().primary::(id)? else { - return Err(AppError::NotFound); - }; - let replacement_torrent = torrent - .replaced_with - .as_ref() - .map(|(id, _)| { - context - .db - .r_transaction()? - .get() - .primary::(id.to_string()) - }) - .transpose()? - .flatten(); - - if replacement_torrent.is_none() && torrent.replaced_with.is_some() { - let (_guard, rw) = context.db.rw_async().await?; - torrent.replaced_with = None; - rw.upsert(torrent.clone())?; - rw.commit()?; - } - let book = match abs { - Some(abs) => abs?.get_book(&torrent).await?, - None => None, - }; - - let events = context - .db - .r_transaction()? - .scan() - .secondary::(EventKey::mam_id)?; - let events = events.range(Some(torrent.mam_id)..=Some(torrent.mam_id))?; - let mut events = events.collect::, _>>()?; - events.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - - let mam = context.mam()?; - let mam_torrent = mam.get_torrent_info_by_id(torrent.mam_id).await?; - let mam_meta = mam_torrent.as_ref().map(|t| t.as_meta()).transpose()?; - - if let Some(mam_meta) = &mam_meta - && torrent.meta.uploaded_at.0 == UtcDateTime::UNIX_EPOCH - { - let (_guard, rw) = context.db.rw_async().await?; - torrent.meta.uploaded_at = mam_meta.uploaded_at; - rw.upsert(torrent.clone())?; - rw.commit()?; - } - - let mut qbit_data = None; - let mut wanted_path = None; - let mut qbit_files = vec![]; - if torrent.id_is_hash - && let Some((qbit_torrent, qbit, _)) = - qbittorrent::get_torrent(&config, &torrent.id).await? - { - let trackers = qbit.trackers(&torrent.id).await?; - let mut categories = qbit.categories().await?.into_values().collect_vec(); - categories.sort_by(|a, b| a.name.cmp(&b.name)); - let tags = qbit.tags().await?; - - wanted_path = find_library(&config, &qbit_torrent).and_then(|library| { - library_dir( - config.exclude_narrator_in_library_dir, - library, - &torrent.meta, - ) - }); - - qbit_data = Some(QbitData { - torrent_tags: qbit_torrent.tags.split(", ").map(str::to_string).collect(), - torrent: qbit_torrent, - trackers, - categories, - tags, - }); - - qbit_files = qbit.files(&torrent.id, None).await?; - } - - println!("book: {:?}", book); - println!("mam_torrent: {:?}", mam_torrent); - println!( - "mam_meta: {} {:?}", - Some(&torrent.meta) == mam_meta.as_ref(), - mam_meta - ); - println!("qbit: {:?}", qbit_data); - - if !config.qbittorrent.is_empty() - && qbit_data.is_none() - && torrent.client_status != Some(ClientStatus::NotInClient) - { - let (_guard, rw) = context.db.rw_async().await?; - torrent.client_status = Some(ClientStatus::NotInClient); - rw.upsert(torrent.clone())?; - rw.commit()?; - } - let other_torrents = other_torrents(&config, &context.db, &mam, &torrent.meta).await?; - - let template = TorrentPageTemplate { - abs_url: config - .audiobookshelf - .as_ref() - .map(|abs| abs.url.clone()) - .unwrap_or_default(), - torrent, - replacement_torrent, - events, - book, - mam_torrent, - mam_meta, - qbit_data, - wanted_path, - qbit_files, - other_torrents, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn torrent_page_post( - State(context): State, - Path(id_or_mam_id): Path, - uri: OriginalUri, - Form(form): Form, -) -> Result { - if let Ok(id) = id_or_mam_id.parse() { - torrent_page_post_mam_id(State(context), Path(id), uri, Form(form)).await - } else { - torrent_page_post_id(State(context), Path(id_or_mam_id), uri, Form(form)).await - } -} - -pub async fn torrent_page_post_mam_id( - State(context): State, - Path(mam_id): Path, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let mam_id = form.mam_id.unwrap_or(mam_id); - if let Some(torrent) = context - .db - .r_transaction()? - .get() - .secondary::(TorrentKey::mam_id, mam_id)? - { - if form.mam_id.is_some() { - return Err(anyhow::Error::msg("torrent is already downloaded").into()); - } - return torrent_page_post_id(State(context), Path(torrent.id), uri, Form(form)).await; - }; - - match form.action.as_str() { - "select" | "wedge" => { - select_torrent(&context, mam_id, form.action == "wedge").await?; - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -pub async fn torrent_page_post_id( - State(context): State, - Path(id): Path, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let config = context.config().await; - match form.action.as_str() { - "select" | "wedge" => { - let Some(mam_id) = form.mam_id else { - return Err(anyhow::Error::msg("torrent is already downloaded").into()); - }; - select_torrent(&context, mam_id, form.action == "wedge").await?; - } - "clean" => { - let Some(torrent) = context.db.r_transaction()?.get().primary(id)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - clean_torrent(&config, &context.db, torrent, true).await?; - } - "refresh" => { - let mam = context.mam()?; - refresh_metadata(&config, &context.db, &mam, id).await?; - } - "refresh-relink" => { - let mam = context.mam()?; - refresh_metadata_relink(&config, &context.db, &mam, id).await?; - } - "remove" => { - let (_guard, rw) = context.db.rw_async().await?; - let Some(torrent) = rw.get().primary::(id)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - rw.remove(torrent)?; - rw.commit()?; - } - "torrent-start" => { - let Some((_torrent, qbit, _)) = qbittorrent::get_torrent(&config, &id).await? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - qbit.start(vec![&id]).await?; - } - "torrent-stop" => { - let Some((_torrent, qbit, _)) = qbittorrent::get_torrent(&config, &id).await? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - qbit.stop(vec![&id]).await?; - } - "clear-replacement" => { - let (_guard, rw) = context.db.rw_async().await?; - let Some(mut torrent) = rw.get().primary::(id)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - torrent.replaced_with.take(); - rw.upsert(torrent)?; - rw.commit()?; - } - "qbit" => { - let Some((torrent, qbit, qbit_conf)) = qbittorrent::get_torrent(&config, &id).await? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - - ensure_category_exists(&qbit, &qbit_conf.url, &form.category).await?; - qbit.set_category(Some(vec![&id]), &form.category).await?; - let mut torrent_tags = torrent.tags.split(", ").collect::>(); - if torrent.tags.is_empty() { - torrent_tags.clear(); - } - if !form.tags.is_empty() { - let mut add_tags = form - .tags - .iter() - .map(|tag| tag.as_str()) - .collect::>(); - for tag in &torrent_tags { - add_tags.remove(tag); - } - if !add_tags.is_empty() { - println!("add tags {:?}", add_tags); - qbit.add_tags(Some(vec![&id]), add_tags.into_iter().collect()) - .await?; - } - } - for tag in &form.tags { - torrent_tags.remove(tag.as_str()); - } - if !torrent_tags.is_empty() { - println!("remove tags {torrent_tags:?}"); - qbit.remove_tags( - Some(vec![&id]), - form.tags.iter().map(Deref::deref).collect(), - ) - .await?; - } - } - "remove-torrent" => { - // let Some(torrent) = context.db.r_transaction()?.get().primary(id)? else { - // return Err(anyhow::Error::msg("Could not find torrent").into()); - // }; - // remove_library_files(&torrent)?; - for qbit_conf in config.qbittorrent.iter() { - let qbit = qbit::Api::new_login_username_password( - &qbit_conf.url, - &qbit_conf.username, - &qbit_conf.password, - ) - .await?; - qbit.delete(vec![&id], true).await?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentPageForm { - action: String, - #[serde(default)] - category: String, - #[serde(default)] - tags: Vec, - mam_id: Option, -} - -#[derive(Template)] -#[template(path = "pages/torrent.html")] -struct TorrentPageTemplate { - abs_url: String, - torrent: Torrent, - replacement_torrent: Option, - events: Vec, - book: Option, - mam_torrent: Option, - mam_meta: Option, - qbit_data: Option, - wanted_path: Option, - qbit_files: Vec, - other_torrents: MaMTorrentsTemplate, -} - -impl TorrentPageTemplate { - fn torrent_title<'a>(&'a self, torrent: &'a Option) -> Conditional> { - Conditional { - template: torrent.as_ref().map(|t| TorrentLink { - id: &t.id, - title: &t.meta.title, - }), - } - } -} - -impl Page for TorrentPageTemplate { - fn item_path(&self) -> &'static str { - "/torrents" - } -} - -#[derive(Template)] -#[template(path = "pages/torrent_mam.html")] -struct TorrentMamPageTemplate { - mam_torrent: MaMTorrent, - meta: TorrentMeta, - other_torrents: MaMTorrentsTemplate, -} - -impl Page for TorrentMamPageTemplate { - fn item_path(&self) -> &'static str { - "/torrents" - } -} - -#[derive(Debug)] -struct QbitData { - torrent: QbitTorrent, - trackers: Vec, - categories: Vec, - tags: Vec, - torrent_tags: BTreeSet, -} - -#[allow(unused)] -fn duration(seconds: f64) -> String { - let mut seconds = seconds as u64; - let hours = seconds / 3600; - seconds -= hours * 3600; - let minutes = seconds / 60; - seconds -= minutes * 60; - let mut duration = vec![]; - if hours > 0 { - duration.push(format!("{hours}h")); - } - if minutes > 0 { - duration.push(format!("{minutes}m")); - } - if seconds > 0 { - duration.push(format!("{seconds}s")); - } - duration.join(" ") -} - -async fn other_torrents( - config: &Config, - db: &Database<'_>, - mam: &MaM<'_>, - meta: &TorrentMeta, -) -> Result { - let title = meta - .title - .split_once(":") - .map_or(meta.title.as_str(), |(a, _)| a); - - let title = Regex::new(r#"([\*\?])"#) - .unwrap() - .replace_all(title, r#""$1""#); - let title = Regex::new(r#"(?:['`/-])"#) - .unwrap() - .replace_all(&title, " "); - let title = Regex::new(r#"\s+\[[^\]\[]+?\]"#) - .unwrap() - .replace_all(&title, ""); - let title = Regex::new(r#"\s+\([^<>\)\(]+?\)|\s+\[[^<>\]\[]+?\]|@"#) - .unwrap() - .replace_all(&title, ""); - let title = Regex::new(r#"&|\band\b"#) - .unwrap() - .replace_all(&title, "(&|and)"); - - let result = mam - .search(&SearchQuery { - fields: SearchFields { - media_info: true, - ..Default::default() - }, - tor: Tor { - text: if meta.authors.is_empty() { - title.to_string() - } else { - format!( - "{} ({})", - title, - meta.authors.iter().map(|a| format!("\"{a}\"")).join(" | ") - ) - }, - srch_in: vec![SearchIn::Title, SearchIn::Author], - ..Default::default() - }, - ..Default::default() - }) - .await?; - - let r = db.r_transaction()?; - let torrents = result - .data - .into_iter() - .filter(|t| t.id != meta.mam_id) - .map(|mam_torrent| { - let meta = mam_torrent.as_meta()?; - let torrent = r - .get() - .secondary::(TorrentKey::mam_id, meta.mam_id)?; - let selected_torrent = r.get().primary(mam_torrent.id)?; - - Ok((mam_torrent, meta, torrent, selected_torrent)) - }) - .collect::>()?; - - Ok(MaMTorrentsTemplate { - config: config.search.clone(), - torrents, - }) -} diff --git a/server/src/web/pages/torrent_edit.rs b/server/src/web/pages/torrent_edit.rs deleted file mode 100644 index 90f589cd..00000000 --- a/server/src/web/pages/torrent_edit.rs +++ /dev/null @@ -1,164 +0,0 @@ -use std::sync::Arc; - -use askama::Template; -use axum::{ - extract::{Path, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use itertools::Itertools; -use mlm_db::{ - AudiobookCategory, DatabaseExt as _, EbookCategory, FlagBits, Flags, Language, MetadataSource, - OldCategory, Series, Torrent, TorrentMeta, impls::format_serie, -}; -use native_db::Database; -use serde::Deserialize; - -use crate::{ - autograbber::update_torrent_meta, - stats::Context, - web::{AppError, Page}, -}; - -pub async fn torrent_edit_page( - State(db): State>>, - Path(hash): Path, -) -> std::result::Result, AppError> { - let Some(torrent) = db.r_transaction()?.get().primary::(hash)? else { - return Err(AppError::NotFound); - }; - - let template = TorrentPageTemplate { - flags: Flags::from_bitfield(torrent.meta.flags.map_or(0, |f| f.0)), - torrent, - }; - Ok::<_, AppError>(Html(template.to_string())) -} - -pub async fn torrent_edit_page_post( - State(context): State, - Path(hash): Path, - Form(form): Form, -) -> Result { - let config = context.config().await; - let mam = context.mam()?; - let Some(torrent) = context - .db - .r_transaction()? - .get() - .primary::(hash.clone())? - else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - let Some(mam_torrent) = mam.get_torrent_info(&hash).await? else { - return Err(anyhow::Error::msg("Could not find torrent on MaM").into()); - }; - - let authors = form.authors.split("\r\n").map(ToOwned::to_owned).collect(); - let narrators = if form.narrators.trim().is_empty() { - vec![] - } else { - form.narrators - .split("\r\n") - .map(ToOwned::to_owned) - .collect() - }; - let series = if form.series.trim().is_empty() { - vec![] - } else { - form.series - .split("\r\n") - .map(|s| { - s.split_once(" #") - .map(|(s, n)| Series::try_from((s.to_string(), n.to_string()))) - .unwrap_or_else(|| Series::try_from((s.to_string(), "".to_string()))) - .map_err(|err| { - anyhow::Error::msg(format!("failed to parse series \"{s}\": {err}")) - }) - }) - .collect::, _>>()? - }; - let language = - Language::from_id(form.language).ok_or_else(|| anyhow::Error::msg("Invalid language"))?; - let category = OldCategory::from_one_id(form.category) - .ok_or_else(|| anyhow::Error::msg("Invalid category"))?; - let flags = Flags { - crude_language: Some(form.crude_language), - violence: Some(form.violence), - some_explicit: Some(form.some_explicit), - explicit: Some(form.explicit), - abridged: Some(form.abridged), - lgbt: Some(form.lgbt), - }; - - let meta = TorrentMeta { - title: form.title, - media_type: category.as_main_cat().into(), - cat: Some(category), - language: Some(language), - flags: Some(FlagBits::new(flags.as_bitfield())), - authors, - narrators, - series, - source: MetadataSource::Manual, - ..torrent.meta.clone() - }; - - update_torrent_meta( - &config, - &context.db, - context.db.rw_async().await?, - &mam_torrent, - torrent, - meta, - true, - false, - ) - .await?; - - Ok(Redirect::to(&format!("/torrents/{}", hash))) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentPageForm { - title: String, - authors: String, - narrators: String, - series: String, - language: u8, - category: u64, - - // #[serde(flatten)] - // flags: Flags, - #[serde(default)] - crude_language: bool, - #[serde(default)] - violence: bool, - #[serde(default)] - some_explicit: bool, - #[serde(default)] - explicit: bool, - #[serde(default)] - abridged: bool, - #[serde(default)] - lgbt: bool, -} - -#[derive(Template)] -#[template(path = "pages/torrent_edit.html")] -struct TorrentPageTemplate { - torrent: Torrent, - flags: Flags, -} - -impl TorrentPageTemplate { - fn series(&self) -> String { - self.torrent.meta.series.iter().map(format_serie).join("\n") - } -} - -impl Page for TorrentPageTemplate { - fn item_path(&self) -> &'static str { - "/torrents" - } -} diff --git a/server/src/web/pages/torrents.rs b/server/src/web/pages/torrents.rs deleted file mode 100644 index 26715dc9..00000000 --- a/server/src/web/pages/torrents.rs +++ /dev/null @@ -1,861 +0,0 @@ -use std::cell::Ref; -use std::cell::RefCell; -use std::mem; -use std::str::FromStr; - -use anyhow::Result; -use askama::Template; -use axum::response::{IntoResponse, Response}; -use axum::{ - extract::{OriginalUri, Query, State}, - response::{Html, Redirect}, -}; -use axum_extra::extract::Form; -use mlm_db::{Language, LibraryMismatch, Torrent, TorrentKey}; -use serde::{Deserialize, Serialize}; -use sublime_fuzzy::FuzzySearch; - -use crate::{ - cleaner::clean_torrent, - linker::{refresh_metadata, refresh_metadata_relink}, - stats::Context, - web::{ - AppError, - tables::{Flex, HidableColumns, Key, Pagination, PaginationParams, SortOn, Sortable}, - time, - }, - web::{Page, flag_icons, tables}, -}; -use mlm_db::{ - Category, ClientStatus, DatabaseExt as _, Flags, MediaType, MetadataSource, OldCategory, - Series, SeriesEntry, -}; - -pub async fn torrents_page( - State(context): State, - uri: OriginalUri, - Query(sort): Query>, - Query(filter): Query>, - Query(show): Query, - Query(paging): Query, -) -> std::result::Result { - let r = context.db.r_transaction()?; - - let torrent_count = r.len().secondary::(TorrentKey::created_at)?; - let torrents = r.scan().secondary::(TorrentKey::created_at)?; - let mut filter = filter; - let query_pos = filter - .iter() - .position(|(field, _)| field == &TorrentsPageFilter::Query); - let query = query_pos - .map(|i| filter.remove(i)) - .and_then(|(_, value)| if value.is_empty() { None } else { Some(value) }); - let metadata_pos = filter - .iter() - .position(|(field, _)| field == &TorrentsPageFilter::Metadata); - let metadata = metadata_pos - .map(|i| filter.remove(i)) - .and_then(|(_, value)| if value.is_empty() { None } else { Some(value) }); - filter.retain(|(field, _)| { - !matches!( - field, - TorrentsPageFilter::SortBy - | TorrentsPageFilter::Asc - | TorrentsPageFilter::Show - | TorrentsPageFilter::From - | TorrentsPageFilter::PageSize - ) - }); - let show = show.show.unwrap_or_default(); - - let torrents = torrents.all()?.rev(); - - let torrents = torrents.filter_map(|t| { - let Ok(t) = t else { - return Some(t.map(|t| (t, 0))); - }; - let mut torrent_score = 0; - for (field, value) in filter.iter() { - let ok = match field { - TorrentsPageFilter::Kind => t.meta.media_type.as_str() == value, - TorrentsPageFilter::Category => { - if value.is_empty() { - t.meta.cat.is_none() - } else if let Some(cat) = &t.meta.cat { - let cats = value - .split(",") - .filter_map(|id| id.parse().ok()) - .filter_map(OldCategory::from_one_id) - .collect::>(); - cats.contains(cat) || cat.as_str() == value - } else { - false - } - } - TorrentsPageFilter::Categories => { - if value.is_empty() { - t.meta.categories.is_empty() - } else { - value - .split(",") - .filter_map(|id| id.parse().ok()) - .filter_map(Category::from_id) - .all(|cat| t.meta.categories.contains(&cat)) - } - } - TorrentsPageFilter::Flags => { - if value.is_empty() { - t.meta.flags.is_none_or(|f| f.0 == 0) - } else if let Some(flags) = &t.meta.flags { - let flags = Flags::from_bitfield(flags.0); - match value.as_str() { - "violence" => flags.violence == Some(true), - "explicit" => flags.explicit == Some(true), - "some_explicit" => flags.some_explicit == Some(true), - "language" => flags.crude_language == Some(true), - "abridged" => flags.abridged == Some(true), - "lgbt" => flags.lgbt == Some(true), - _ => false, - } - } else { - false - } - } - TorrentsPageFilter::Title => &t.meta.title == value, - TorrentsPageFilter::Author => { - if value.is_empty() { - t.meta.authors.is_empty() - } else { - t.meta.authors.contains(value) - } - } - TorrentsPageFilter::Narrator => { - if value.is_empty() { - t.meta.narrators.is_empty() - } else { - t.meta.narrators.contains(value) - } - } - TorrentsPageFilter::Series => { - if value.is_empty() { - t.meta.series.is_empty() - } else { - t.meta.series.iter().any(|s| &s.name == value) - } - } - TorrentsPageFilter::Language => { - if value.is_empty() { - t.meta.language.is_none() - } else { - t.meta.language == Language::from_str(value).ok() - } - } - TorrentsPageFilter::Filetype => t.meta.filetypes.contains(value), - TorrentsPageFilter::Linker => { - if value.is_empty() { - t.linker.is_none() - } else { - t.linker.as_ref() == Some(value) - } - } - TorrentsPageFilter::QbitCategory => { - if value.is_empty() { - t.category.is_none() - } else { - t.category.as_ref() == Some(value) - } - } - TorrentsPageFilter::Linked => t.library_path.is_some() == (value == "true"), - TorrentsPageFilter::LibraryMismatch => { - if value.is_empty() { - t.library_mismatch.is_some() - } else { - match t.library_mismatch { - Some(LibraryMismatch::NewLibraryDir(ref path)) => { - value == "new_library" || value.as_str() == path.to_string_lossy() - } - Some(LibraryMismatch::NewPath(ref path)) => { - value == "new_path" || value.as_str() == path.to_string_lossy() - } - Some(LibraryMismatch::NoLibrary) => value == "no_library", - None => false, - } - } - } - TorrentsPageFilter::ClientStatus => match t.client_status { - Some(ClientStatus::NotInClient) => value == "not_in_client", - Some(ClientStatus::RemovedFromMam) => value == "removed_from_mam", - None => false, - }, - TorrentsPageFilter::Abs => t.abs_id.is_some() == (value == "true"), - TorrentsPageFilter::Source => match value.as_str() { - "mam" => t.meta.source == MetadataSource::Mam, - "manual" => t.meta.source == MetadataSource::Manual, - _ => false, - }, - TorrentsPageFilter::Query => true, - TorrentsPageFilter::Metadata => true, - TorrentsPageFilter::SortBy => true, - TorrentsPageFilter::Asc => true, - TorrentsPageFilter::Show => true, - TorrentsPageFilter::From => true, - TorrentsPageFilter::PageSize => true, - }; - if !ok { - return None; - } - } - if let Some(value) = query.as_deref() { - torrent_score += score(value, &t.meta.title); - if show.authors { - for author in &t.meta.authors { - torrent_score += score(value, author); - } - } - if show.narrators { - for narrator in &t.meta.narrators { - torrent_score += score(value, narrator); - } - } - if show.series { - for s in &t.meta.series { - torrent_score += score(value, &s.name); - } - } - if torrent_score < 10 { - return None; - } - } - Some(Ok((t, torrent_score))) - }); - - let mut paging = match paging.default_page_size(uri, 500, torrent_count as usize) { - Ok(paging) => paging, - Err(redirect) => return Ok(redirect.into_response()), - }; - - let mut torrents: Vec = if query.is_some() || sort.sort_by.is_some() { - let mut torrents = torrents.collect::, native_db::db_type::Error>>()?; - if let Some(sort_by) = &sort.sort_by { - torrents.sort_by(|(a, _), (b, _)| { - let ord = match sort_by { - TorrentsPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), - TorrentsPageSort::Category => a - .meta - .cat - .partial_cmp(&b.meta.cat) - .unwrap_or(std::cmp::Ordering::Less), - TorrentsPageSort::Title => a.meta.title.cmp(&b.meta.title), - TorrentsPageSort::Edition => a - .meta - .edition - .as_ref() - .map(|e| e.1) - .cmp(&b.meta.edition.as_ref().map(|e| e.1)) - .then(a.meta.edition.cmp(&b.meta.edition)), - TorrentsPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), - TorrentsPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), - TorrentsPageSort::Series => a - .meta - .series - .cmp(&b.meta.series) - .then(a.meta.media_type.cmp(&b.meta.media_type)), - TorrentsPageSort::Language => a.meta.language.cmp(&b.meta.language), - TorrentsPageSort::Size => a.meta.size.cmp(&b.meta.size), - TorrentsPageSort::Linker => a.linker.cmp(&b.linker), - TorrentsPageSort::QbitCategory => a.category.cmp(&b.category), - TorrentsPageSort::Linked => a.library_path.cmp(&b.library_path), - TorrentsPageSort::CreatedAt => a.created_at.cmp(&b.created_at), - TorrentsPageSort::UploadedAt => a.meta.uploaded_at.cmp(&b.meta.uploaded_at), - }; - if sort.asc { ord.reverse() } else { ord } - }); - } else { - torrents.sort_by_key(|(_, score)| -*score); - } - if metadata.is_none() - && let Some(paging) = &mut paging - { - paging.total = torrents.len(); - let torrents: Vec<_> = torrents - .into_iter() - .map(|(t, _)| t) - .skip(paging.from) - .take(paging.page_size) - .collect(); - torrents - } else { - torrents.into_iter().map(|(t, _)| t).collect() - } - } else if metadata.is_none() - && let Some(paging) = &mut paging - { - if filter.is_empty() { - torrents - .map(|t| t.map(|(t, _)| t)) - .skip(paging.from) - .take(paging.page_size) - .collect::>()? - } else { - let mut torrent_count = 0; - let mut new_torrents = vec![]; - for torrent in torrents.map(|t| t.map(|(t, _)| t)) { - torrent_count += 1; - if torrent_count >= paging.from && new_torrents.len() < paging.page_size { - new_torrents.push(torrent?); - } - } - paging.total = torrent_count; - new_torrents - } - } else { - torrents - .map(|t| t.map(|(t, _)| t)) - .collect::>()? - }; - - if let Some(metadata) = metadata { - match metadata.as_str() { - "title" => { - torrents.sort_by(|a, b| { - a.title_search - .cmp(&b.title_search) - .then_with(|| a.meta.authors.cmp(&b.meta.authors)) - }); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if current.title_search != torrent.title_search - || current - .meta - .authors - .iter() - .all(|a| !torrent.meta.authors.contains(a)) - { - if batch.len() > 1 - && !batch.iter().all(|t| t.meta.title == current.meta.title) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "authors" => { - torrents.sort_by(|a, b| a.title_search.cmp(&b.title_search)); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if current.title_search != torrent.title_search { - if batch.len() > 1 - && !batch.iter().all(|t| t.meta.authors == current.meta.authors) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "series" => { - torrents.sort_by(|a, b| a.title_search.cmp(&b.title_search)); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if current.title_search != torrent.title_search { - if batch.len() > 1 - && !batch.iter().all(|t| t.meta.series == current.meta.series) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "leading" => { - fn remove_leading(title: &str) -> &str { - title - .strip_prefix("the ") - .or_else(|| title.strip_prefix("a ")) - .unwrap_or(title) - } - torrents.sort_by(|a, b| { - remove_leading(&a.title_search).cmp(remove_leading(&b.title_search)) - }); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if remove_leading(¤t.title_search) - != remove_leading(&torrent.title_search) - { - if batch.len() > 1 - && !batch.iter().all(|t| t.meta.title == current.meta.title) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "subtitle" => { - fn subtitle(title: &str) -> &str { - let Some((title, _subtitle)) = title.split_once(':') else { - return title; - }; - title - } - torrents.sort_by(|a, b| subtitle(&a.title_search).cmp(subtitle(&b.title_search))); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if subtitle(¤t.title_search) != subtitle(&torrent.title_search) { - if batch.len() > 1 - && !batch.iter().all(|t| t.meta.title == current.meta.title) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "missing_ebook" => { - torrents.sort_by(|a, b| a.title_search.cmp(&b.title_search)); - let mut batch: Vec = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - if let Some(current) = batch.first() { - if current.title_search != torrent.title_search { - if batch - .iter() - .any(|t| t.meta.media_type.matches(MediaType::Audiobook)) - && !batch - .iter() - .any(|t| t.meta.media_type.matches(MediaType::Ebook)) - { - new_torrents.extend(mem::take(&mut batch)); - } else { - batch.clear(); - } - } - batch.push(torrent); - } else { - batch.push(torrent); - } - } - torrents = new_torrents; - } - "initials" => { - fn bunched_initials(name: &str) -> bool { - let mut capital = false; - for char in name.chars() { - if capital && char.is_uppercase() { - return true; - } - capital = char.is_uppercase() - } - false - } - torrents.retain(|t| { - t.meta.authors.iter().any(|name| bunched_initials(name)) - || t.meta.narrators.iter().any(|name| bunched_initials(name)) - }); - } - "series_with_holes" => { - fn first_series(series: &[Series]) -> Option<&Series> { - series.iter().find(|s| !s.entries.0.is_empty()) - } - fn series_name(series: &[Series]) -> &str { - first_series(series) - .map(|s| s.name.as_str()) - .unwrap_or_default() - } - torrents - .sort_by(|a, b| series_name(&a.meta.series).cmp(series_name(&b.meta.series))); - let mut batch: Vec<(Torrent, Series)> = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - let Some(series) = first_series(&torrent.meta.series) else { - continue; - }; - if let Some(current) = batch.first() { - if current.1.name != series.name { - if batch.iter().any(|t| t.0.library_path.is_some()) { - batch.sort_by(|a, b| a.1.entries.cmp(&b.1.entries)); - let last = batch - .iter() - .flat_map(|s| &s.1.entries.0) - .map(|s| match s { - SeriesEntry::Num(n) => *n, - SeriesEntry::Range(_start, end) => *end, - SeriesEntry::Part(n, _) => *n, - } as i32) - .max() - .unwrap_or_default(); - for i in 1..=last { - if !batch - .iter() - .any(|(_, series)| series.entries.contains(i as f32)) - { - new_torrents.extend( - mem::take(&mut batch).into_iter().map(|(t, _)| t), - ); - break; - } - } - } - batch.clear(); - } - let series = series.clone(); - batch.push((torrent, series)); - } else { - let series = series.clone(); - batch.push((torrent, series)); - } - } - torrents = new_torrents; - } - "series_authors" => { - fn first_series(series: &[Series]) -> Option<&Series> { - series.iter().find(|s| !s.entries.0.is_empty()) - } - fn series_name(series: &[Series]) -> &str { - first_series(series) - .map(|s| s.name.as_str()) - .unwrap_or_default() - } - torrents - .sort_by(|a, b| series_name(&a.meta.series).cmp(series_name(&b.meta.series))); - let mut batch: Vec<(Torrent, Series)> = vec![]; - let mut new_torrents: Vec = vec![]; - for torrent in torrents { - let Some(series) = first_series(&torrent.meta.series) else { - continue; - }; - if let Some(current) = batch.first() { - if current.1.name != series.name { - if batch.len() > 1 - && !batch - .iter() - .all(|t| t.0.meta.authors == current.0.meta.authors) - { - new_torrents - .extend(mem::take(&mut batch).into_iter().map(|(t, _)| t)); - } - batch.clear(); - } - let series = series.clone(); - batch.push((torrent, series)); - } else { - let series = series.clone(); - batch.push((torrent, series)); - } - } - torrents = new_torrents; - } - _ => return Err(anyhow::Error::msg("Unknown metadata filter").into()), - } - if let Some(paging) = &mut paging { - paging.total = torrents.len(); - torrents = torrents - .into_iter() - .skip(paging.from) - .take(paging.page_size) - .collect(); - } - } - - let template = TorrentsPageTemplate { - abs_url: context - .config - .lock() - .await - .audiobookshelf - .as_ref() - .map(|abs| abs.url.clone()), - paging: paging.unwrap_or_default(), - sort, - show, - cols: Default::default(), - query: query.as_deref().unwrap_or("").to_owned(), - torrents, - }; - Ok::<_, AppError>(Html(template.to_string()).into_response()) -} - -fn score(query: &str, target: &str) -> isize { - FuzzySearch::new(query, target) - .case_insensitive() - .best_match() - .map_or(0, |m| m.score()) -} - -pub async fn torrents_page_post( - State(context): State, - uri: OriginalUri, - Form(form): Form, -) -> Result { - let config = context.config().await; - match form.action.as_str() { - "clean" => { - for torrent in form.torrents { - let Some(torrent) = context.db.r_transaction()?.get().primary(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - clean_torrent(&config, &context.db, torrent, true).await?; - } - } - "refresh" => { - let mam = context.mam()?; - for torrent in form.torrents { - refresh_metadata(&config, &context.db, &mam, torrent).await?; - } - } - "refresh-relink" => { - let mam = context.mam()?; - for torrent in form.torrents { - refresh_metadata_relink( - context.config.lock().await.as_ref(), - &context.db, - &mam, - torrent, - ) - .await?; - } - } - "remove" => { - for torrent in form.torrents { - let (_guard, rw) = context.db.rw_async().await?; - let Some(torrent) = rw.get().primary::(torrent)? else { - return Err(anyhow::Error::msg("Could not find torrent").into()); - }; - rw.remove(torrent)?; - rw.commit()?; - } - } - action => { - eprintln!("unknown action: {action}"); - } - } - - Ok(Redirect::to(&uri.to_string())) -} - -#[derive(Debug, Deserialize)] -pub struct TorrentsPageForm { - action: String, - #[serde(default, rename = "torrent")] - torrents: Vec, -} - -#[derive(Template)] -#[template(path = "pages/torrents.html")] -struct TorrentsPageTemplate { - abs_url: Option, - paging: Pagination, - sort: SortOn, - show: TorrentsPageColumns, - cols: RefCell>>, - query: String, - torrents: Vec, -} - -impl Page for TorrentsPageTemplate {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] -#[serde(rename_all = "lowercase")] -pub enum TorrentsPageSort { - Kind, - Category, - Title, - Edition, - Authors, - Narrators, - Series, - Language, - Size, - Linker, - QbitCategory, - Linked, - CreatedAt, - UploadedAt, -} - -impl Key for TorrentsPageSort {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TorrentsPageFilter { - Kind, - Category, - Categories, - Flags, - Title, - Author, - Narrator, - Series, - Language, - Filetype, - Linker, - QbitCategory, - Linked, - LibraryMismatch, - ClientStatus, - Abs, - Query, - Source, - Metadata, - // Workaround sort decode failure - SortBy, - Asc, - Show, - From, - PageSize, -} - -impl Key for TorrentsPageFilter {} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(try_from = "String")] -struct TorrentsPageColumns { - category: bool, - categories: bool, - flags: bool, - edition: bool, - authors: bool, - narrators: bool, - series: bool, - language: bool, - size: bool, - filetypes: bool, - linker: bool, - qbit_category: bool, - path: bool, - created_at: bool, - uploaded_at: bool, -} - -#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct TorrentsPageColumnsQuery { - show: Option, -} - -impl Default for TorrentsPageColumns { - fn default() -> Self { - TorrentsPageColumns { - category: false, - categories: false, - flags: false, - edition: false, - authors: true, - narrators: true, - series: true, - language: false, - size: true, - filetypes: true, - linker: false, - qbit_category: false, - path: false, - created_at: true, - uploaded_at: false, - } - } -} - -impl TryFrom for TorrentsPageColumns { - type Error = String; - - fn try_from(value: String) -> std::result::Result { - let mut columns = TorrentsPageColumns { - category: false, - categories: false, - flags: false, - edition: false, - authors: false, - narrators: false, - series: false, - language: false, - size: false, - filetypes: false, - linker: false, - qbit_category: false, - path: false, - created_at: false, - uploaded_at: false, - }; - for column in value.split(",") { - match column { - "category" => columns.category = true, - "categories" => columns.categories = true, - "flags" => columns.flags = true, - "edition" => columns.edition = true, - "author" => columns.authors = true, - "narrator" => columns.narrators = true, - "series" => columns.series = true, - "language" => columns.language = true, - "size" => columns.size = true, - "filetype" => columns.filetypes = true, - "linker" => columns.linker = true, - "qbit_category" => columns.qbit_category = true, - "path" => columns.path = true, - "created_at" => columns.created_at = true, - "uploaded_at" => columns.uploaded_at = true, - "" => {} - _ => { - return Err(format!("Unknown column {column}")); - } - } - } - Ok(columns) - } -} - -impl Sortable for TorrentsPageTemplate { - type SortKey = TorrentsPageSort; - - fn get_current_sort(&self) -> SortOn { - self.sort - } -} -impl HidableColumns for TorrentsPageTemplate { - fn add_column(&self, size: Box) { - self.cols.borrow_mut().push(size); - } - - fn get_columns(&self) -> Ref<'_, Vec>> { - self.cols.borrow() - } -} diff --git a/server/src/web/tables.rs b/server/src/web/tables.rs deleted file mode 100644 index a911b544..00000000 --- a/server/src/web/tables.rs +++ /dev/null @@ -1,408 +0,0 @@ -use std::{ - cell::Ref, - fmt::{self, Display}, - ops::RangeInclusive, -}; - -use askama::{Template, filters::HtmlSafe}; -use axum::{extract::OriginalUri, response::Redirect}; -use itertools::Itertools as _; -use reqwest::Url; -use serde::{Deserialize, Serialize}; - -use super::Conditional; - -#[derive(Debug, Deserialize)] -pub struct PaginationParams { - pub from: Option, - pub page_size: Option, -} - -impl PaginationParams { - pub fn default_page_size( - &self, - uri: OriginalUri, - page_size: usize, - total: usize, - ) -> Result, Redirect> { - let from = self.from.unwrap_or_default(); - let page_size = self.page_size.unwrap_or(page_size); - let pagination = Pagination { - from, - page_size, - total, - max_pages: 7, - }; - if total < from { - let from = pagination.link(pagination.num_pages()); - let base_url = Url::parse("https://a").unwrap(); - let url = base_url.join(&uri.to_string()); - if let Ok(mut url) = url { - let mut pairs = url - .query_pairs() - .map(|(name, value)| (name.to_string(), value.to_string())) - .collect_vec(); - let from_pair = pairs.iter().find_position(|(name, _)| name == "from"); - if let Some((index, (name, _))) = from_pair { - pairs[index] = (name.clone(), from.to_string()); - } - url.query_pairs_mut().clear().extend_pairs(pairs); - return Err(Redirect::to(&url.as_str()[10..])); - } - } - if page_size == 0 { - Ok(None) - } else { - Ok(Some(pagination)) - } - } -} - -/// ```askama -/// {% if page_size > 0 && total > page_size %} -/// -/// {% endif %} -/// ``` -#[derive(Template, Default)] -#[template(ext = "html", in_doc = true)] -pub struct Pagination { - pub from: usize, - pub page_size: usize, - pub total: usize, - pub max_pages: usize, -} -impl Pagination { - pub fn num_pages(&self) -> usize { - (self.total as f64 / self.page_size as f64).ceil().round() as usize - } - pub fn pages(&self) -> RangeInclusive { - let last_page = self.num_pages(); - if last_page > self.max_pages { - let current_page = self.from / self.page_size + 1; - let half = self.max_pages / 2; - if current_page <= half { - 1..=self.max_pages - } else if current_page >= last_page - half { - (last_page - self.max_pages + 1)..=last_page - } else { - (current_page - half)..=(current_page + half) - } - } else { - 1..=last_page - } - } - pub fn active(&self, page: usize) -> bool { - let from = (page - 1) * self.page_size; - from == self.from - } - pub fn prev(&self) -> usize { - self.from.saturating_sub(self.page_size) - } - pub fn next(&self) -> usize { - let last_page = self.num_pages(); - (self.from + self.page_size).min((last_page.saturating_sub(1)) * self.page_size) - } - pub fn link(&self, page: usize) -> usize { - self.page_size * (page - 1) - } - pub fn last(&self) -> bool { - self.from >= self.total - self.page_size - } - pub fn selector<'a>(&'a self, values: &'a [usize]) -> PageSizeSelector<'a> { - PageSizeSelector { - current: self.page_size, - values, - } - } -} - -/// ```askama -/// -/// ``` -#[derive(Template, Default)] -#[template(ext = "html", in_doc = true)] -pub struct PageSizeSelector<'a> { - pub current: usize, - pub values: &'a [usize], -} - -impl<'a> PageSizeSelector<'a> { - pub fn options(&self) -> impl Iterator { - let mut values = vec![]; - if self.current != 0 && !self.values.contains(&self.current) { - values.push(self.current); - } - for value in self.values { - values.push(*value); - } - values.into_iter().map(|v| (v, v == self.current)) - } -} - -#[derive(Clone, Copy, Deserialize)] -pub struct SortOn { - pub sort_by: Option, - #[serde(default)] - pub asc: bool, -} - -pub trait Key: Clone + Copy + PartialEq + Serialize {} - -impl Display for SortOn { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - self.sort_by.unwrap().serialize(f) - } -} - -pub trait Sortable { - type SortKey: Key; - - fn get_current_sort(&self) -> SortOn; - - fn table_header( - &self, - sort_key: Option, - label: &str, - ) -> TableHeader { - let sort = self.get_current_sort(); - TableHeader { - current_key: sort.sort_by, - asc: sort.asc, - key: sort_key, - label: label.to_owned(), - } - } - fn table_header_all_s<'a>(&self, name: &'a str) -> AllColumnHeader<'a> { - AllColumnHeader { name } - } -} - -pub trait HidableColumns: Sortable { - fn add_column(&self, size: Box); - fn get_columns(&self) -> Ref<'_, Vec>>; - - fn table2_styles(&self) -> String { - format!( - "& > div {{ grid-template-columns: {}; }}", - self.get_columns().iter().map(|c| c.style()).join(" ") - ) - } - - fn table_header_if( - &self, - show: &bool, - sort_key: Option, - label: &str, - size: impl Size + 'static, - ) -> Conditional> { - let sort = self.get_current_sort(); - if *show { - self.add_column(Box::new(size)); - } - Conditional { - template: show.then_some(TableHeader { - current_key: sort.sort_by, - asc: sort.asc, - key: sort_key, - label: label.to_owned(), - }), - } - } - fn table_header_s( - &self, - sort_key: Option, - label: &str, - size: impl Size + 'static, - ) -> TableHeader { - let sort = self.get_current_sort(); - self.add_column(Box::new(size)); - TableHeader { - current_key: sort.sort_by, - asc: sort.asc, - key: sort_key, - label: label.to_owned(), - } - } - fn table_header_all<'a>( - &self, - name: &'a str, - size: impl Size + 'static, - ) -> AllColumnHeader<'a> { - self.add_column(Box::new(size)); - AllColumnHeader { name } - } -} - -pub trait Size { - fn style(&self) -> String; - fn px(&self) -> u64; -} -impl Size for u64 { - fn style(&self) -> String { - format!("{self}px") - } - - fn px(&self) -> u64 { - *self - } -} - -pub struct Flex(pub u64, pub u64); -impl Size for Flex { - fn style(&self) -> String { - format!("{}fr", self.0) - } - - fn px(&self) -> u64 { - self.1 - } -} - -/// ```askama -///
-/// -///
-/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct AllColumnHeader<'a> { - name: &'a str, -} - -impl<'a> HtmlSafe for AllColumnHeader<'a> {} - -/// ```askama -/// {% match key %} -/// {% when Some(key) %} -/// -/// {{ label }} -/// {% if Some(**key) == current_key %} -/// {% if asc %}↑{% else %}↓{% endif %} -/// {% endif %} -/// -/// {% when None %} -///
-/// {{ label }} -///
-/// {% endmatch %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct TableHeader { - current_key: Option, - asc: bool, - key: Option, - label: String, -} -impl HtmlSafe for TableHeader {} - -impl TableHeader { - pub fn link(&self) -> String { - let key = SortOn { - sort_by: self.key, - asc: false, - }; - if self.key == self.current_key { - format!("?sort_by={}&asc={}", key, !self.asc) - } else { - format!("?sort_by={}", key) - } - } -} - -pub fn table_styles(cols: u64) -> String { - table_styles_rows(cols, 1) -} -pub fn table_styles_rows(cols: u64, rows: u64) -> String { - let mut styles = format!("grid-template-columns: repeat({cols}, auto);"); - - let grid = cols * rows; - for i in 1..=grid { - styles.push_str(&format!( - "& > div:nth-child({}n+{})", - grid * 2, - grid + i + cols - )); - if i < grid { - styles.push(','); - } - } - styles.push_str("{ background: var(--alternate); }"); - - styles -} - -/// ```askama -/// {{label}} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct ItemFilter<'a, T: Key> { - pub field: T, - pub label: &'a str, - pub value: Option, - pub path: &'a str, -} -impl<'a, T: Key> HtmlSafe for ItemFilter<'a, T> {} - -impl<'a, T: Key> ItemFilter<'a, T> { - pub fn link(&self) -> String { - let key = SortOn { - sort_by: Some(self.field), - asc: false, - }; - format!( - "{}?{}={}", - self.path, - key, - &urlencoding::encode(self.value.as_deref().unwrap_or(self.label)) - ) - } -} - -/// ```askama -/// {% for label in labels %} -/// {{ item(*field, label) | safe }}{% if !loop.last %}, {% endif %} -/// {% endfor %} -/// ``` -#[derive(Template)] -#[template(ext = "html", in_doc = true)] -pub struct ItemFilters<'a, T: Key> { - pub field: T, - pub labels: &'a [String], - pub path: &'a str, -} - -impl<'a, T: Key> ItemFilters<'a, T> { - fn item(&'a self, field: T, label: &'a str) -> ItemFilter<'a, T> { - ItemFilter { - field, - label, - value: None, - path: self.path, - } - } -} -impl<'a, T: Key> HtmlSafe for ItemFilters<'a, T> {} diff --git a/server/src/windows/error_window.rs b/server/src/windows/error_window.rs deleted file mode 100644 index f4cb87de..00000000 --- a/server/src/windows/error_window.rs +++ /dev/null @@ -1,101 +0,0 @@ -use std::path::PathBuf; - -use tracing::error; -use winsafe::{self as w, co::ES, gui, prelude::*}; - -#[derive(Clone)] -#[allow(unused)] -pub struct ErrorWindow { - wnd: gui::WindowMain, - text_error: gui::Edit, - btn_close: gui::Button, - btn_config_file: Option, - - error: String, - config_file: Option, -} - -impl ErrorWindow { - pub fn create_and_run( - title: String, - error: String, - config_file: Option, - ) -> w::AnyResult { - let wnd = gui::WindowMain::new(gui::WindowMainOpts { - title: title.as_str(), - size: gui::dpi(400, 400), - ..Default::default() - }); - - let text_error = gui::Edit::new( - &wnd, - gui::EditOpts { - text: error.as_str(), - position: gui::dpi(8, 8), - width: gui::dpi_x(400 - 16), - height: gui::dpi_y(400 - 16 - 8 - 26), - control_style: ES::MULTILINE | ES::WANTRETURN | ES::AUTOVSCROLL | ES::NOHIDESEL, - resize_behavior: (gui::Horz::Resize, gui::Vert::Resize), - ..Default::default() - }, - ); - - let btn_close = gui::Button::new( - &wnd, - gui::ButtonOpts { - text: "&Close", - position: gui::dpi(400 - 8 - 88, 400 - 8 - 26), - ..Default::default() - }, - ); - - let btn_config_file = config_file.is_some().then(|| { - gui::Button::new( - &wnd, - gui::ButtonOpts { - text: "&Open Config File", - width: gui::dpi_x(100), - position: gui::dpi(8, 400 - 8 - 26), - ..Default::default() - }, - ) - }); - - let new_self = Self { - wnd, - text_error, - btn_close, - btn_config_file, - - error, - config_file, - }; - new_self.events(); - - new_self.wnd.run_main(None) - } - - fn events(&self) { - let text_error = self.text_error.clone(); - let error = self.error.clone(); - self.text_error.on().en_change(move || { - text_error.set_text(error.as_str())?; - Ok(()) - }); - let wnd = self.wnd.clone(); - self.btn_close.on().bn_clicked(move || { - wnd.close(); - Ok(()) - }); - if let (Some(config_file), Some(btn_config_file)) = - (self.config_file.clone(), self.btn_config_file.as_ref()) - { - btn_config_file.on().bn_clicked(move || { - if let Err(err) = open::that(&config_file) { - error!("Error opening config file: {}", err); - } - Ok(()) - }); - } - } -} diff --git a/server/src/windows/mod.rs b/server/src/windows/mod.rs deleted file mode 100644 index c38604c1..00000000 --- a/server/src/windows/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod error_window; -pub mod tray; diff --git a/server/src/windows/tray.rs b/server/src/windows/tray.rs deleted file mode 100644 index ef666e80..00000000 --- a/server/src/windows/tray.rs +++ /dev/null @@ -1,37 +0,0 @@ -use std::{path::PathBuf, process::exit, sync::Arc}; - -use anyhow::Result; -use tracing::error; -use tray_item::{IconSource, TrayItem}; - -use crate::config::Config; - -pub fn start_tray_icon( - log_dir: Option, - config_file: PathBuf, - config: Arc, -) -> Result { - let mut tray = TrayItem::new("MLM", IconSource::Resource("tray-icon"))?; - tray.add_label(&format!("MLM v{}", env!("CARGO_PKG_VERSION"))) - .unwrap(); - tray.add_menu_item("Open Web UI", move || { - if let Err(err) = open::that(format!("http://localhost:{}", config.web_port)) { - error!("Error opening web ui: {}", err); - } - })?; - tray.add_menu_item("Open Config File", move || { - if let Err(err) = open::that(&config_file) { - error!("Error opening config file: {}", err); - } - })?; - if let Some(log_dir) = log_dir { - tray.add_menu_item("Open Log Directory", move || { - if let Err(err) = open::that(&log_dir) { - error!("Error opening log directory: {}", err); - } - })?; - } - tray.inner_mut().add_separator()?; - tray.add_menu_item("Quit", || exit(0))?; - Ok(tray) -} diff --git a/server/templates/base.html b/server/templates/base.html deleted file mode 100644 index 8443c204..00000000 --- a/server/templates/base.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - {% block title %}{{ title }} - MLM{% endblock %} - - - - - {% block head %}{% endblock %} - - - -
- {% block content %}{% endblock %} -
- - diff --git a/server/templates/pages/config.html b/server/templates/pages/config.html deleted file mode 100644 index 1430e253..00000000 --- a/server/templates/pages/config.html +++ /dev/null @@ -1,206 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Config{% endblock %} - -{% block content %} -

Config

- -
-
- unsat_buffer = {{ config.unsat_buffer }}
- wedge_buffer = {{ config.wedge_buffer }}
- {% if config.add_torrents_stopped %} - add_torrents_stopped = {{ config.add_torrents_stopped }}
- {% endif %} - {% if config.exclude_narrator_in_library_dir %} - exclude_narrator_in_library_dir = {{ config.exclude_narrator_in_library_dir }}
- {% endif %} - search_interval = {{ config.search_interval }}
- link_interval = {{ config.link_interval }}
- import_interval = {{ config.import_interval }}
- {% if !config.ignore_torrents.is_empty() %} - ignore_torrents = {{ self::yaml_nums(config.ignore_torrents) }}
- {% endif %} - audio_types = {{ self::yaml_items(config.audio_types) }}
- ebook_types = {{ self::yaml_items(config.ebook_types) }}
- music_types = {{ self::yaml_items(config.music_types) }}
- radio_types = {{ self::yaml_items(config.radio_types) }}
-
-
- -{% for qbit in config.qbittorrent %} -
-
-
-

[[qbittorrent]]

-
- url = {{ qbit.url | json }}
- {% if !qbit.username.is_empty() %} - username = {{ qbit.username | json }}
- {% endif %} - {% if !qbit.password.is_empty() %} - password = "" # hidden
- {% endif %} - {% if let Some(on_cleaned) = qbit.on_cleaned %} -
-

[qbittorrent.on_cleaned]

-
- {% if let Some(category) = on_cleaned.category %} - category = {{ category | json }}
- {% endif %} - {% if !on_cleaned.tags.is_empty() %} - tags = {{ self::yaml_items(on_cleaned.tags) }}
- {% endif %} - {% endif %} -
-
-{% endfor %} - -{% for autograb in config.autograbs %} -
-
-
-

[[autograb]]

- search on MaM -
- {% if let Some(name) = autograb.filter.name %} - name = {{ name | json }}
- {% endif %} - type = {{ autograb.kind | json }}
- cost = {{ autograb.cost | json }}
- {% if let Some(query) = autograb.query %} - query = {{ query | json }}
- {% endif %} - {% if !autograb.search_in.is_empty() %} - search_in = {{ self::yaml_items(autograb.search_in) }}
- {% endif %} - {% if let Some(sort_by) = autograb.sort_by %} - sort_by = {{ sort_by | json }}
- {% endif %} - {{ self::filter(autograb.filter) }} - {% if let Some(search_interval) = autograb.search_interval %} - search_interval = {{ search_interval }}
- {% endif %} - {% if let Some(unsat_buffer) = autograb.unsat_buffer %} - unsat_buffer = {{ unsat_buffer }}
- {% endif %} - {% if let Some(max_active_downloads) = autograb.max_active_downloads %} - max_active_downloads = {{ max_active_downloads }}
- {% endif %} - {% if let Some(wedge_buffer) = autograb.wedge_buffer %} - wedge_buffer = {{ wedge_buffer }}
- {% endif %} - {% if autograb.dry_run %} - dry_run = {{ autograb.dry_run }}
- {% endif %} - {% if let Some(category) = autograb.category %} - category = {{ category | json }}
- {% endif %} -
-
-{% endfor %} - -{% for list in config.goodreads_lists %} -
-
-
-

[[goodreads_list]]

-
- {% if let Some(name) = list.name %} - name = {{ name | json }}
- {% endif %} - {% if let Some(search_interval) = list.search_interval %} - search_interval = {{ search_interval }}
- {% endif %} - {% if let Some(unsat_buffer) = list.unsat_buffer %} - unsat_buffer = {{ unsat_buffer }}
- {% endif %} - {% if let Some(wedge_buffer) = list.wedge_buffer %} - wedge_buffer = {{ wedge_buffer }}
- {% endif %} - {% if list.dry_run %} - dry_run = {{ list.dry_run }}
- {% endif %} - {% for grab in list.grab %} -
-
-
-

[[goodreads_list.grab]]

-
- cost = {{ grab.cost | json }}
- {{ self::filter(grab.filter) }} -
-
- {% endfor %} -
-
-{% endfor %} - -{% for (i, tag) in config.tags.iter().enumerate() %} -
-
-
-

[[tag]]

- {% if show_apply_tags %} - - - - - - {% endif %} -
- {% if let Some(name) = tag.filter.name %} - name = {{ name | json }}
- {% endif %} - {{ self::filter(tag.filter) }} - {% if let Some(category) = tag.category %} - category = {{ category | json }}
- {% endif %} - {% if !tag.tags.is_empty() %} - tags = {{ self::yaml_items(tag.tags) }}
- {% endif %} -
-
-{% endfor %} - -{% for library in config.libraries %} -
-
-
-

[[library]]

-
- {% if let Some(name) = library.tag_filters().name %} - name = {{ name | json }}
- {% endif %} - {% match library %} - {% when Library::ByDir(library) %} - download_dir = {{ library.download_dir | json }}
- library_dir = {{ library.library_dir | json }}
- {% when Library::ByCategory(library) %} - category = {{ library.category | json }}
- library_dir = {{ library.library_dir | json }}
- {% endmatch %} - {% if !library.tag_filters().allow_tags.is_empty() %} - allow_tags = {{ self::yaml_items(library.tag_filters().allow_tags) }}
- {% endif %} - {% if !library.tag_filters().deny_tags.is_empty() %} - deny_tags = {{ self::yaml_items(library.tag_filters().deny_tags) }}
- {% endif %} - {% if library.tag_filters().method != Default::default() %} - method = {{ library.tag_filters().method | json }}
- {% endif %} - {% if let Some(audio_types) = library.tag_filters().audio_types %} - {% if !audio_types.is_empty() %} - audio_types = {{ self::yaml_items(audio_types) }}
- {% endif %} - {% endif %} - {% if let Some(ebook_types) = library.tag_filters().ebook_types %} - {% if !ebook_types.is_empty() %} - ebook_types = {{ self::yaml_items(ebook_types) }}
- {% endif %} - {% endif %} -
-
-{% endfor %} - -{% endblock %} diff --git a/server/templates/pages/duplicate.html b/server/templates/pages/duplicate.html deleted file mode 100644 index a975adae..00000000 --- a/server/templates/pages/duplicate.html +++ /dev/null @@ -1,72 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Duplicate Torrents{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-
-

Duplicate Torrents

-
- - -
-
-

Torrents that the autograbber did not select due to a pre-existing torrent in your library

-
- {{ table_header_all_s("torrent") }} - {{ table_header(Some(DuplicatePageSort::Kind), "Type") }} - {{ table_header(Some(DuplicatePageSort::Title), "Title") }} - {{ table_header(Some(DuplicatePageSort::Authors), "Authors") }} - {{ table_header(Some(DuplicatePageSort::Narrators), "Narrators") }} - {{ table_header(Some(DuplicatePageSort::Series), "Series") }} - {{ table_header(Some(DuplicatePageSort::Size), "Size") }} - {{ table_header(None, "Filetypes") }} - {{ table_header(None, "Linked") }} - {{ table_header(Some(DuplicatePageSort::CreatedAt), "Added At") }} - {{ table_header(None, "") }} -{% for (torrent, duplicate_of) in torrents %} -
-
{{ item(DuplicatePageFilter::Kind, torrent.meta.media_type.as_str()) }}
-
{{ item(DuplicatePageFilter::Title, torrent.meta.title) }}
-
{{ items(DuplicatePageFilter::Author, torrent.meta.authors) }}
-
{{ items(DuplicatePageFilter::Narrator, torrent.meta.narrators) }}
-
{{ series(DuplicatePageFilter::Series, torrent.meta.series) }}
-
{{ torrent.meta.size }}
-
{{ items(DuplicatePageFilter::Filetype, torrent.meta.filetypes) }}
-
-
{{ self::time(torrent.created_at) }}
- -
-
duplicate of:
-
{{ item(DuplicatePageFilter::Title, duplicate_of.meta.title) }}
-
{{ items(DuplicatePageFilter::Author, duplicate_of.meta.authors) }}
-
{{ items(DuplicatePageFilter::Narrator, duplicate_of.meta.narrators) }}
-
{{ series(DuplicatePageFilter::Series, duplicate_of.meta.series) }}
-
{{ duplicate_of.meta.size }}
-
{{ items(DuplicatePageFilter::Filetype, duplicate_of.meta.filetypes) }}
-
- {{ duplicate_of.library_path.is_some().to_string() }} -
-
{{ self::time(duplicate_of.created_at) }}
-
- open - MaM - {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), duplicate_of.abs_id.as_ref()) %} - ABS - {% endif %} -
-{% endfor %} -{% if torrents.is_empty() %} -

There are currently no duplicate torrents -{% endif %} -

-
-{% endblock %} diff --git a/server/templates/pages/errors.html b/server/templates/pages/errors.html deleted file mode 100644 index fac81cfa..00000000 --- a/server/templates/pages/errors.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Errors{% endblock %} - -{% block head %} -{% endblock %} - -{% block content %} -

Torrent Errors

-

Errors encountered while either grabbing, linking or cleaning torrents

-
-
- -
-
-
- {{ table_header_all("error", 30) | safe }} - {{ table_header_s(Some(ErrorsPageSort::Step), "Step", 100) }} - {{ table_header_s(Some(ErrorsPageSort::Title), "Title", Flex(1, 170)) }} - {{ table_header_s(Some(ErrorsPageSort::Error), "Error", Flex(1, 170)) }} - {{ table_header_s(Some(ErrorsPageSort::CreatedAt), "When", 157) }} - {{ table_header_s(None, "", 88) }} -
- -{% for error in errors %} -
-
-
{{ item(ErrorsPageFilter::Step, self::step(error.id)) }}
-
{{ item(ErrorsPageFilter::Title, error.title) }}
-
{{ error.error }}
-
{{ self::time(error.created_at) }}
-
- {% match error.meta %} - {% when Some(meta) %} - open - MaM - {% when None %} - {% endmatch %} -
-
-{% endfor %} -{% if errors.is_empty() %} -

There are currently no errors -{% endif %} -

-
-{% endblock %} diff --git a/server/templates/pages/events.html b/server/templates/pages/events.html deleted file mode 100644 index 68b7c5a9..00000000 --- a/server/templates/pages/events.html +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Events{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-

Events

-
- Show: - - - - - - -
-
- Page size: {{ paging.selector([100, 500, 1000, 5000]) | safe }} -
-
- -
-{% for (event, torrent, replacement) in events %} -
{{ self::time(event.created_at) }}
-
- {% match event.event %} - {% when EventType::Grabbed { grabber, cost, wedged } %} - Grabbed {{ torrent_media_type(&torrent) }} Torrent {{ torrent_title(&torrent) | safe }} - {% if wedged %} using a wedge - {% else %}{% match cost %} - {% when Some(TorrentCost::Vip) %} as VIP - {% when Some(TorrentCost::GlobalFreeleech) %} as Freeleech - {% when Some(TorrentCost::PersonalFreeleech) %} as Personal Freeleech - {% when Some(_) %} using ratio - {% when _ %}{% endmatch %}{% endif %} - {% if let Some(grabber) = grabber %} - with grabber {{ grabber }} - {% endif %} -
- {% when EventType::Linked { linker, library_path } %} - Linked {{ torrent_media_type(&torrent) }} Torrent {{ torrent_title(&torrent) | safe }} - {% if let Some(linker) = linker %} - with linker {{ linker }} - {% endif %} -
- to: {{ library_path.to_string_lossy() }}
- {% if let Some(torrent) = torrent %} -
- Files -
    - {% for file in torrent.library_files.iter().flatten() %} -
  • {{ file.to_string_lossy() }}
  • - {% endfor %} -
-
- {% endif %} - {% when EventType::Cleaned { library_path, files } %} - Cleaned {{ torrent_media_type(&torrent) }} Torrent {{ torrent_title(&torrent) | safe }}
- {% if let Some(torrent) = torrent %} - size: {{ torrent.meta.size }}
- formats: {{ torrent.meta.filetypes.join(", ") }}
- {% endif %} - from: {{ library_path.to_string_lossy() }}
- {% if let Some(r) = replacement %} -
replaced with: {{ torrent_title(&replacement) | safe }}
- {% if let Some(replacement) = replacement %} - size: {{ replacement.meta.size }}
- formats: {{ replacement.meta.filetypes.join(", ") }}
- {% endif %} - {% if let Some(library_path) = r.library_path %} - in: {{ library_path.to_string_lossy() }}
- {% endif %} - {% endif %}
-
- Removed files -
    - {% for file in files %} -
  • {{ file.to_string_lossy() }}
  • - {% endfor %} -
-
- {% when EventType::Updated { fields } %} - Updated {{ torrent_media_type(&torrent) }} Torrent {{ torrent_title(&torrent) | safe }}
-
    - {% for field in fields %} -
  • {{ field.field }}: {{ field.from }} → {{ field.to }}
  • - {% endfor %} -
- {% when EventType::RemovedFromMam %} - {{ torrent_media_type(&torrent) }} Torrent {{ torrent_title(&torrent) | safe }} was removed from MaM
- {% endmatch %} -
-{% endfor %} -{% if events.is_empty() %} -

No events yet -{% endif %} -

-{{ paging | safe }} -{% endblock %} diff --git a/server/templates/pages/index.html b/server/templates/pages/index.html deleted file mode 100644 index 3f915bf4..00000000 --- a/server/templates/pages/index.html +++ /dev/null @@ -1,112 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM{% endblock %} - -{% block head %} -{% endblock %} - -{% block content %} -

{% if let Some(username) = username %}Hi {{ username }}! {% endif %}Welcome to MLM, select a page above

- -{% if let Some(mam_error) = mam_error %} -

-mam_id is invalid, all features are disabled: {{ mam_error }} -

-{% endif %} -{% if has_no_qbits %} -

-no qbittorrent instances configured, all features are disabled -

-{% endif %} - -
-{% for (i, grab) in config.autograbs.iter().enumerate() %} -
-

Autograbber: {{ grab.filter.display_name(*i) }}

-

Last run: {% match autograbber_run_at.get(i) %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - - {% if autograbber_run_at.get(i).is_some() %} -

Result: {% match autograbber_result.get(i) %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

-{% endfor %} -{% for (i, grab) in config.snatchlist.iter().enumerate() %} -
-

Snatchlist Grabber: {{ grab.filter.display_name(i + config.autograbs.len()) }}

-

Last run: {% match autograbber_run_at.get(&(i + config.autograbs.len())) %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - - {% if autograbber_run_at.get(&(i + config.autograbs.len())).is_some() %} -

Result: {% match autograbber_result.get(&(i + config.autograbs.len())) %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

-{% endfor %} -
- -
- {% for (i, list) in lists.iter().enumerate() %} -
-

{{ list.list_type() }} Import: {{ list.display_name(*i) }}

-

Last run: {% match import_run_at.get(i) %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - - {% if import_run_at.get(i).is_some() %} -

Result: {% match import_result.get(i) %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

-{% endfor %} -
- -
-
-

Linker

-

Last run: {% match linker_run_at %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - {% if linker_run_at.is_some() %} -

Result: {% match linker_result %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

- -
-

Cleaner

-

Last run: {% match cleaner_run_at %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - {% if cleaner_run_at.is_some() %} -

Result: {% match cleaner_result %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

- -
-

Torrent downloader

-

Last run: {% match downloader_run_at %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - {% if downloader_run_at.is_some() %} -

Result: {% match downloader_result %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

- -
-

Audiobookshelf Matcher

-

Last run: {% match audiobookshelf_run_at %}{% when Some(run_at) %}{{ self::time(run_at) }}{% when None %}never{% endmatch %} - - {% if audiobookshelf_run_at.is_some() %} -

Result: {% match audiobookshelf_result %}{% when Some(Ok(())) %}success{% when Some(Err(err)) %}{{ err }}{% when None %}running{% endmatch %} - {% endif %} -

-
- -
-

🏳️‍⚧️ Trans Rights are Human Rights

- - -{% endblock %} diff --git a/server/templates/pages/list.html b/server/templates/pages/list.html deleted file mode 100644 index 346e0bf0..00000000 --- a/server/templates/pages/list.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - {{ list.title }}{% endblock %} - -{% block content %} -
-

{{ list.title }}

-
- Show: - - - - -
-
- -{% for item in items %} -
- - -
-
-

{{ item.title }}

-
- search on MaM - {% if let Some(url) = item.book_url %}goodreads{% endif %} -
-
-

by {{ item.authors.join(", ") }}

- {% if !item.series.is_empty() %} -

{% for (name, num) in item.series %} - {{ name }} #{{num}}{% if !loop.last %}, {% endif %} - {% endfor %}

- {% endif %} - {% if let Some(torrent) = item.audio_torrent %} - {% match torrent.status %} - {% when TorrentStatus::Selected %} - Downloaded audiobook torrent at {{ self::time(torrent.at) }}
- {% when TorrentStatus::Wanted %} - Suggest wedge audiobook torrent at {{ self::time(torrent.at) }}
- {% when TorrentStatus::NotWanted %} - Skipped audiobook torrent as an ebook was found at {{ self::time(torrent.at) }}
- {% when TorrentStatus::Existing %} - Found matching audiobook torrent in library at {{ self::time(torrent.at) }}
- {% endmatch %} - {% elif item.want_audio() %} - Audiobook missing
- {% endif %} - {% if let Some(torrent) = item.ebook_torrent %} - {% match torrent.status %} - {% when TorrentStatus::Selected %} - Downloaded ebook torrent at {{ self::time(torrent.at) }}
- {% when TorrentStatus::Wanted %} - Suggest wedge ebook torrent at {{ self::time(torrent.at) }}
- {% when TorrentStatus::NotWanted %} - Skipped ebook torrent as an ebook was found at {{ self::time(torrent.at) }}
- {% when TorrentStatus::Existing %} - Found matching ebook torrent in library at {{ self::time(torrent.at) }}
- {% endmatch %} - {% elif item.want_ebook() %} - Ebook missing
- {% endif %} - {% if item.want_audio() || item.want_ebook() %} - - {% endif %} -
-
-{% endfor %} -{% if items.is_empty() %} -

The list is empty -{% endif %} -{% endblock %} diff --git a/server/templates/pages/lists.html b/server/templates/pages/lists.html deleted file mode 100644 index aed03a71..00000000 --- a/server/templates/pages/lists.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Goodreads lists{% endblock %} - -{% block content %} -

Goodreads Lists

-

Goodreads lists can be used to autograb want to read books

- -{% for (config, list) in lists %} -
-

{{ config.name.as_deref().unwrap_or(list.title) }}

- Last updated: {% if let Some(updated_at) = list.updated_at %}{{ self::time(updated_at) }}{% else %}never{% endif %} -
-{% endfor %} -{% if !inactive_lists.is_empty() %} -

Inactive Lists

-

Lists that have been removed from the config but are still in the database. They won't be refreshed or have books searched for at MaM.

-{% for list in inactive_lists %} -
-

{{ list.title }}

- Last updated: {% if let Some(updated_at) = list.updated_at %}{{ self::time(updated_at) }}{% else %}never{% endif %} -
-{% endfor %} -{% endif %} -{% if lists.is_empty() %} -

You have no Goodreads lists -{% endif %} -{% endblock %} diff --git a/server/templates/pages/replaced.html b/server/templates/pages/replaced.html deleted file mode 100644 index 00fb307d..00000000 --- a/server/templates/pages/replaced.html +++ /dev/null @@ -1,152 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Replaced Torrents{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -

-
-

Replaced Torrents

-
- - - -
-
-
- Columns: - - - - - - -
-
- Page size: {{ paging.selector([100, 500, 1000, 5000]) | safe }} -
-
-
-

Torrents that have been unlinked from the library and been replaced with a preferred version

-
-
- {{ table_header_all("torrent", 30) }} - {{ table_header_s(Some(TorrentsPageSort::Kind), "Type", 110) }} - {{ table_header_s(Some(TorrentsPageSort::Title), "Title", Flex(2, 170)) }} - {{ table_header_if(show.authors, Some(TorrentsPageSort::Authors), "Authors", Flex(1, 130)) }} - {{ table_header_if(show.narrators, Some(TorrentsPageSort::Narrators), "Narrators", Flex(1, 130)) }} - {{ table_header_if(show.series, Some(TorrentsPageSort::Series), "Series", Flex(1, 130)) }} - {{ table_header_if(show.language, Some(TorrentsPageSort::Language), "Language", 100) }} - {{ table_header_if(show.size, Some(TorrentsPageSort::Size), "Size", 80) }} - {{ table_header_if(show.filetypes, None, "Filetypes", 100) }} - {{ table_header_s(Some(TorrentsPageSort::Replaced), "Replaced", 157) }} - {{ table_header_s(Some(TorrentsPageSort::CreatedAt), "Added At", 157) }} - {{ table_header_s(None, "", 132) }} -
-{% for (torrent, replacement) in torrents %} -
-
-
{{ item(TorrentsPageFilter::Kind, torrent.meta.media_type.as_str()) }}
-
{{ item(TorrentsPageFilter::Title, torrent.meta.title) }}
- {% if show.authors %} -
{{ items(TorrentsPageFilter::Author, torrent.meta.authors) }}
- {% endif %} - {% if show.narrators %} -
{{ items(TorrentsPageFilter::Narrator, torrent.meta.narrators) }}
- {% endif %} - {% if show.series %} -
{{ series(TorrentsPageFilter::Series, torrent.meta.series) }}
- {% endif %} - {% if show.language %} -
{{ item(TorrentsPageFilter::Language, torrent.meta.language.map(Language::to_str).unwrap_or_default()) }}
- {% endif %} - {% if show.size %} -
{{ torrent.meta.size }}
- {% endif %} - {% if show.filetypes %} -
{{ items(TorrentsPageFilter::Filetype, torrent.meta.filetypes) }}
- {% endif %} -
- {% if let Some((_, replaced_at)) = torrent.replaced_with %}{{ self::time(replaced_at) }}{% endif %} -
-
{{ self::time(torrent.created_at) }}
-
- open - MaM - {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), torrent.abs_id.as_ref()) %} - ABS - {% endif %} -
- -
-
replaced with:
-
{{ item(TorrentsPageFilter::Title, replacement.meta.title) }}
- {% if show.authors %} -
{{ items(TorrentsPageFilter::Author, replacement.meta.authors) }}
- {% endif %} - {% if show.narrators %} -
{{ items(TorrentsPageFilter::Narrator, replacement.meta.narrators) }}
- {% endif %} - {% if show.series %} -
{{ series(TorrentsPageFilter::Series, replacement.meta.series) }}
- {% endif %} - {% if show.language %} -
{{ item(TorrentsPageFilter::Language, replacement.meta.language.map(Language::to_str).unwrap_or_default()) }}
- {% endif %} - {% if show.size %} -
{{ replacement.meta.size }}
- {% endif %} - {% if show.filetypes %} -
{{ items(TorrentsPageFilter::Filetype, replacement.meta.filetypes) }}
- {% endif %} -
- {% if let Some((_, replaced_at)) = replacement.replaced_with %}{{ self::time(replaced_at) }}{% endif %} -
-
{{ self::time(replacement.created_at) }}
-
- open - MaM - {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), replacement.abs_id.as_ref()) %} - ABS - {% endif %} -
-
-{% endfor %} -
-{% if torrents.is_empty() %} -

You have no replaced torrents -{% endif %} -

-{{ paging | safe }} - -{% endblock %} diff --git a/server/templates/pages/search.html b/server/templates/pages/search.html deleted file mode 100644 index 9e09cc0c..00000000 --- a/server/templates/pages/search.html +++ /dev/null @@ -1,34 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Search{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-

MaM Search

- -
-{{ torrents }} -{% endblock %} diff --git a/server/templates/pages/selected.html b/server/templates/pages/selected.html deleted file mode 100644 index f249521d..00000000 --- a/server/templates/pages/selected.html +++ /dev/null @@ -1,179 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Selected Torrents{% endblock %} - -{% block head %} -{% endblock %} - -{% block content %} -
-
-

Selected Torrents

-
- - -
-
-
- Columns: -
- - - - - - - - - - - - -
-
-
-
-

Torrents that the autograbber has selected and will be downloaded

-{% if let Some(user_info) = user_info %} -

- {% if let Some(remaining_buffer) = remaining_buffer %} - Buffer: {{ remaining_buffer }}
- {% endif %} - Unsats: {{ user_info.unsat.count }} / {{ user_info.unsat.limit }}
- Wedges: {{ user_info.wedges }}
- Bonus: {{ user_info.seedbonus }}
- {% if !torrents.is_empty() %} -
Queued Torrents: {{ queued() }} -
Downloading Torrents: {{ downloading() }} - {% endif %} -

-{% endif %} -
-
- {{ table_header_all("torrent", 30) }} - {% if show.category %} - {{ table_header_s(Some(SelectedPageSort::Kind), "Type", 130) }} - {% else %} - {{ table_header_s(Some(SelectedPageSort::Kind), "Type", 84) }} - {% endif %} - {{ table_header_if(show.flags, None, "Flags", 60) }} - {{ table_header_s(Some(SelectedPageSort::Title), "Title", Flex(2, 170)) }} - {{ table_header_if(show.authors, Some(SelectedPageSort::Authors), "Authors", Flex(1, 130)) }} - {{ table_header_if(show.narrators, Some(SelectedPageSort::Narrators), "Narrators", Flex(1, 130)) }} - {{ table_header_if(show.series, Some(SelectedPageSort::Series), "Series", Flex(1, 130)) }} - {{ table_header_if(show.language, Some(SelectedPageSort::Language), "Language", 100) }} - {{ table_header_if(show.size, Some(SelectedPageSort::Size), "Size", 80) }} - {{ table_header_if(show.filetypes, None, "Filetypes", 100) }} - {{ table_header_s(Some(SelectedPageSort::Cost), "Cost", 80) }} - {{ table_header_s(Some(SelectedPageSort::Buffer), "Required Unsats", 80) }} - {{ table_header_if(show.grabber, Some(SelectedPageSort::Grabber), "Grabber", 130) }} - {{ table_header_if(show.created_at, Some(SelectedPageSort::CreatedAt), "Added At", 157) }} - {{ table_header_if(show.started_at, Some(SelectedPageSort::StartedAt), "Started At", 157) }} - {{ table_header_if(show.removed_at, None, "Removed At", 157) }} - {{ table_header_s(None, "", 44) }} -
- -{% for torrent in torrents %} -
-
-
- {{ item(SelectedPageFilter::Kind, torrent.meta.media_type.as_str()) }} - {% if show.category %} - {% if let Some(cat) = torrent.meta.cat %} -
{{ item_v(SelectedPageFilter::Category, torrent.meta.cat_name(), &cat.as_id().to_string()) }}
- {% endif %} - {% endif %} -
- {% if show.flags %} -
{{ self::flag_icons(torrent.meta) }}
- {% endif %} -
{{ item(SelectedPageFilter::Title, torrent.meta.title) }}
- {% if show.authors %} -
{{ items(SelectedPageFilter::Author, torrent.meta.authors) }}
- {% endif %} - {% if show.narrators %} -
{{ items(SelectedPageFilter::Narrator, torrent.meta.narrators) }}
- {% endif %} - {% if show.series %} -
{{ series(SelectedPageFilter::Series, torrent.meta.series) }}
- {% endif %} - {% if show.language %} -
{{ item(SelectedPageFilter::Language, torrent.meta.language.map(Language::to_str).unwrap_or_default()) }}
- {% endif %} - {% if show.size %} -
{{ torrent.meta.size }}
- {% endif %} - {% if show.filetypes %} -
{{ items(SelectedPageFilter::Filetype, torrent.meta.filetypes) }}
- {% endif %} -
{{ item(SelectedPageFilter::Cost, torrent.cost.as_str()) }}
-
{{ torrent.unsat_buffer.unwrap_or(*unsat_buffer) }}
- {% if show.grabber %} -
{{ item(SelectedPageFilter::Grabber, &torrent.grabber.clone().unwrap_or_default()) }}
- {% endif %} - {% if show.created_at %} -
{{ self::time(torrent.created_at) }}
- {% endif %} - {% if show.started_at %} -
- {% if let Some(started_at) = torrent.started_at %} - {{ self::time(started_at) }} - {% endif %} -
- {% endif %} - {% if show.removed_at %} -
- {% if let Some(removed_at) = torrent.removed_at %} - {{ self::time(removed_at) }} - {% endif %} -
- {% endif %} - -
-{% endfor %} -{% if torrents.is_empty() %} -

There are currently no torrents selected for downloading -{% endif %} -

-
-{% endblock %} diff --git a/server/templates/pages/torrent.html b/server/templates/pages/torrent.html deleted file mode 100644 index 9d1aa874..00000000 --- a/server/templates/pages/torrent.html +++ /dev/null @@ -1,295 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - {{ torrent.meta.title }}{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-

{{ torrent.meta.title }}

- edit -
-{% if let Some((edition, _)) = torrent.meta.edition %} - {{ edition }} -{% endif %} -{% if let Some(torrent) = replacement_torrent %} -
-

Replaced with: {{ torrent.meta.title }}

-
-{% else if torrent.replaced_with.is_some() %} -
- Replaced with torrent that has been removed - -
-{% endif %} -
-
- id: {{ torrent.mam_id }} {% if let Some(vip_status) = torrent.meta.vip_status %}{{ vip_status }}{% endif %} - {% if let Some(book) = book %} - Open in ABS - {% endif %} - {% if let Some(mam_torrent) = mam_torrent %} - Open in MaM - {% endif %} -
-
- -
-
-

By {{ items(TorrentsPageFilter::Author, torrent.meta.authors) }}

-{% if !torrent.meta.series.is_empty() %} -

Series: {{ series(TorrentsPageFilter::Series, torrent.meta.series) }}

-{% endif %} -{% if !torrent.meta.narrators.is_empty() %} -

Narrator: {{ items(TorrentsPageFilter::Narrator, torrent.meta.narrators) }}

-{% endif %} -{% if let Some(language) = torrent.meta.language %} -

Language: {{ language.to_str() }}

-{% endif %} -{% if let Some(cat) = torrent.meta.cat %} -

Category: {{ torrent.meta.media_type.as_str() }} - {{ cat.as_str() }} {{ self::flag_icons(torrent.meta) }}

-{% else %} -

Category: {{ torrent.meta.media_type.as_str() }} {{ self::flag_icons(torrent.meta) }}

-{% endif %} -{% if !torrent.meta.categories.is_empty() %} -

Categories: {{ torrent.meta.categories.iter().join(", ") }}

-{% endif %} -

Size: {{ torrent.meta.size }} {{ torrent.meta.filetypes.join(", ") }} Files: {{ torrent.meta.num_files }}

-{% if let Some(mam_torrent) = mam_torrent %} -

Uploader: {{ mam_torrent.owner_name }}

-{% endif %} -

Uploaded At: {{ self::time(torrent.meta.uploaded_at) }}

-{% if let Some(library_path) = torrent.library_path %} -
- Linked Path: {{ library_path.to_string_lossy() }} -
- - -
-
-{% endif %} -{% if wanted_path != torrent.library_path %} - {% if let Some(wanted_path) = wanted_path %} -
- Wanted Path: {{ wanted_path.to_string_lossy() }} -
- {% else %} -
- No longer wanted in library -
- {% endif %} -{% endif %} -{% if !torrent.library_files.is_empty() %} -
- Files - -
-{% endif %} - -{% if let Some(qbit) = qbit_data %} -
- Status: {{ "{:#?}" | format(qbit.torrent.state) }} - {% match qbit.torrent.state %} - {% when TorrentState::Error %} - {% when TorrentState::MissingFiles %} - {% when TorrentState::Moving %} - {% when TorrentState::Unknown %} - {% when TorrentState::Allocating %} - {% when TorrentState::CheckingResumeData %} - {% when TorrentState::Uploading %} - - {% when TorrentState::StoppedUploading %} - - {% when TorrentState::QueuedUploading %} - - {% when TorrentState::StalledUploading %} - - {% when TorrentState::CheckingUploading %} - {% when TorrentState::ForcedUploading %} - - {% when TorrentState::Downloading %} - - {% when TorrentState::MetadataDownloading %} - - {% when TorrentState::ForcedMetadataDownloading %} - - {% when TorrentState::StoppedDownloading %} - - {% when TorrentState::QueuedDownloading %} - - {% when TorrentState::StalledDownloading %} - - {% when TorrentState::CheckingDownloading %} - {% when TorrentState::ForcedDownloading %} - - {% endmatch %} -
-
- Category: - Tags:
- {% for tag in qbit.tags %} - - {% endfor %} -
- -
-

Uploaded: {{ Size::from_bytes(*(&qbit.torrent.uploaded as u64)) }}

- {% if let Some(tracker) = qbit.trackers.last() %} -
- {% if !tracker.msg.is_empty() %} - Tracker Msg: {{ tracker.msg }} - {% if tracker.msg == "torrent not registered with this tracker" %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} -
- {% endif %} - {% if !qbit_files.is_empty() %} -
- Files - -
- {% endif %} -{% endif %} -{% if let Some(book) = book %} - {# {% if !book.media.chapters.is_empty() %} #} - {#
#} - {# Chapters #} - {# #} - {# {% for chapter in book.media.chapters %} #} - {# #} - {# {% endfor %} #} - {#
{{ chapter.title }}{{ self::duration(chapter.end - chapter.start) }}
#} - {#
#} - {# {% endif %} #} -{% endif %} - -{% if let Some(mam_torrent) = mam_torrent %} -

{{ mam_torrent.tags }}

-
- Description - {% if let Some(description) = mam_torrent.description %} -

{{ description | safe }}

- {% endif %} -
-{% endif %} - -{% if let Some(mam_meta) = mam_meta %} -{% if &torrent.meta != mam_meta %} - {% for field in torrent.meta.diff(&mam_meta) %} -
New {{ field.field }}: {{ field.to }}
- {% endfor %} -
- -
-{% endif %} -{% endif %} - -
- Events - -
- {% for event in events %} -
{{ self::time(event.created_at) }}
-
- {% match event.event %} - {% when EventType::Grabbed { grabber, cost, wedged } %} - Grabbed Torrent - {% if wedged %} using a wedge - {% else %}{% match cost %} - {% when Some(TorrentCost::Vip) %} as VIP - {% when Some(TorrentCost::GlobalFreeleech) %} as Freeleech - {% when Some(TorrentCost::PersonalFreeleech) %} as Personal Freeleech - {% when Some(_) %} using ratio - {% when _ %}{% endmatch %}{% endif %} - {% if let Some(grabber) = grabber %} - with grabber {{ grabber }} - {% endif %} -
- {% when EventType::Linked { linker, library_path } %} - Linked Torrent - {% if let Some(linker) = linker %} - with linker {{ linker }} - {% endif %} -
- to: {{ library_path.to_string_lossy() }}
-
- Files -
    - {% for file in torrent.library_files.iter().flatten() %} -
  • {{ file.to_string_lossy() }}
  • - {% endfor %} -
-
- {% when EventType::Cleaned { library_path, files } %} - Cleaned Torrent
- size: {{ torrent.meta.size }}
- formats: {{ torrent.meta.filetypes.join(", ") }}
- from: {{ library_path.to_string_lossy() }}
- {% if let Some(replacement) = replacement_torrent %} -
replaced with: {{ torrent_title(&replacement_torrent) | safe }}
- size: {{ replacement.meta.size }}
- formats: {{ replacement.meta.filetypes.join(", ") }}
- {% if let Some(library_path) = replacement.library_path %} - in: {{ library_path.to_string_lossy() }}
- {% endif %} - {% endif %}
-
- Removed files -
    - {% for file in files %} -
  • {{ file.to_string_lossy() }}
  • - {% endfor %} -
-
- - {% when EventType::Updated { fields } %} - Updated Torrent
-
    - {% for field in fields %} -
  • {{ field.field }}: {{ field.from }} -> {{ field.to }}
  • - {% endfor %} -
- {% when EventType::RemovedFromMam %} - Torrent was removed from MaM
- {% endmatch %} -
- {% endfor %} - {% if events.is_empty() %} -

No events yet - {% endif %} -

-
- -

Other Torrents

-{{ other_torrents }} -{% if other_torrents.torrents.is_empty() %} -

No other torrents for this book was found -{% endif %} -{% endblock %} diff --git a/server/templates/pages/torrent_edit.html b/server/templates/pages/torrent_edit.html deleted file mode 100644 index b1e8e830..00000000 --- a/server/templates/pages/torrent_edit.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Edit- {{ torrent.meta.title }}{% endblock %} - -{% block head %} -{% endblock %} - -{% block content %} -

-

{{ torrent.meta.title }}

-
-
- - - - - - -
- Flags - - - - - - -
- -
-{% endblock %} diff --git a/server/templates/pages/torrent_mam.html b/server/templates/pages/torrent_mam.html deleted file mode 100644 index 3324938d..00000000 --- a/server/templates/pages/torrent_mam.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - {{ meta.title }}{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-

{{ meta.title }}

-
-{% if let Some((edition, _)) = meta.edition %} - {{ edition }} -{% endif %} -
-
- id: {{ meta.mam_id }} {% if let Some(vip_status) = meta.vip_status %}{{ vip_status }}{% endif %} - Open in MaM -
-
- -
-
-

By {{ items(TorrentsPageFilter::Author, meta.authors) }}

-{% if !meta.series.is_empty() %} -

Series: {{ series(TorrentsPageFilter::Series, meta.series) }}

-{% endif %} -{% if !meta.narrators.is_empty() %} -

Narrator: {{ items(TorrentsPageFilter::Narrator, meta.narrators) }}

-{% endif %} -{% if let Some(language) = meta.language %} -

Language: {{ language.to_str() }}

-{% endif %} -{% if let Some(cat) = meta.cat %} -

Category: {{ meta.media_type.as_str() }} - {{ cat.as_str() }} {{ self::flag_icons(meta) }}

-{% else %} -

Category: {{ meta.media_type.as_str() }} {{ self::flag_icons(meta) }}

-{% endif %} -{% if !meta.categories.is_empty() %} -

Categories: {{ meta.categories.iter().join(", ") }}

-{% endif %} -
-

- Size: {{ meta.size }} {{ meta.filetypes.join(", ") }} Files: {{ meta.num_files }} - {% if mam_torrent.vip %} - {% else if mam_torrent.personal_freeleech %} - {% else if mam_torrent.free %} - {% else %} - - - {% endif %} -

-
-

Uploader: {{ mam_torrent.owner_name }}

-

Uploaded At: {{ self::time(meta.uploaded_at) }}

- -

{{ mam_torrent.tags }}

-
- Description - {% if let Some(description) = mam_torrent.description %} -

{{ description | safe }}

- {% endif %} -
- -

Other Torrents

-{{ other_torrents }} -{% endblock %} diff --git a/server/templates/pages/torrents.html b/server/templates/pages/torrents.html deleted file mode 100644 index facaf17a..00000000 --- a/server/templates/pages/torrents.html +++ /dev/null @@ -1,249 +0,0 @@ -{% extends "base.html" %} - -{% block title %}MLM - Torrents{% endblock %} - -{% block head %} - -{% endblock %} - -{% block content %} -
-

Torrents

- -
-
- Columns: -
- - - - - - - - - - - - - - - -
-
-
- Page size: {{ paging.selector([100, 500, 1000, 5000]) | safe }} -
-
-
-
-
- - - - -
-
-
- {{ table_header_all("torrent", 30) | safe }} - {% if show.category %} - {{ table_header_s(Some(TorrentsPageSort::Kind), "Type", 130) }} - {% else %} - {{ table_header_s(Some(TorrentsPageSort::Kind), "Type", 89) }} - {% endif %} - {{ table_header_if(show.categories, None, "Categories", Flex(1, 130)) }} - {{ table_header_if(show.flags, None, "Flags", 60) }} - {{ table_header_s(Some(TorrentsPageSort::Title), "Title", Flex(2, 170)) }} - {{ table_header_if(show.edition, Some(TorrentsPageSort::Edition), "Edition", 80) }} - {{ table_header_if(show.authors, Some(TorrentsPageSort::Authors), "Authors", Flex(1, 130)) }} - {{ table_header_if(show.narrators, Some(TorrentsPageSort::Narrators), "Narrators", Flex(1, 130)) }} - {{ table_header_if(show.series, Some(TorrentsPageSort::Series), "Series", Flex(1, 130)) }} - {{ table_header_if(show.language, Some(TorrentsPageSort::Language), "Language", 100) }} - {{ table_header_if(show.size, Some(TorrentsPageSort::Size), "Size", 81) }} - {{ table_header_if(show.filetypes, None, "Filetypes", 100) }} - {{ table_header_if(show.linker, Some(TorrentsPageSort::Linker), "Linker", 130) }} - {{ table_header_if(show.qbit_category, Some(TorrentsPageSort::QbitCategory), "Qbit Category", 100) }} - {{ table_header_if(!show.path, Some(TorrentsPageSort::Linked), "Linked", 72) }} - {{ table_header_if(show.path, Some(TorrentsPageSort::Linked), "Path", Flex(2, 200)) }} - {{ table_header_if(show.created_at, Some(TorrentsPageSort::CreatedAt), "Added At", 157) }} - {{ table_header_if(show.uploaded_at, Some(TorrentsPageSort::UploadedAt), "Uploaded At", 157) }} - {{ table_header_s(None, "", 132) }} -
-{% for torrent in torrents %} -
-
-
- {{ item(TorrentsPageFilter::Kind, torrent.meta.media_type.as_str()) }} - {% if show.category %} - {% if let Some(cat) = torrent.meta.cat %} -
{{ item_v(TorrentsPageFilter::Category, torrent.meta.cat_name(), &cat.as_id().to_string()) }}
- {% endif %} - {% endif %} -
- {% if show.categories %} -
{{ cats(TorrentsPageFilter::Categories, torrent.meta.categories) }}
- {% endif %} - {% if show.flags %} -
{{ self::flag_icons(torrent.meta) }}
- {% endif %} -
- {{ item(TorrentsPageFilter::Title, torrent.meta.title) }} - {% match torrent.client_status %} - {% when Some(ClientStatus::RemovedFromMam) %} - - {{ item_v(TorrentsPageFilter::ClientStatus, "⚠", "removed_from_mam") }} - - {% when Some(ClientStatus::NotInClient) %} - - {{ item_v(TorrentsPageFilter::ClientStatus, "ℹ", "not_in_client") }} - - {% when None %} - {% endmatch %} -
- {% if show.edition %} -
{% if let Some((edition, _)) = torrent.meta.edition %}{{ edition }}{% endif %}
- {% endif %} - {% if show.authors %} -
{{ items(TorrentsPageFilter::Author, torrent.meta.authors) }}
- {% endif %} - {% if show.narrators %} -
{{ items(TorrentsPageFilter::Narrator, torrent.meta.narrators) }}
- {% endif %} - {% if show.series %} -
{{ series(TorrentsPageFilter::Series, torrent.meta.series) }}
- {% endif %} - {% if show.language %} -
{{ item(TorrentsPageFilter::Language, torrent.meta.language.map(Language::to_str).unwrap_or_default()) }}
- {% endif %} - {% if show.size %} -
{{ torrent.meta.size }}
- {% endif %} - {% if show.filetypes %} -
{{ items(TorrentsPageFilter::Filetype, torrent.meta.filetypes) }}
- {% endif %} - {% if show.linker %} -
{{ item(TorrentsPageFilter::Linker, &torrent.linker.clone().unwrap_or_default()) }}
- {% endif %} - {% if show.qbit_category %} -
{{ item(TorrentsPageFilter::QbitCategory, &torrent.category.clone().unwrap_or_default()) }}
- {% endif %} - {% if show.path %} -
- {% match torrent.library_path %} - {% when Some(path) %} - {{ path.to_string_lossy() }} - {% when None %} - {% endmatch %} - {% match torrent.library_mismatch %} - {% when Some(LibraryMismatch::NewLibraryDir(path)) %} - - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "new_library") }} - - {% when Some(LibraryMismatch::NewPath(path)) %} - - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "new_path") }} - - {% when Some(LibraryMismatch::NoLibrary) %} - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "no_library") }} - {% when None %} - {% endmatch %} -
- {% else %} -
- {{ item(TorrentsPageFilter::Linked, &torrent.library_path.is_some().to_string()) }} - {% match torrent.library_mismatch %} - {% when Some(LibraryMismatch::NewLibraryDir(path)) %} - - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "new_library") }} - - {% when Some(LibraryMismatch::NewPath(path)) %} - - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "new_path") }} - - {% when Some(LibraryMismatch::NoLibrary) %} - {{ item_v(TorrentsPageFilter::LibraryMismatch, "⚠", "no_library") }} - {% when None %} - {% endmatch %} -
- {% endif %} - {% if show.created_at %} -
{{ self::time(torrent.created_at) }}
- {% endif %} - {% if show.uploaded_at %} -
{{ self::time(torrent.meta.uploaded_at) }}
- {% endif %} -
- open - MaM - {% if let (Some(abs_url), Some(abs_id)) = (abs_url.as_ref(), torrent.abs_id.as_ref()) %} - ABS - {% endif %} -
-
-{% endfor %} -
-{% if torrents.is_empty() %} -

You have no torrents selected by MLM -{% endif %} -

-{{ paging | safe }} - -{% endblock %} diff --git a/server/templates/partials/cost_icon.html b/server/templates/partials/cost_icon.html deleted file mode 100644 index 942de00d..00000000 --- a/server/templates/partials/cost_icon.html +++ /dev/null @@ -1,11 +0,0 @@ -{% if mam_torrent.personal_freeleech %} -Personal Freeleech -{% elif mam_torrent.vip %} -{% if mam_torrent.vip_expire > 0 %} -VIP (expires {{ self::vip_expire(mam_torrent) }}) -{% else %} -VIP -{% endif %} -{% elif mam_torrent.free %} -Freeleech -{% endif %} diff --git a/server/templates/partials/filter.html b/server/templates/partials/filter.html deleted file mode 100644 index ab31b9a2..00000000 --- a/server/templates/partials/filter.html +++ /dev/null @@ -1,108 +0,0 @@ -{% if filter.categories.audio.is_some() || filter.categories.ebook.is_some() %} - categories = - {
-{% if let Some(cats) = filter.categories.audio %} - {% if cats.is_empty() %} -   audio = false
- {% elif cats == &AudiobookCategory::all() %} -   audio = true
- {% else %} -   audio = {{ self::yaml_items(cats) }}
- {% endif %} -{% else %} -   audio = true
-{% endif %} -{% if let Some(cats) = filter.categories.ebook %} - {% if cats.is_empty() %} -   ebook = false
- {% elif cats == &EbookCategory::all() %} -   ebook = true
- {% else %} -   ebook = {{ self::yaml_items(cats) }}
- {% endif %} -{% else %} -   ebook = true
-{% endif %} - }
- {% else %} -{% endif %} -{% if !filter.languages.is_empty() %} -languages = {{ self::yaml_items(filter.languages) }}
-{% endif %} -{% if filter.flags.as_bitfield() > 0 %} -flags = { -{% if filter.flags.as_search_bitfield().1.len() > 3 %} -
- {% if let Some(flag) = filter.flags.crude_language %} -   crude_language = {{ flag }}
- {% endif %} - {% if let Some(flag) = filter.flags.violence %} -   violence = {{ flag }}
- {% endif %} - {% if let Some(flag) = filter.flags.some_explicit %} -   some_explicit = {{ flag }}
- {% endif %} - {% if let Some(flag) = filter.flags.explicit %} -   explicit = {{ flag }}
- {% endif %} - {% if let Some(flag) = filter.flags.abridged %} -   abridged = {{ flag }}
- {% endif %} - {% if let Some(flag) = filter.flags.lgbt %} -   lgbt = {{ flag }}
- {% endif %} -{% else %} - {% if let Some(flag) = filter.flags.crude_language %} - crude_language = {{ flag }} - {% endif %} - {% if let Some(flag) = filter.flags.violence %} - violence = {{ flag }} - {% endif %} - {% if let Some(flag) = filter.flags.some_explicit %} - some_explicit = {{ flag }} - {% endif %} - {% if let Some(flag) = filter.flags.explicit %} - explicit = {{ flag }} - {% endif %} - {% if let Some(flag) = filter.flags.abridged %} - abridged = {{ flag }} - {% endif %} - {% if let Some(flag) = filter.flags.lgbt %} - lgbt = {{ flag }} - {% endif %} -{% endif %} -}
-{% endif %} -{% if filter.min_size.bytes() > 0 %} -min_size = "{{ filter.min_size }}"
-{% endif %} -{% if filter.max_size.bytes() > 0 %} -max_size = "{{ filter.max_size }}"
-{% endif %} -{% if !filter.exclude_uploader.is_empty() %} -exclude_uploader = {{ self::yaml_items(filter.exclude_uploader) }}
-{% endif %} -{% if let Some(uploaded_after) = filter.uploaded_after %} -uploaded_after = "{{ self::date(uploaded_after) }}"
-{% endif %} -{% if let Some(uploaded_before) = filter.uploaded_before %} -uploaded_before = "{{ self::date(uploaded_before) }}"
-{% endif %} -{% if let Some(min_seeders) = filter.min_seeders %} -min_seeders = {{ min_seeders }}
-{% endif %} -{% if let Some(max_seeders) = filter.max_seeders %} -max_seeders = {{ max_seeders }}
-{% endif %} -{% if let Some(min_leechers) = filter.min_leechers %} -min_leechers = {{ min_leechers }}
-{% endif %} -{% if let Some(max_leechers) = filter.max_leechers %} -max_leechers = {{ max_leechers }}
-{% endif %} -{% if let Some(min_snatched) = filter.min_snatched %} -min_snatched = {{ min_snatched }}
-{% endif %} -{% if let Some(max_snatched) = filter.max_snatched %} -max_snatched = {{ max_snatched }}
-{% endif %} diff --git a/server/templates/partials/flag_icons.html b/server/templates/partials/flag_icons.html deleted file mode 100644 index 94869fea..00000000 --- a/server/templates/partials/flag_icons.html +++ /dev/null @@ -1,18 +0,0 @@ -{% if flags.crude_language == Some(true) %} -Crude Language -{% endif %} -{% if flags.violence == Some(true) %} -Violence -{% endif %} -{% if flags.some_explicit == Some(true) %} -Some Sexually Explicit Content -{% endif %} -{% if flags.explicit == Some(true) %} -Sexually Explicit Content -{% endif %} -{% if flags.abridged == Some(true) %} -Abridged -{% endif %} -{% if flags.lgbt == Some(true) %} -LGBT -{% endif %} diff --git a/server/templates/partials/mam_torrents.html b/server/templates/partials/mam_torrents.html deleted file mode 100644 index 4e7fde37..00000000 --- a/server/templates/partials/mam_torrents.html +++ /dev/null @@ -1,98 +0,0 @@ -
- -
-{% for (mam_torrent, meta, torrent, selected_torrent) in torrents %} -
-
- {% if let Some(cat) = meta.cat %} - {{ meta.media_type.as_str() }} - {{ meta.cat_name() }} - {% endif %} -
-
{{ self::cost_icon(&mam_torrent) }}{{ self::flag_icons(&meta) }}
-
- {% if mam_torrent.lang_code != "ENG" %} - [{{ mam_torrent.lang_code }}] - {% endif %} - {{ meta.title }}{% if let Some((edition, _)) = meta.edition %} {{ edition }}{% endif %}
- by {% for author in meta.authors %}{{ author }}{% if !loop.last %}, {% endif %}{% endfor %}
- {% if !meta.series.is_empty() %} - series - {% for series in meta.series %} - {{ series.name }} - {% if !series.entries.0.is_empty() %}#{{ series.entries }}{% endif %} - {% endfor %} -
- {% endif %} - {% if !meta.narrators.is_empty() %} - narrated by {% for narrator in meta.narrators %}{{ narrator }}{% if !loop.last %}, {% endif %}{% endfor %}
- {% endif %} - {{ mam_torrent.tags }}
- - {{ meta.filetypes.join(", ") }} | - {% if let Some(media_info) = mam_torrent.media_info %} - {{ media_info.audio.format }} | {{ media_info.audio.bitrate }} {{ media_info.audio.mode }} | {{ media_info.general.duration }} | - {% endif %} - {{ mam_torrent.comments }} comment{{ mam_torrent.comments | pluralize }} -
- {{ meta.categories.iter().join(", ") }} -
-
- {% if selected_torrent.is_some() %} - Torrent is downloading - {% else if torrent.is_some() || selected_torrent.is_some() %} - Torrent is downloaded - {% else %} -
- - {% if let Some(wedge_over) = config.wedge_over %} - {% if &meta.size >= &wedge_over && !mam_torrent.is_free() %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - - -
- {% endif %} -
-
- {{ mam_torrent.numfiles }}
- {{ meta.size }} -
-
- {{ mam_torrent.added }}
- {% if mam_torrent.owner != 0 %} - {{ mam_torrent.owner_name }} - {% endif %} -
-
- {{ mam_torrent.seeders }} seeders
- {{ mam_torrent.leechers }} leechers
- {{ mam_torrent.times_completed }} snatches
-
-
-{% endfor %} -
diff --git a/server/tray.rc b/server/tray.rc deleted file mode 100644 index 8049dc8c..00000000 --- a/server/tray.rc +++ /dev/null @@ -1,2 +0,0 @@ -aa-exe-icon ICON "assets/tray.ico" -tray-icon ICON "assets/tray.ico" diff --git a/server/tsconfig.json b/server/tsconfig.json deleted file mode 100644 index f578cdb0..00000000 --- a/server/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "lib": ["es2024", "dom"] - }, - "include": ["assets/*"] -}