commit a99c7381649e5c6c4d79e84f8ba873353300df16 Author: Harvey Date: Fri Apr 3 15:53:00 2026 +0100 Initial public release diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..085539e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,45 @@ +# Source control & CI +.git +.github +.gitignore +.dockerignore + +# Documentation & metadata +*.md +LICENSE +pyproject.toml +todos.md +AGENTS.md +Docs/ +Images/ + +# Caches & runtime data +/cache/ +config/ +__pycache__/ +*.pyc +*.pyo +.mypy_cache/ +.ruff_cache/ +backend/.ruff* + +# Dev-only files +backend/requirements-dev.txt + +# Frontend build artifacts & dev files +frontend/node_modules/ +frontend/build/ +frontend/.svelte-kit/ +frontend/.vite/ + +# Docker (prevent recursive context) +docker-compose*.yml +Dockerfile +manage.sh + +# IDE & OS +.vscode/ +.idea/ +*.swp +.DS_Store +Thumbs.db diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..37fac2d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,83 @@ +name: "\U0001F41B Bug Report" +description: Report a problem with MusicSeerr +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Before opening a bug report, please search [existing issues](https://github.com/habirabbu/musicseerr/issues) to check if it's already been reported. + + For general support questions, use [Discord](https://discord.gg/f98bFfsPuB) instead. + - type: input + id: version + attributes: + label: MusicSeerr Version + description: Found in Settings or the bottom of the sidebar. + placeholder: v1.0.0 + validations: + required: true + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Steps to reproduce + description: How can we reproduce this? + placeholder: | + 1. Go to ... + 2. Click on ... + 3. See error ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: What did you expect to happen instead? + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain the problem. + - type: textarea + id: logs + attributes: + label: Logs + description: Relevant log output from `docker compose logs musicseerr`. This will be formatted as code automatically. + render: shell + - type: dropdown + id: deployment + attributes: + label: Deployment method + options: + - Docker Compose + - Docker CLI + - Other + validations: + required: true + - type: input + id: browser + attributes: + label: Browser + description: e.g. Chrome 120, Firefox 121, Safari 17 + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Any other details that might help (integrations in use, host OS, Docker version, etc). + - type: checkboxes + id: search-existing + attributes: + label: Duplicate check + options: + - label: I have searched existing issues and this hasn't been reported before. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cd975bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Discord + url: https://discord.gg/f98bFfsPuB + about: For support questions and general chat. + - name: Documentation + url: https://musicseerr.com/ + about: Check the docs before opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..6bf57c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,36 @@ +name: "\u2728 Feature Request" +description: Suggest an idea for MusicSeerr +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Before opening a feature request, please search [existing issues](https://github.com/habirabbu/musicseerr/issues) to check if it's already been suggested. + + For general discussion, use [Discord](https://discord.gg/f98bFfsPuB). + - type: textarea + id: description + attributes: + label: What would you like? + description: A clear description of what you want MusicSeerr to do. If this is related to a problem, describe the problem too. + validations: + required: true + - type: textarea + id: desired-behavior + attributes: + label: How should it work? + description: Describe the behaviour you'd expect from this feature. + validations: + required: true + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Screenshots, mockups, links to similar features in other apps, or anything else that helps explain what you're after. + - type: checkboxes + id: search-existing + attributes: + label: Duplicate check + options: + - label: I have searched existing issues and this hasn't been requested before. + required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f0c2634 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,63 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag + id: version + run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Get build date + id: date + run: echo "timestamp=$(git log -1 --pretty=%cI)" >> "$GITHUB_OUTPUT" + + - name: Build and push (versioned) + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/habirabbu/musicseerr:${{ steps.version.outputs.tag }} + build-args: | + COMMIT_TAG=${{ steps.version.outputs.tag }} + BUILD_DATE=${{ steps.date.outputs.timestamp }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + + - name: Tag latest (stable releases only) + if: ${{ !contains(steps.version.outputs.tag, '-') }} + run: | + docker buildx imagetools create \ + -t ghcr.io/habirabbu/musicseerr:latest \ + ghcr.io/habirabbu/musicseerr:${{ steps.version.outputs.tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e9c720 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +.github/instructions/ +.Trash-1000 + +# Runtime files +/cache/ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log +*.pid +*.sock +*.tmp +.env +.env.* + +# Virtual environments and tool caches +.venv/ +venv/ +env/ +.pytest_cache +.mypy_cache/ +.ruff_cache/ +backend/.venv/ +backend/.ruff* +backend/.virtualenv.pyz + +# Frontend build output +frontend/node_modules/ +frontend/.svelte-kit/ +frontend/build/ +frontend/dist/ +frontend/.vite/ +.vite/ + +# Local config +docker-compose.yml +config/config.json +!config/config.example.json + +# Editor and OS files +.DS_Store +.idea/ +.vscode/ +*.swp +Thumbs.db +ehthumbs.db + +# Private working files +/manage.sh +/scripts/ +/todos.md +AGENTS.md +AGENTS.md.bak +Docs/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..31caa94 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to MusicSeerr + +Thanks for your interest. Bug reports, feature requests, and pull requests are all welcome. + +## Reporting Bugs + +Use the [bug report template](https://github.com/habirabbu/musicseerr/issues/new?template=bug.yml). Include your MusicSeerr version, steps to reproduce, and relevant logs from `docker compose logs musicseerr`. The more detail you give, the faster things get fixed. + +## Requesting Features + +Use the [feature request template](https://github.com/habirabbu/musicseerr/issues/new?template=feature.yml). Check existing issues first to avoid duplicates. + +## Development Setup + +The backend is Python 3.13 with FastAPI. The frontend is SvelteKit with Svelte 5, Tailwind CSS, and daisyUI. + +### Prerequisites + +- Python 3.13+ +- Node.js 22+ +- Docker (for building the full image) + +### Running Locally + +Backend: + +```bash +cd backend +pip install -r requirements-dev.txt +uvicorn main:app --reload --port 8688 +``` + +Frontend: + +```bash +cd frontend +npm install +npm run dev +``` + +### Running Tests + +```bash +make backend-test # backend suite +make frontend-test # frontend suite +make test # both +``` + +Frontend browser tests use Playwright. Install the browser first: + +```bash +make frontend-browser-install +``` + +## Pull Requests + +1. Fork the repo and create a branch from `main`. +2. Give your branch a descriptive name: `fix-scrobble-timing`, `feature-playlist-export`, etc. +3. If you're fixing a bug, mention the issue number in the PR description. +4. Make sure tests pass before submitting. +5. Keep changes focused. One PR per fix or feature. + +## Code Style + +- Backend: strong typing, async/await, no blocking I/O in async contexts. +- Frontend: strict TypeScript, no `any`. Named exports. Async/await only. +- Use existing design tokens (`primary`, `secondary`, etc.) for colours, not hardcoded values. +- Run `npm run lint` and `npm run check` in the frontend before submitting. + +## AI-Assisted Contributions + +If you used AI tools (Copilot, ChatGPT, Claude, etc.) to write code in your PR, please mention it. This isn't a problem and won't get your PR rejected, but it helps reviewers calibrate how much scrutiny to apply. A quick note like "Claude helped with the caching logic" is enough. + +You're still responsible for understanding and testing the code you submit. + +## Questions? + +Open a thread in [Discord](https://discord.gg/f98bFfsPuB) or start a [GitHub Discussion](https://github.com/habirabbu/musicseerr/discussions). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c82ca86 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +## +# Stage 1 — Build frontend +## +FROM node:22.16-alpine AS frontend-build + +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci --ignore-scripts + +COPY frontend/ . +RUN npm run build + +## +# Stage 2 — Install Python dependencies +## +FROM python:3.13.5-slim AS python-deps + +COPY backend/requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt + +## +# Stage 3 — Final runtime image +## +FROM python:3.13.5-slim + +ARG COMMIT_TAG +ARG BUILD_DATE + +LABEL org.opencontainers.image.title="MusicSeerr" \ + org.opencontainers.image.description="Music request and discovery app for Lidarr" \ + org.opencontainers.image.url="https://github.com/habirabbu/musicseerr" \ + org.opencontainers.image.source="https://github.com/habirabbu/musicseerr" \ + org.opencontainers.image.version="${COMMIT_TAG}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.licenses="AGPL-3.0" + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PORT=8688 \ + COMMIT_TAG=${COMMIT_TAG} + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl tini gosu \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=python-deps /install /usr/local + +RUN groupadd -r -g 911 musicseerr \ + && useradd -r -u 911 -g musicseerr -d /app -s /sbin/nologin musicseerr + +COPY backend/ . +COPY --from=frontend-build /app/frontend/build ./static +COPY entrypoint.sh /entrypoint.sh + +RUN mkdir -p /app/cache /app/config \ + && chown -R musicseerr:musicseerr /app \ + && chmod +x /entrypoint.sh + +EXPOSE ${PORT} + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:${PORT}/health || exit 1 + +ENTRYPOINT ["tini", "--", "/entrypoint.sh"] +CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port ${PORT} --loop uvloop --http httptools --workers 1"] diff --git a/Images/AlbumPage.png b/Images/AlbumPage.png new file mode 100644 index 0000000..81c5c23 Binary files /dev/null and b/Images/AlbumPage.png differ diff --git a/Images/ArtistPage.png b/Images/ArtistPage.png new file mode 100644 index 0000000..4af7a0c Binary files /dev/null and b/Images/ArtistPage.png differ diff --git a/Images/DiscoverPage.png b/Images/DiscoverPage.png new file mode 100644 index 0000000..69a4255 Binary files /dev/null and b/Images/DiscoverPage.png differ diff --git a/Images/DiscoverQueue.png b/Images/DiscoverQueue.png new file mode 100644 index 0000000..6c8b87f Binary files /dev/null and b/Images/DiscoverQueue.png differ diff --git a/Images/HomePage.png b/Images/HomePage.png new file mode 100644 index 0000000..8fea2d3 Binary files /dev/null and b/Images/HomePage.png differ diff --git a/Images/LibraryAlbumViewer.png b/Images/LibraryAlbumViewer.png new file mode 100644 index 0000000..7ddd9f2 Binary files /dev/null and b/Images/LibraryAlbumViewer.png differ diff --git a/Images/LibraryArtistViewer.png b/Images/LibraryArtistViewer.png new file mode 100644 index 0000000..7e936e3 Binary files /dev/null and b/Images/LibraryArtistViewer.png differ diff --git a/Images/LibraryPage.png b/Images/LibraryPage.png new file mode 100644 index 0000000..acc32a0 Binary files /dev/null and b/Images/LibraryPage.png differ diff --git a/Images/LocalFilesPage.png b/Images/LocalFilesPage.png new file mode 100644 index 0000000..13d214b Binary files /dev/null and b/Images/LocalFilesPage.png differ diff --git a/Images/Logo-OG.png b/Images/Logo-OG.png new file mode 100644 index 0000000..2000d18 Binary files /dev/null and b/Images/Logo-OG.png differ diff --git a/Images/NavidromePage.png b/Images/NavidromePage.png new file mode 100644 index 0000000..48a9515 Binary files /dev/null and b/Images/NavidromePage.png differ diff --git a/Images/PlaylistPage.png b/Images/PlaylistPage.png new file mode 100644 index 0000000..93e4c36 Binary files /dev/null and b/Images/PlaylistPage.png differ diff --git a/Images/ProfilePage.png b/Images/ProfilePage.png new file mode 100644 index 0000000..fc84029 Binary files /dev/null and b/Images/ProfilePage.png differ diff --git a/Images/SearchPage.png b/Images/SearchPage.png new file mode 100644 index 0000000..eeb2cf2 Binary files /dev/null and b/Images/SearchPage.png differ diff --git a/Images/YoutubePage.png b/Images/YoutubePage.png new file mode 100644 index 0000000..8e16206 Binary files /dev/null and b/Images/YoutubePage.png differ diff --git a/Images/logo_icon.png b/Images/logo_icon.png new file mode 100644 index 0000000..05b49cd Binary files /dev/null and b/Images/logo_icon.png differ diff --git a/Images/logo_wide.png b/Images/logo_wide.png new file mode 100644 index 0000000..63d1f4e Binary files /dev/null and b/Images/logo_wide.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee94000 --- /dev/null +++ b/LICENSE @@ -0,0 +1,670 @@ +Copyright (c) 2025 Harvey Bragg + +This software is licensed under the GNU Affero General Public License v3.0 (AGPLv3). +You may obtain a copy of the License at https://www.gnu.org/licenses/agpl-3.0.txt + +Commercial licensing options are available from the copyright holder. + +─────────────────────────────────────────────────────────────────────────────── + +GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + MusicSeerr - self-hosted music request and discovery platform + Copyright (C) 2025 Harvey Bragg + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfad006 --- /dev/null +++ b/Makefile @@ -0,0 +1,139 @@ +SHELL := /bin/bash + +.DEFAULT_GOAL := help + +ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +BACKEND_DIR := $(ROOT_DIR)/backend +FRONTEND_DIR := $(ROOT_DIR)/frontend +BACKEND_VENV_DIR := $(BACKEND_DIR)/.venv +BACKEND_VENV_PYTHON := $(BACKEND_VENV_DIR)/bin/python +BACKEND_VENV_STAMP := $(BACKEND_VENV_DIR)/.deps-stamp +BACKEND_VIRTUALENV_ZIPAPP := $(BACKEND_DIR)/.virtualenv.pyz +PYTHON ?= python3 +NPM ?= npm + +.PHONY: help backend-venv backend-lint backend-test backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 backend-test-exception-handling backend-test-playlist backend-test-multidisc backend-test-performance backend-test-security backend-test-config-validation backend-test-home backend-test-home-genre backend-test-infra-hardening backend-test-library-pagination backend-test-search-top-result test-audiodb-all frontend-install frontend-build frontend-check frontend-lint frontend-test frontend-test-queuehelpers frontend-test-album-page frontend-test-playlist-detail frontend-test-audiodb-images frontend-browser-install project-map rebuild test check lint ci + +help: ## Show available targets + @grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-26s %s\n", $$1, $$2}' + +$(BACKEND_VENV_DIR): + cd "$(BACKEND_DIR)" && test -f .virtualenv.pyz || curl -fsSLo .virtualenv.pyz https://bootstrap.pypa.io/virtualenv.pyz + cd "$(BACKEND_DIR)" && $(PYTHON) .virtualenv.pyz .venv + +$(BACKEND_VENV_STAMP): $(BACKEND_DIR)/requirements.txt $(BACKEND_DIR)/requirements-dev.txt | $(BACKEND_VENV_DIR) + cd "$(BACKEND_DIR)" && .venv/bin/python -m pip install --upgrade pip setuptools wheel + cd "$(BACKEND_DIR)" && .venv/bin/python -m pip install -r requirements-dev.txt pytest pytest-asyncio + touch "$(BACKEND_VENV_STAMP)" + +backend-venv: $(BACKEND_VENV_STAMP) ## Create or refresh the backend virtualenv + +backend-lint: $(BACKEND_VENV_STAMP) ## Run backend Ruff checks + cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check backend + +backend-test: $(BACKEND_VENV_STAMP) ## Run all backend tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest + +backend-test-audiodb: $(BACKEND_VENV_STAMP) ## Run focused AudioDB backend tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_audiodb_repository.py tests/infrastructure/test_disk_metadata_cache.py tests/services/test_audiodb_image_service.py tests/services/test_artist_audiodb_population.py tests/services/test_album_audiodb_population.py tests/services/test_audiodb_detail_flows.py tests/services/test_search_audiodb_overlay.py + +backend-test-audiodb-prewarm: $(BACKEND_VENV_STAMP) ## Run AudioDB prewarm tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_audiodb_prewarm.py tests/services/test_audiodb_sweep.py tests/services/test_audiodb_browse_queue.py tests/services/test_audiodb_fallback_gating.py tests/services/test_preferences_generic_settings.py + +backend-test-coverart-audiodb: $(BACKEND_VENV_STAMP) ## Run AudioDB coverart provider tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_coverart_album_fetcher.py tests/repositories/test_coverart_audiodb_provider.py tests/repositories/test_coverart_repository_memory_cache.py tests/services/test_audiodb_byte_caching_integration.py + +backend-test-audiodb-settings: $(BACKEND_VENV_STAMP) ## Run AudioDB settings tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_audiodb_settings.py tests/test_advanced_settings_roundtrip.py tests/routes/test_settings_audiodb_key.py + +backend-test-audiodb-phase8: $(BACKEND_VENV_STAMP) ## Run AudioDB cross-cutting tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/repositories/test_audiodb_models.py tests/test_audiodb_schema_contracts.py tests/services/test_audiodb_byte_caching_integration.py tests/services/test_audiodb_url_only_integration.py tests/services/test_audiodb_fallback_integration.py tests/services/test_audiodb_negative_cache_expiry.py tests/test_audiodb_killswitch.py tests/test_advanced_settings_roundtrip.py + +backend-test-audiodb-phase9: $(BACKEND_VENV_STAMP) ## Run AudioDB observability tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_phase9_observability.py + +backend-test-exception-handling: $(BACKEND_VENV_STAMP) ## Run exception-handling regressions + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/routes/test_scrobble_routes.py tests/routes/test_scrobble_settings_routes.py tests/test_error_leakage.py tests/test_background_task_logging.py + +backend-test-playlist: $(BACKEND_VENV_STAMP) ## Run playlist tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_playlist_service.py tests/services/test_playlist_source_resolution.py tests/repositories/test_playlist_repository.py tests/routes/test_playlist_routes.py + +backend-test-multidisc: $(BACKEND_VENV_STAMP) ## Run multi-disc album tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_album_utils.py tests/services/test_album_service.py tests/infrastructure/test_cache_layer_followups.py + +backend-test-performance: $(BACKEND_VENV_STAMP) ## Run performance regression tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_album_singleflight.py tests/services/test_artist_singleflight.py tests/services/test_genre_batch_parallel.py tests/services/test_cache_stats_nonblocking.py tests/services/test_settings_cache_invalidation.py tests/services/test_discover_enrich_singleflight.py + +backend-test-security: $(BACKEND_VENV_STAMP) ## Run security regression tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_rate_limiter_middleware.py tests/test_url_validation.py tests/test_error_leakage.py + +backend-test-config-validation: $(BACKEND_VENV_STAMP) ## Run config validation tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_config_validation.py + +backend-test-home: $(BACKEND_VENV_STAMP) ## Run home page backend tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_home_service.py tests/routes/test_home_routes.py + +backend-test-home-genre: $(BACKEND_VENV_STAMP) ## Run home genre decoupling tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_home_genre_decoupling.py + +backend-test-infra-hardening: $(BACKEND_VENV_STAMP) ## Run infrastructure hardening tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_circuit_breaker_sync.py tests/infrastructure/test_disk_cache_periodic.py tests/infrastructure/test_retry_non_breaking.py + +backend-test-discovery-precache: $(BACKEND_VENV_STAMP) ## Run artist discovery precache tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_discovery_precache_progress.py tests/infrastructure/test_retry_non_breaking.py -v + +backend-test-library-pagination: $(BACKEND_VENV_STAMP) ## Run library pagination tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/infrastructure/test_library_pagination.py -v + +backend-test-search-top-result: $(BACKEND_VENV_STAMP) ## Run search top result detection tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/services/test_search_top_result.py -v + +backend-test-cache-cleanup: $(BACKEND_VENV_STAMP) ## Run cache cleanup tests + cd "$(BACKEND_DIR)" && .venv/bin/python -m pytest tests/test_cache_cleanup.py -v + +test-audiodb-all: backend-test-audiodb backend-test-audiodb-prewarm backend-test-audiodb-settings backend-test-coverart-audiodb backend-test-audiodb-phase8 backend-test-audiodb-phase9 frontend-test-audiodb-images ## Run every AudioDB test target + +frontend-install: ## Install frontend npm dependencies + cd "$(FRONTEND_DIR)" && $(NPM) install + +frontend-build: ## Run frontend production build + cd "$(FRONTEND_DIR)" && $(NPM) run build + +frontend-check: ## Run frontend type checks + cd "$(FRONTEND_DIR)" && $(NPM) run check + +frontend-lint: ## Run frontend linting + cd "$(FRONTEND_DIR)" && $(NPM) run lint + +frontend-test: ## Run the frontend vitest suite + cd "$(FRONTEND_DIR)" && $(NPM) run test + +frontend-test-queuehelpers: ## Run queue helper regressions + cd "$(FRONTEND_DIR)" && npx vitest run --project server src/lib/player/queueHelpers.spec.ts + +frontend-test-album-page: ## Run the album page browser test + cd "$(FRONTEND_DIR)" && npx vitest run --project client src/routes/album/[id]/page.svelte.spec.ts + +frontend-test-playlist-detail: ## Run playlist page browser tests + cd "$(FRONTEND_DIR)" && npx vitest run --project client src/routes/playlists/[id]/page.svelte.spec.ts + +frontend-browser-install: ## Install Playwright Chromium for browser tests + cd "$(FRONTEND_DIR)" && npx playwright install chromium + +frontend-test-audiodb-images: ## Run AudioDB image tests + cd "$(FRONTEND_DIR)" && npx vitest run --project server src/lib/utils/imageSuffix.spec.ts + cd "$(FRONTEND_DIR)" && npx vitest run --project client src/lib/components/BaseImage.svelte.spec.ts + +project-map: ## Refresh the project map block + cd "$(ROOT_DIR)" && $(PYTHON) scripts/gen-project-map.py + +rebuild: ## Rebuild the application + cd "$(ROOT_DIR)" && ./manage.sh --rebuild + +test: backend-test frontend-test ## Run backend and frontend tests + +check: backend-test frontend-check ## Run backend tests and frontend type checks + +lint: backend-lint frontend-lint ## Run linting targets + +ci: backend-test backend-lint frontend-check frontend-lint frontend-test ## Run the local CI checks diff --git a/README.md b/README.md new file mode 100644 index 0000000..00a968f --- /dev/null +++ b/README.md @@ -0,0 +1,317 @@ +
+ +MusicSeerr + +[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) +[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue?logo=docker)](https://github.com/habirabbu/musicseerr/pkgs/container/musicseerr) +[![Discord](https://img.shields.io/discord/1356702267809808404?label=discord&logo=discord&logoColor=white)](https://discord.gg/f98bFfsPuB) +[![Docs](https://img.shields.io/badge/docs-musicseerr.com-blue)](https://musicseerr.com/) + +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4M41URGJO) + +
+ +--- + +MusicSeerr is a self-hosted music request and discovery app built around [Lidarr](https://lidarr.audio/). Search the full MusicBrainz catalogue, request albums, stream music from Jellyfin, Navidrome, or your local library, discover new albums based on your listening history, and scrobble everything to ListenBrainz and Last.fm. The whole thing runs as a single Docker container with a web UI for all configuration. + +--- + +## Screenshots + +Home page with trending artists, popular albums, and personalized recommendations +Artist detail page with biography, discography, and similar artists +Album detail page with tracklist, playback controls, and request button +Discover page with personalized album recommendations + +
+More screenshots + +Search results for artists and albums +Library overview with statistics and recent additions +Playlist with tracklist and playback controls +Discover queue with album recommendations to request or skip +Local files library with format and storage stats +Navidrome library view +YouTube linked albums for streaming +User profile with connected services and library stats + +
+ +--- + +## Quick Start + +You need Docker and a running [Lidarr](https://lidarr.audio/) instance with an API key. + +### 1. Create a docker-compose.yml + +```yaml +services: + musicseerr: + image: ghcr.io/habirabbu/musicseerr:latest + container_name: musicseerr + environment: + - PUID=1000 # Run `id` on your host to find your user/group ID + - PGID=1000 + - PORT=8688 + - TZ=Etc/UTC # Your timezone, e.g. Europe/London, America/New_York + ports: + - "8688:8688" + volumes: + - ./config:/app/config # Persistent app configuration + - ./cache:/app/cache # Cover art and metadata cache + # Optional: mount your music library for local file playback. + # The left side should match the root folder Lidarr uses. + # The right side (/music) must match "Music Directory Path" in Settings > Local Files. + # - /path/to/music:/music:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8688/health"] + interval: 30s + timeout: 10s + start_period: 15s + retries: 3 +``` + +### 2. Start it + +```bash +docker compose up -d +``` + +### 3. Configure + +Open [http://localhost:8688](http://localhost:8688) and head to Settings. Add your Lidarr URL and API key, then connect whichever streaming and discovery services you use. + +--- + +## Recommended Stack + +MusicSeerr is designed to work with Lidarr. If you're putting together a music stack from scratch, this combination covers most needs: + +| Service | Role | +|-|-| +| [Lidarr](https://lidarr.audio/) (nightly recommended) | Library management, download orchestration | +| [slskd](https://github.com/slskd/slskd) | Soulseek download client | +| [Tubifarry](https://github.com/Tubifarry/Tubifarry) | YouTube-based download client for Lidarr | + +Lidarr is the only requirement. slskd and Tubifarry are optional but between them they cover most music sourcing needs. For playback, connect Jellyfin, Navidrome, or mount your music folder directly into the container. + +--- + +## Features + +### Search and Request + +Search the full MusicBrainz catalogue for any artist or album. Request an album and Lidarr handles the download. A persistent queue tracks all requests, and you can browse pending and fulfilled requests on a dedicated page with retry and cancel support. + +### Built-in Player + +MusicSeerr has a full audio player that supports multiple playback sources per track: + +- Jellyfin, with configurable codec (AAC, MP3, FLAC, Opus, and others) and bitrate. Playback events are reported back to Jellyfin automatically. +- Navidrome, streaming via the Subsonic API. +- Local files, served directly from a mounted music directory. +- YouTube, for previewing albums you haven't downloaded yet. Links can be auto-generated or set manually. + +The player supports queue management, shuffle, seek, volume control, and a 10-band equalizer with presets. + +### Discovery + +The home page shows trending artists, popular albums, recently added items, genre quick-links, weekly exploration playlists from ListenBrainz, and "Because You Listened To" carousels personalized to your history. + +The discover page goes further with a recommendation queue drawn from similar artists, library gaps, fresh releases, global charts, and your listening patterns across ListenBrainz and Last.fm. Each album can be expanded to show the full tracklist and artwork before you decide to request or skip it. + +You can also browse by genre, view trending and popular charts over different time ranges, and see your own top albums. + +### Library + +Browse your Lidarr-managed library by artist or album with search, filtering, sorting, and pagination. View recently added albums and library statistics. Remove albums directly from the UI. + +Jellyfin, Navidrome, and local file sources each get their own library view with play, shuffle, and queue actions. + +### Scrobbling + +Every track you play can be scrobbled to ListenBrainz and Last.fm simultaneously. Both are toggled independently in settings. A "now playing" update goes out when a track starts, and a scrobble is submitted when it finishes. + +### Playlists + +Create playlists from any mix of Jellyfin, Navidrome, local, and YouTube tracks. Reorder by dragging, set custom cover art, and play everything through the same player. + +### Profile + +Set a display name and avatar, view connected services, and check your library statistics from a profile page. + +--- + +## Integrations + +| Service | What it does | +|-|-| +| [Lidarr](https://lidarr.audio/) | Download management and library syncing | +| [MusicBrainz](https://musicbrainz.org/) | Artist and album metadata, release search | +| [Cover Art Archive](https://coverartarchive.org/) | Album artwork | +| [TheAudioDB](https://www.theaudiodb.com/) | Artist and album images (fanart, banners, logos, CD art) | +| [Wikidata](https://www.wikidata.org/) | Artist descriptions and external links | +| [Jellyfin](https://jellyfin.org/) | Audio streaming and library browsing | +| [Navidrome](https://www.navidrome.org/) | Audio streaming via Subsonic API | +| [ListenBrainz](https://listenbrainz.org/) | Listening history, discovery, scrobbling, weekly playlists | +| [Last.fm](https://www.last.fm/) | Scrobbling and listen tracking | +| YouTube | Album playback when no local copy exists | +| Local files | Direct playback from a mounted music directory | + +All integrations are configured through the web UI. No config files or environment variables needed beyond the basics listed below. + +--- + +## Configuration + +MusicSeerr stores its config in `config/config.json` inside the mapped config volume. Everything is managed through the UI. + +### Environment Variables + +| Variable | Default | Description | +|-|-|-| +| `PUID` | `1000` | User ID for file ownership inside the container | +| `PGID` | `1000` | Group ID for file ownership inside the container | +| `PORT` | `8688` | Port the application listens on | +| `TZ` | `Etc/UTC` | Container timezone | + +Run `id` on your host to find your PUID and PGID values. + +### In-App Settings + +| Setting | Location | +|-|-| +| Lidarr URL, API key, profiles, root folder, sync frequency | Settings > Lidarr | +| Jellyfin URL and API key | Settings > Jellyfin | +| Navidrome URL and credentials | Settings > Navidrome | +| Local files directory path | Settings > Local Files | +| ListenBrainz username and token | Settings > ListenBrainz | +| Last.fm API key, secret, and OAuth session | Settings > Last.fm | +| YouTube API key | Settings > YouTube | +| Scrobbling toggles per service | Settings > Scrobbling | +| Home page layout preferences | Settings > Preferences | +| AudioDB settings and cache TTLs | Settings > Advanced | + +### Setting Up Last.fm + +1. Register an app at [last.fm/api/account/create](https://www.last.fm/api/account/create) to get an API key and shared secret. +2. Enter them in Settings > Last.fm. +3. Click Authorise and follow the redirect. You'll be returned to MusicSeerr automatically. + +### Setting Up ListenBrainz + +1. Copy your user token from [listenbrainz.org/profile](https://listenbrainz.org/profile/). +2. Enter your username and token in Settings > ListenBrainz. + +### TheAudioDB + +AudioDB provides richer artist and album artwork from a fast CDN. It's enabled by default with the free public API key, which is rate-limited to 30 requests per minute. Premium keys from [theaudiodb.com](https://www.theaudiodb.com/) unlock higher limits. + +Under Settings > Advanced, you can toggle AudioDB on or off, switch between direct CDN loading and proxied loading (for privacy), enable name-based search fallback for niche artists, and adjust cache TTLs. + +--- + +## Playback Sources + +### Jellyfin + +Audio is transcoded on the Jellyfin server and streamed to the browser. Supported codecs include AAC, MP3, Opus, FLAC, Vorbis, ALAC, WAV, and WMA. Bitrate is configurable between 32 kbps and 320 kbps. Playback start, progress, and stop events are reported back to Jellyfin. + +### Local Files + +Mount your music directory into the container and MusicSeerr serves files directly. The mount path inside the container must match the Music Directory Path set in Settings > Local Files. + +```yaml +volumes: + - /path/to/your/music:/music:ro +``` + +### Navidrome + +Connect your Navidrome instance under Settings > Navidrome. + +### YouTube + +Albums can be linked to a YouTube URL and played inline. This is useful for listening to albums before you've downloaded them. Links can be auto-generated with a YouTube API key or added manually. + +A note on reliability: YouTube playback depends on the embedded player, which can be finicky. It works best in a browser where you're signed into YouTube, and VPNs tend to cause issues. Treat it as a convenience for previewing albums rather than a primary playback source. + +--- + +## Volumes and Persistence + +| Container path | Purpose | +|-|-| +| `/app/config` | Application config (`config.json`) | +| `/app/cache` | Cover art cache, metadata cache, SQLite databases | +| `/music` (optional) | Music library root for local file playback | + +Map both `/app/config` and `/app/cache` to persistent host directories so they survive container restarts. + +--- + +## API + +Interactive API docs (Swagger UI) are available at `/api/v1/docs` on your MusicSeerr instance. + +A health check endpoint is at `/health`. + +--- + +## Development + +The backend is Python 3.13 with FastAPI. The frontend is SvelteKit with Svelte 5, Tailwind CSS, and DaisyUI. + +### Backend + +```bash +cd backend +pip install -r requirements-dev.txt +uvicorn main:app --reload --port 8688 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +### Tests + +A root Makefile wraps the test commands: + +```bash +make backend-test # full backend suite +make frontend-test # full frontend suite +make test # both +make ci # tests + linting + type checks +``` + +Frontend browser tests use Playwright. Install the browser with: + +```bash +make frontend-browser-install +``` + +--- + +## Support + +Documentation is at [musicseerr.com](https://musicseerr.com/). + +For questions, help, or just to chat, join the [Discord](https://discord.gg/f98bFfsPuB). Bug reports and feature requests go on [GitHub Issues](https://github.com/habirabbu/musicseerr/issues). + +If you find MusicSeerr useful, consider supporting development: + +[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/M4M41URGJO) + +--- + +## License + +[GNU Affero General Public License v3.0](LICENSE) diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/routes/__init__.py b/backend/api/v1/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/routes/albums.py b/backend/api/v1/routes/albums.py new file mode 100644 index 0000000..714b3f9 --- /dev/null +++ b/backend/api/v1/routes/albums.py @@ -0,0 +1,149 @@ +import logging +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status +from core.exceptions import ClientDisconnectedError +from api.v1.schemas.album import AlbumInfo, AlbumBasicInfo, AlbumTracksInfo, LastFmAlbumEnrichment +from api.v1.schemas.discovery import SimilarAlbumsResponse, MoreByArtistResponse +from core.dependencies import get_album_service, get_album_discovery_service, get_album_enrichment_service +from services.album_service import AlbumService +from services.album_discovery_service import AlbumDiscoveryService +from services.album_enrichment_service import AlbumEnrichmentService +from infrastructure.validators import is_unknown_mbid +from infrastructure.degradation import try_get_degradation_context +from infrastructure.msgspec_fastapi import MsgSpecRoute + +import msgspec.structs + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/albums", tags=["album"]) + + +@router.get("/{album_id}", response_model=AlbumInfo) +async def get_album( + album_id: str, + album_service: AlbumService = Depends(get_album_service) +): + if is_unknown_mbid(album_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown album ID: {album_id}" + ) + + try: + result = await album_service.get_album_info(album_id) + ctx = try_get_degradation_context() + if ctx is not None and ctx.has_degradation(): + result = msgspec.structs.replace(result, service_status=ctx.degraded_summary()) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid album request" + ) + + +@router.get("/{album_id}/basic", response_model=AlbumBasicInfo) +async def get_album_basic( + album_id: str, + request: Request, + background_tasks: BackgroundTasks, + album_service: AlbumService = Depends(get_album_service) +): + """Get minimal album info for fast initial load - no tracks.""" + if await request.is_disconnected(): + raise ClientDisconnectedError("Client disconnected") + + if is_unknown_mbid(album_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown album ID: {album_id}" + ) + + try: + result = await album_service.get_album_basic_info(album_id) + background_tasks.add_task(album_service.warm_full_album_cache, album_id) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid album request" + ) + + +@router.get("/{album_id}/tracks", response_model=AlbumTracksInfo) +async def get_album_tracks( + album_id: str, + request: Request, + album_service: AlbumService = Depends(get_album_service) +): + """Get track list and extended details - loaded asynchronously.""" + if await request.is_disconnected(): + raise ClientDisconnectedError("Client disconnected") + + if is_unknown_mbid(album_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown album ID: {album_id}" + ) + + try: + return await album_service.get_album_tracks_info(album_id) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid album request" + ) + + +@router.get("/{album_id}/similar", response_model=SimilarAlbumsResponse) +async def get_similar_albums( + album_id: str, + artist_id: str = Query(..., description="Artist MBID for similarity lookup"), + count: int = Query(default=10, ge=1, le=30), + discovery_service: AlbumDiscoveryService = Depends(get_album_discovery_service) +): + """Get albums from similar artists.""" + if is_unknown_mbid(album_id) or is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or unknown album/artist ID" + ) + return await discovery_service.get_similar_albums(album_id, artist_id, count) + + +@router.get("/{album_id}/more-by-artist", response_model=MoreByArtistResponse) +async def get_more_by_artist( + album_id: str, + artist_id: str = Query(..., description="Artist MBID"), + count: int = Query(default=10, ge=1, le=30), + discovery_service: AlbumDiscoveryService = Depends(get_album_discovery_service) +): + """Get other albums by the same artist.""" + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or unknown artist ID" + ) + return await discovery_service.get_more_by_artist(artist_id, album_id, count) + + +@router.get("/{album_id}/lastfm", response_model=LastFmAlbumEnrichment) +async def get_album_lastfm_enrichment( + album_id: str, + artist_name: str = Query(..., description="Artist name for Last.fm lookup"), + album_name: str = Query(..., description="Album name for Last.fm lookup"), + enrichment_service: AlbumEnrichmentService = Depends(get_album_enrichment_service), +): + if is_unknown_mbid(album_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown album ID: {album_id}" + ) + result = await enrichment_service.get_lastfm_enrichment( + artist_name=artist_name, album_name=album_name, album_mbid=album_id + ) + if result is None: + return LastFmAlbumEnrichment() + return result diff --git a/backend/api/v1/routes/artists.py b/backend/api/v1/routes/artists.py new file mode 100644 index 0000000..1b52a21 --- /dev/null +++ b/backend/api/v1/routes/artists.py @@ -0,0 +1,152 @@ +import logging +from typing import Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from core.exceptions import ClientDisconnectedError +from api.v1.schemas.artist import ArtistInfo, ArtistExtendedInfo, ArtistReleases, LastFmArtistEnrichment +from api.v1.schemas.discovery import SimilarArtistsResponse, TopSongsResponse, TopAlbumsResponse +from core.dependencies import get_artist_service, get_artist_discovery_service, get_artist_enrichment_service +from services.artist_service import ArtistService +from services.artist_discovery_service import ArtistDiscoveryService +from services.artist_enrichment_service import ArtistEnrichmentService +from infrastructure.validators import is_unknown_mbid +from infrastructure.msgspec_fastapi import MsgSpecRoute +from infrastructure.degradation import try_get_degradation_context + +import msgspec.structs + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/artists", tags=["artist"]) + + +@router.get("/{artist_id}", response_model=ArtistInfo) +async def get_artist( + artist_id: str, + request: Request, + artist_service: ArtistService = Depends(get_artist_service) +): + if await request.is_disconnected(): + raise ClientDisconnectedError("Client disconnected") + + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + + try: + result = await artist_service.get_artist_info(artist_id) + ctx = try_get_degradation_context() + if ctx and ctx.has_degradation(): + result = msgspec.structs.replace(result, service_status=ctx.degraded_summary()) + return result + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid artist request" + ) + + +@router.get("/{artist_id}/extended", response_model=ArtistExtendedInfo) +async def get_artist_extended( + artist_id: str, + artist_service: ArtistService = Depends(get_artist_service) +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + + try: + return await artist_service.get_artist_extended_info(artist_id) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid artist request" + ) + + +@router.get("/{artist_id}/releases", response_model=ArtistReleases) +async def get_artist_releases( + artist_id: str, + offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), + artist_service: ArtistService = Depends(get_artist_service) +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + + try: + return await artist_service.get_artist_releases(artist_id, offset, limit) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid artist request" + ) + + +@router.get("/{artist_id}/similar", response_model=SimilarArtistsResponse) +async def get_similar_artists( + artist_id: str, + count: int = Query(default=15, ge=1, le=50), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + discovery_service: ArtistDiscoveryService = Depends(get_artist_discovery_service) +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + return await discovery_service.get_similar_artists(artist_id, count, source=source) + + +@router.get("/{artist_id}/top-songs", response_model=TopSongsResponse) +async def get_top_songs( + artist_id: str, + count: int = Query(default=10, ge=1, le=50), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + discovery_service: ArtistDiscoveryService = Depends(get_artist_discovery_service) +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + return await discovery_service.get_top_songs(artist_id, count, source=source) + + +@router.get("/{artist_id}/top-albums", response_model=TopAlbumsResponse) +async def get_top_albums( + artist_id: str, + count: int = Query(default=10, ge=1, le=50), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + discovery_service: ArtistDiscoveryService = Depends(get_artist_discovery_service) +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + return await discovery_service.get_top_albums(artist_id, count, source=source) + + +@router.get("/{artist_id}/lastfm", response_model=LastFmArtistEnrichment) +async def get_artist_lastfm_enrichment( + artist_id: str, + artist_name: str = Query(..., description="Artist name for Last.fm lookup"), + enrichment_service: ArtistEnrichmentService = Depends(get_artist_enrichment_service), +): + if is_unknown_mbid(artist_id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid or unknown artist ID: {artist_id}" + ) + result = await enrichment_service.get_lastfm_enrichment(artist_id, artist_name) + if result is None: + return LastFmArtistEnrichment() + return result diff --git a/backend/api/v1/routes/cache.py b/backend/api/v1/routes/cache.py new file mode 100644 index 0000000..2e318c3 --- /dev/null +++ b/backend/api/v1/routes/cache.py @@ -0,0 +1,78 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException + +from api.v1.schemas.cache import CacheStats, CacheClearResponse +from core.dependencies import get_cache_service +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.cache_service import CacheService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/cache", tags=["cache"]) + + +@router.get("/stats", response_model=CacheStats) +async def get_cache_stats( + cache_service: CacheService = Depends(get_cache_service), +): + return await cache_service.get_stats() + + +@router.post("/clear/memory", response_model=CacheClearResponse) +async def clear_memory_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_memory_cache() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result + + +@router.post("/clear/disk", response_model=CacheClearResponse) +async def clear_disk_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_disk_cache() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result + + +@router.post("/clear/all", response_model=CacheClearResponse) +async def clear_all_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_all_cache() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result + + +@router.post("/clear/covers", response_model=CacheClearResponse) +async def clear_covers_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_covers_cache() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result + + +@router.post("/clear/library", response_model=CacheClearResponse) +async def clear_library_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_library_cache() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result + + +@router.post("/clear/audiodb", response_model=CacheClearResponse) +async def clear_audiodb_cache( + cache_service: CacheService = Depends(get_cache_service), +): + result = await cache_service.clear_audiodb() + if not result.success: + raise HTTPException(status_code=500, detail=result.message) + return result diff --git a/backend/api/v1/routes/cache_status.py b/backend/api/v1/routes/cache_status.py new file mode 100644 index 0000000..37b4961 --- /dev/null +++ b/backend/api/v1/routes/cache_status.py @@ -0,0 +1,83 @@ +import asyncio +import logging +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse +import msgspec + +from api.v1.schemas.cache_status import CacheSyncStatus +from core.dependencies import get_cache_status_service +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.cache_status_service import CacheStatusService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/cache/sync", tags=["cache"]) + + +@router.get("/status", response_model=CacheSyncStatus) +async def get_sync_status( + status_service: CacheStatusService = Depends(get_cache_status_service), +): + progress = status_service.get_progress() + + return CacheSyncStatus( + is_syncing=progress.is_syncing, + phase=progress.phase, + total_items=progress.total_items, + processed_items=progress.processed_items, + progress_percent=progress.progress_percent, + current_item=progress.current_item, + started_at=progress.started_at, + error_message=progress.error_message, + total_artists=progress.total_artists, + processed_artists=progress.processed_artists, + total_albums=progress.total_albums, + processed_albums=progress.processed_albums + ) + + +@router.get("/stream") +async def stream_sync_status( + status_service: CacheStatusService = Depends(get_cache_status_service), +): + queue = status_service.subscribe_sse() + + async def event_generator(): + try: + progress = status_service.get_progress() + initial_data = { + 'is_syncing': progress.is_syncing, + 'phase': progress.phase, + 'total_items': progress.total_items, + 'processed_items': progress.processed_items, + 'progress_percent': progress.progress_percent, + 'current_item': progress.current_item, + 'started_at': progress.started_at, + 'error_message': progress.error_message, + 'total_artists': progress.total_artists, + 'processed_artists': progress.processed_artists, + 'total_albums': progress.total_albums, + 'processed_albums': progress.processed_albums + } + yield f"data: {msgspec.json.encode(initial_data).decode('utf-8')}\n\n" + + while True: + try: + data = await asyncio.wait_for(queue.get(), timeout=30.0) + yield f"data: {data}\n\n" + except asyncio.TimeoutError: + yield ": keepalive\n\n" + except asyncio.CancelledError: + pass + finally: + status_service.unsubscribe_sse(queue) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) diff --git a/backend/api/v1/routes/covers.py b/backend/api/v1/routes/covers.py new file mode 100644 index 0000000..2084621 --- /dev/null +++ b/backend/api/v1/routes/covers.py @@ -0,0 +1,281 @@ +import logging +import hashlib +from typing import Optional +from fastapi import APIRouter, HTTPException, Path, Query, Depends, Request +from fastapi.responses import Response +from core.dependencies import get_coverart_repository +from infrastructure.msgspec_fastapi import MsgSpecRoute +from repositories.coverart_repository import CoverArtRepository + +router = APIRouter(route_class=MsgSpecRoute, prefix="/covers", tags=["covers"]) +log = logging.getLogger(__name__) + +_ALLOWED_SIZES = {"250", "500", "1200"} +_SIZE_ALIAS_NONE = {"", "original", "full", "max", "largest"} + + +def _quote_etag(content_hash: str) -> str: + return f'"{content_hash}"' + + +def _etag_matches(if_none_match: Optional[str], etag_header: str) -> bool: + if not if_none_match: + return False + + candidates = [token.strip() for token in if_none_match.split(",")] + if "*" in candidates: + return True + + if etag_header in candidates: + return True + + weak_etag = f"W/{etag_header}" + return weak_etag in candidates + + +def _normalize_size(size: Optional[str]) -> Optional[str]: + if size is None: + return "500" + normalized = size.strip().lower() + if normalized in _SIZE_ALIAS_NONE: + return None + if normalized not in _ALLOWED_SIZES: + raise HTTPException( + status_code=400, + detail=f"Unsupported size '{size}'. Choose one of 250, 500, 1200 or original.", + ) + return normalized + + +@router.get("/release-group/{release_group_id}") +async def cover_from_release_group( + request: Request, + release_group_id: str = Path(..., min_length=1, description="MusicBrainz release group ID"), + size: Optional[str] = Query( + "500", + description="Preferred size: 250, 500, 1200, or 'original' for full size", + ), + coverart_repo: CoverArtRepository = Depends(get_coverart_repository) +): + desired_size = _normalize_size(size) + + etag_hash = await coverart_repo.get_release_group_cover_etag(release_group_id, desired_size) + etag_header = _quote_etag(etag_hash) if etag_hash else None + if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): + return Response( + status_code=304, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": etag_header, + }, + ) + + result = await coverart_repo.get_release_group_cover(release_group_id, desired_size, is_disconnected=request.is_disconnected) + + if result: + image_data, content_type, source = result + if not etag_header: + etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) + return Response( + content=image_data, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "X-Cover-Source": source, + "ETag": etag_header, + } + ) + + placeholder_svg = ''' + + + + + + + ''' + return Response( + content=placeholder_svg.encode(), + media_type="image/svg+xml", + headers={ + "Cache-Control": "public, max-age=86400", + "X-Cover-Source": "placeholder", + } + ) + + +@router.get("/release/{release_id}") +async def cover_from_release( + request: Request, + release_id: str = Path(..., min_length=1, description="MusicBrainz release ID"), + size: Optional[str] = Query( + "500", + description="Preferred size: 250, 500, 1200, or 'original' for full size", + ), + coverart_repo: CoverArtRepository = Depends(get_coverart_repository) +): + desired_size = _normalize_size(size) + + etag_hash = await coverart_repo.get_release_cover_etag(release_id, desired_size) + etag_header = _quote_etag(etag_hash) if etag_hash else None + if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): + return Response( + status_code=304, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": etag_header, + }, + ) + + result = await coverart_repo.get_release_cover(release_id, desired_size, is_disconnected=request.is_disconnected) + + if result: + image_data, content_type, source = result + if not etag_header: + etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) + return Response( + content=image_data, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "X-Cover-Source": source, + "ETag": etag_header, + } + ) + + placeholder_svg = ''' + + + + + + + ''' + return Response( + content=placeholder_svg.encode(), + media_type="image/svg+xml", + headers={ + "Cache-Control": "public, max-age=86400", + "X-Cover-Source": "placeholder", + } + ) + + +@router.get("/artist/{artist_id}") +async def get_artist_cover( + request: Request, + artist_id: str, + size: Optional[int] = Query(None, description="Preferred size in pixels for width"), + coverart_repo: CoverArtRepository = Depends(get_coverart_repository) +): + etag_hash = await coverart_repo.get_artist_image_etag(artist_id, size) + etag_header = _quote_etag(etag_hash) if etag_hash else None + if etag_header and _etag_matches(request.headers.get("if-none-match"), etag_header): + return Response( + status_code=304, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "ETag": etag_header, + }, + ) + + result = await coverart_repo.get_artist_image(artist_id, size, is_disconnected=request.is_disconnected) + + if not result: + placeholder_svg = ''' + + + + ''' + return Response( + content=placeholder_svg.encode(), + media_type="image/svg+xml", + headers={ + "Cache-Control": "public, max-age=86400", + "X-Cover-Source": "placeholder", + } + ) + + image_data, content_type, source = result + if not etag_header: + etag_header = _quote_etag(hashlib.sha1(image_data).hexdigest()) + return Response( + content=image_data, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000, immutable", + "X-Cover-Source": source, + "ETag": etag_header, + } + ) + + +@router.get("/debug/artist/{artist_id}") +async def debug_artist_cover( + artist_id: str, + coverart_repo: CoverArtRepository = Depends(get_coverart_repository) +): + """ + Debug endpoint that returns diagnostic info about an artist image fetch. + Shows cache state, Lidarr availability, MusicBrainz relations, and Wikidata URL. + """ + from infrastructure.validators import validate_mbid + + debug_info = { + "artist_id": artist_id, + "is_valid_mbid": False, + "validated_mbid": None, + "disk_cache": { + "exists_250": False, + "exists_500": False, + "negative_250": False, + "negative_500": False, + "meta_250": None, + "meta_500": None, + }, + "lidarr": { + "configured": False, + "has_image_url": False, + "image_url": None, + }, + "musicbrainz": { + "artist_found": False, + "has_wikidata_relation": False, + "wikidata_url": None, + }, + "memory_cache": { + "wikidata_url_cached": False, + "cached_value": None, + }, + "circuit_breakers": {}, + "recommendation": None, + } + + try: + validated_id = validate_mbid(artist_id, "artist") + debug_info["is_valid_mbid"] = True + debug_info["validated_mbid"] = validated_id + except ValueError as e: + debug_info["recommendation"] = f"Invalid MBID format: {e}. No image can be fetched." + return debug_info + + debug_info = await coverart_repo.debug_artist_image(validated_id, debug_info) + + if debug_info["disk_cache"]["negative_250"] or debug_info["disk_cache"]["negative_500"]: + debug_info["recommendation"] = "Artist has a negative cache entry. Wait for expiry or purge negative cache." + elif debug_info["disk_cache"]["exists_250"] or debug_info["disk_cache"]["exists_500"]: + debug_info["recommendation"] = "Image is cached on disk - should load successfully." + elif any( + breaker.get("state") == "open" + for breaker in debug_info.get("circuit_breakers", {}).values() + if isinstance(breaker, dict) + ): + debug_info["recommendation"] = "One or more cover fetch circuit breakers are OPEN. Retry after cooldown or reset breakers." + elif debug_info["lidarr"]["has_image_url"]: + debug_info["recommendation"] = "Lidarr has an image URL - fetch should succeed from Lidarr." + elif debug_info["musicbrainz"]["has_wikidata_relation"]: + debug_info["recommendation"] = "Wikidata URL found - fetch should succeed from Wikidata/Wikimedia." + else: + debug_info["recommendation"] = "No image source found. This artist will show a placeholder." + + return debug_info diff --git a/backend/api/v1/routes/discover.py b/backend/api/v1/routes/discover.py new file mode 100644 index 0000000..f6f7378 --- /dev/null +++ b/backend/api/v1/routes/discover.py @@ -0,0 +1,213 @@ +import logging +from typing import Literal +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from api.v1.schemas.discover import ( + DiscoverResponse, + DiscoverQueueResponse, + DiscoverQueueEnrichment, + DiscoverIgnoredRelease, + DiscoverQueueIgnoreRequest, + DiscoverQueueValidateRequest, + DiscoverQueueValidateResponse, + DiscoverQueueStatusResponse, + QueueGenerateRequest, + QueueGenerateResponse, + YouTubeSearchResponse, + YouTubeQuotaResponse, + TrackCacheCheckRequest, + TrackCacheCheckResponse, + TrackCacheCheckResponseItem, +) +from api.v1.schemas.common import StatusMessageResponse +from core.dependencies import get_discover_service, get_discover_queue_manager, get_youtube_repo +from infrastructure.degradation import try_get_degradation_context +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute + +import msgspec.structs +from repositories.youtube import YouTubeRepository +from services.discover_service import DiscoverService +from services.discover_queue_manager import DiscoverQueueManager + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/discover", tags=["discover"]) + + +@router.get("", response_model=DiscoverResponse) +async def get_discover_data( + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + discover_service: DiscoverService = Depends(get_discover_service), +): + result = await discover_service.get_discover_data(source=source) + ctx = try_get_degradation_context() + if ctx is not None and ctx.has_degradation(): + result = msgspec.structs.replace(result, service_status=ctx.degraded_summary()) + return result + + +@router.post("/refresh", response_model=StatusMessageResponse) +async def refresh_discover_data( + discover_service: DiscoverService = Depends(get_discover_service), +): + await discover_service.refresh_discover_data() + return StatusMessageResponse(status="ok", message="Discover refresh triggered") + + +@router.get("/queue", response_model=DiscoverQueueResponse) +async def get_discover_queue( + count: int | None = Query(default=None, description="Number of items (default from settings, max 20)"), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + discover_service: DiscoverService = Depends(get_discover_service), + queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager), +): + resolved = source or discover_service.resolve_source(None) + cached = await queue_manager.consume_queue(resolved) + if cached: + logger.info("Serving pre-built discover queue (source=%s, items=%d)", resolved, len(cached.items)) + return cached + effective_count = min(count, 20) if count is not None else None + return await queue_manager.build_hydrated_queue(resolved, effective_count) + + +@router.get("/queue/status", response_model=DiscoverQueueStatusResponse) +async def get_queue_status( + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source"), + discover_service: DiscoverService = Depends(get_discover_service), + queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager), +): + resolved = source or discover_service.resolve_source(None) + return queue_manager.get_status(resolved) + + +@router.post("/queue/generate", response_model=QueueGenerateResponse) +async def generate_queue( + body: QueueGenerateRequest = MsgSpecBody(QueueGenerateRequest), + discover_service: DiscoverService = Depends(get_discover_service), + queue_manager: DiscoverQueueManager = Depends(get_discover_queue_manager), +): + resolved = body.source or discover_service.resolve_source(None) + return await queue_manager.start_build(resolved, force=body.force) + + +@router.get("/queue/enrich/{release_group_mbid}", response_model=DiscoverQueueEnrichment) +async def enrich_queue_item( + release_group_mbid: str, + discover_service: DiscoverService = Depends(get_discover_service), +): + return await discover_service.enrich_queue_item(release_group_mbid) + + +@router.post("/queue/ignore", status_code=204) +async def ignore_queue_item( + body: DiscoverQueueIgnoreRequest = MsgSpecBody(DiscoverQueueIgnoreRequest), + discover_service: DiscoverService = Depends(get_discover_service), +): + await discover_service.ignore_release( + body.release_group_mbid, body.artist_mbid, body.release_name, body.artist_name + ) + + +@router.get("/queue/ignored", response_model=list[DiscoverIgnoredRelease]) +async def get_ignored_items( + discover_service: DiscoverService = Depends(get_discover_service), +): + return await discover_service.get_ignored_releases() + + +@router.post("/queue/validate", response_model=DiscoverQueueValidateResponse) +async def validate_queue( + body: DiscoverQueueValidateRequest = MsgSpecBody(DiscoverQueueValidateRequest), + discover_service: DiscoverService = Depends(get_discover_service), +): + in_library = await discover_service.validate_queue_mbids(body.release_group_mbids) + return DiscoverQueueValidateResponse(in_library=in_library) + + +@router.get("/queue/youtube-search", response_model=YouTubeSearchResponse) +async def youtube_search( + artist: str = Query(..., description="Artist name"), + album: str = Query(..., description="Album name"), + yt_repo: YouTubeRepository = Depends(get_youtube_repo), +): + if not yt_repo or not yt_repo.is_configured: + return YouTubeSearchResponse(error="not_configured") + + if yt_repo.quota_remaining <= 0 and not yt_repo.is_cached(artist, album): + return YouTubeSearchResponse(error="quota_exceeded") + + was_cached = yt_repo.is_cached(artist, album) + video_id = await yt_repo.search_video(artist, album) + if video_id: + return YouTubeSearchResponse( + video_id=video_id, + embed_url=f"https://www.youtube.com/embed/{video_id}", + cached=was_cached, + ) + return YouTubeSearchResponse(error="not_found") + + +@router.get("/queue/youtube-track-search", response_model=YouTubeSearchResponse) +async def youtube_track_search( + artist: str = Query(..., description="Artist name"), + track: str = Query(..., description="Track name"), + yt_repo: YouTubeRepository = Depends(get_youtube_repo), +): + if not yt_repo or not yt_repo.is_configured: + return YouTubeSearchResponse(error="not_configured") + + if yt_repo.quota_remaining <= 0 and not yt_repo.is_cached(artist, track): + return YouTubeSearchResponse(error="quota_exceeded") + + was_cached = yt_repo.is_cached(artist, track) + video_id = await yt_repo.search_track(artist, track) + if video_id: + return YouTubeSearchResponse( + video_id=video_id, + embed_url=f"https://www.youtube.com/embed/{video_id}", + cached=was_cached, + ) + return YouTubeSearchResponse(error="not_found") + + +@router.get("/queue/youtube-quota", response_model=YouTubeQuotaResponse) +async def youtube_quota( + yt_repo: YouTubeRepository = Depends(get_youtube_repo), +): + if not yt_repo or not yt_repo.is_configured: + raise HTTPException(status_code=404, detail="YouTube not configured") + return yt_repo.get_quota_status() + + +CACHE_CHECK_MAX_ITEMS = 100 +CACHE_CHECK_MAX_STR_LEN = 200 + + +@router.post("/queue/youtube-cache-check", response_model=TrackCacheCheckResponse) +async def youtube_cache_check( + body: TrackCacheCheckRequest = MsgSpecBody(TrackCacheCheckRequest), + yt_repo: YouTubeRepository = Depends(get_youtube_repo), +): + if not yt_repo or not yt_repo.is_configured: + return TrackCacheCheckResponse() + + seen: set[str] = set() + deduped: list[tuple[str, str]] = [] + for item in body.items[:CACHE_CHECK_MAX_ITEMS]: + artist = item.artist[:CACHE_CHECK_MAX_STR_LEN] + track = item.track[:CACHE_CHECK_MAX_STR_LEN] + key = f"{artist.lower()}|{track.lower()}" + if key not in seen: + seen.add(key) + deduped.append((artist, track)) + + cache_results = yt_repo.are_cached(deduped) + return TrackCacheCheckResponse( + items=[ + TrackCacheCheckResponseItem( + artist=artist, + track=track, + cached=cache_results.get(f"{artist.lower()}|{track.lower()}", False), + ) + for artist, track in deduped + ] + ) diff --git a/backend/api/v1/routes/home.py b/backend/api/v1/routes/home.py new file mode 100644 index 0000000..22a7d26 --- /dev/null +++ b/backend/api/v1/routes/home.py @@ -0,0 +1,144 @@ +import logging +from typing import Literal +from fastapi import APIRouter, Depends, Query, HTTPException +from api.v1.schemas.home import ( + HomeResponse, + HomeIntegrationStatus, + GenreDetailResponse, + GenreArtistResponse, + GenreArtistsBatchResponse, + TrendingArtistsResponse, + TrendingArtistsRangeResponse, + PopularAlbumsResponse, + PopularAlbumsRangeResponse, +) +from core.dependencies import get_home_service, get_home_charts_service +from infrastructure.degradation import try_get_degradation_context +from infrastructure.msgspec_fastapi import MsgSpecRoute + +import msgspec.structs +from services.home_service import HomeService +from services.home_charts_service import HomeChartsService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/home", tags=["home"]) + + +@router.get("", response_model=HomeResponse) +async def get_home_data( + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None, description="Data source: listenbrainz or lastfm"), + home_service: HomeService = Depends(get_home_service), +): + result = await home_service.get_home_data(source=source) + ctx = try_get_degradation_context() + if ctx is not None and ctx.has_degradation(): + result = msgspec.structs.replace(result, service_status=ctx.degraded_summary()) + return result + + +@router.get("/integration-status", response_model=HomeIntegrationStatus) +async def get_integration_status( + home_service: HomeService = Depends(get_home_service) +): + return home_service.get_integration_status() + + +@router.get("/genre/{genre_name}", response_model=GenreDetailResponse) +async def get_genre_detail( + genre_name: str, + limit: int = Query(default=50, ge=1, le=200), + artist_offset: int = Query(default=0, ge=0), + album_offset: int = Query(default=0, ge=0), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_genre_artists( + genre=genre_name, + limit=limit, + artist_offset=artist_offset, + album_offset=album_offset, + ) + + +@router.get("/trending/artists", response_model=TrendingArtistsResponse) +async def get_trending_artists( + limit: int = Query(default=10, ge=1, le=25), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_trending_artists(limit=limit, source=source) + + +@router.get("/trending/artists/{range_key}", response_model=TrendingArtistsRangeResponse) +async def get_trending_artists_by_range( + range_key: str, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_trending_artists_by_range( + range_key=range_key, limit=limit, offset=offset, source=source + ) + + +@router.get("/popular/albums", response_model=PopularAlbumsResponse) +async def get_popular_albums( + limit: int = Query(default=10, ge=1, le=25), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_popular_albums(limit=limit, source=source) + + +@router.get("/popular/albums/{range_key}", response_model=PopularAlbumsRangeResponse) +async def get_popular_albums_by_range( + range_key: str, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_popular_albums_by_range( + range_key=range_key, limit=limit, offset=offset, source=source + ) + + +@router.get("/your-top/albums", response_model=PopularAlbumsResponse) +async def get_your_top_albums( + limit: int = Query(default=10, ge=1, le=25), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_your_top_albums(limit=limit, source=source) + + +@router.get("/your-top/albums/{range_key}", response_model=PopularAlbumsRangeResponse) +async def get_your_top_albums_by_range( + range_key: str, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), + source: Literal["listenbrainz", "lastfm"] | None = Query(default=None), + charts_service: HomeChartsService = Depends(get_home_charts_service) +): + return await charts_service.get_your_top_albums_by_range( + range_key=range_key, limit=limit, offset=offset, source=source + ) + + +@router.get("/genre-artist/{genre_name}", response_model=GenreArtistResponse) +async def get_genre_artist( + genre_name: str, + home_service: HomeService = Depends(get_home_service) +): + artist_mbid = await home_service.get_genre_artist(genre_name) + return GenreArtistResponse(artist_mbid=artist_mbid) + + +@router.post("/genre-artists", response_model=GenreArtistsBatchResponse) +async def get_genre_artists_batch( + genres: list[str], + home_service: HomeService = Depends(get_home_service) +): + results = await home_service.get_genre_artists_batch(genres) + return GenreArtistsBatchResponse(genre_artists=results) diff --git a/backend/api/v1/routes/jellyfin_library.py b/backend/api/v1/routes/jellyfin_library.py new file mode 100644 index 0000000..c7ac4ba --- /dev/null +++ b/backend/api/v1/routes/jellyfin_library.py @@ -0,0 +1,134 @@ +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query + +from api.v1.schemas.jellyfin import ( + JellyfinAlbumDetail, + JellyfinAlbumMatch, + JellyfinAlbumSummary, + JellyfinArtistSummary, + JellyfinLibraryStats, + JellyfinPaginatedResponse, + JellyfinSearchResponse, + JellyfinTrackInfo, +) +from core.dependencies import get_jellyfin_library_service +from core.exceptions import ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.jellyfin_library_service import JellyfinLibraryService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/jellyfin", tags=["jellyfin-library"]) + + +@router.get("/albums", response_model=JellyfinPaginatedResponse) +async def get_jellyfin_albums( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + sort_by: Literal["SortName", "DateCreated", "PlayCount", "ProductionYear"] = Query(default="SortName"), + sort_order: Literal["Ascending", "Descending"] = Query(default="Ascending"), + genre: str | None = Query(default=None), + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> JellyfinPaginatedResponse: + try: + items, total = await service.get_albums( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, genre=genre + ) + return JellyfinPaginatedResponse( + items=items, total=total, offset=offset, limit=limit + ) + except ExternalServiceError as e: + logger.error("Jellyfin service error getting albums: %s", e) + raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin") + + +@router.get("/albums/{album_id}", response_model=JellyfinAlbumDetail) +async def get_jellyfin_album_detail( + album_id: str, + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> JellyfinAlbumDetail: + result = await service.get_album_detail(album_id) + if not result: + raise HTTPException(status_code=404, detail="Album not found") + return result + + +@router.get( + "/albums/{album_id}/tracks", response_model=list[JellyfinTrackInfo] +) +async def get_jellyfin_album_tracks( + album_id: str, + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> list[JellyfinTrackInfo]: + try: + return await service.get_album_tracks(album_id) + except ExternalServiceError as e: + logger.error("Jellyfin service error getting album tracks %s: %s", album_id, e) + raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin") + + +@router.get( + "/albums/match/{musicbrainz_id}", response_model=JellyfinAlbumMatch +) +async def match_jellyfin_album( + musicbrainz_id: str, + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> JellyfinAlbumMatch: + try: + return await service.match_album_by_mbid(musicbrainz_id) + except ExternalServiceError as e: + logger.error("Failed to match Jellyfin album %s: %s", musicbrainz_id, e) + raise HTTPException(status_code=502, detail="Failed to match Jellyfin album") + + +@router.get("/artists", response_model=list[JellyfinArtistSummary]) +async def get_jellyfin_artists( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> list[JellyfinArtistSummary]: + return await service.get_artists(limit=limit, offset=offset) + + +@router.get("/search", response_model=JellyfinSearchResponse) +async def search_jellyfin( + q: str = Query(..., min_length=1), + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> JellyfinSearchResponse: + return await service.search(q) + + +@router.get("/recent", response_model=list[JellyfinAlbumSummary]) +async def get_jellyfin_recent( + limit: int = Query(default=20, ge=1, le=50), + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> list[JellyfinAlbumSummary]: + return await service.get_recently_played(limit=limit) + + +@router.get("/favorites", response_model=list[JellyfinAlbumSummary]) +async def get_jellyfin_favorites( + limit: int = Query(default=20, ge=1, le=50), + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> list[JellyfinAlbumSummary]: + return await service.get_favorites(limit=limit) + + +@router.get("/genres", response_model=list[str]) +async def get_jellyfin_genres( + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> list[str]: + try: + return await service.get_genres() + except ExternalServiceError as e: + logger.error("Jellyfin service error getting genres: %s", e) + raise HTTPException(status_code=502, detail="Failed to communicate with Jellyfin") + + +@router.get("/stats", response_model=JellyfinLibraryStats) +async def get_jellyfin_stats( + service: JellyfinLibraryService = Depends(get_jellyfin_library_service), +) -> JellyfinLibraryStats: + return await service.get_stats() diff --git a/backend/api/v1/routes/lastfm.py b/backend/api/v1/routes/lastfm.py new file mode 100644 index 0000000..be3a516 --- /dev/null +++ b/backend/api/v1/routes/lastfm.py @@ -0,0 +1,132 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from api.v1.schemas.settings import ( + LastFmAuthTokenResponse, + LastFmAuthSessionRequest, + LastFmAuthSessionResponse, + LastFmConnectionSettings, + LASTFM_SECRET_MASK, +) +from core.dependencies import ( + get_lastfm_auth_service, + get_lastfm_repository, + get_preferences_service, + clear_lastfm_dependent_caches, +) +from core.exceptions import ConfigurationError, ExternalServiceError, TokenNotAuthorizedError +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.lastfm_auth_service import LastFmAuthService +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/lastfm", tags=["lastfm"]) + + +@router.post("/auth/token", response_model=LastFmAuthTokenResponse) +async def request_auth_token( + auth_service: LastFmAuthService = Depends(get_lastfm_auth_service), + preferences_service: PreferencesService = Depends(get_preferences_service), +): + try: + settings = preferences_service.get_lastfm_connection() + if not settings.api_key or not settings.shared_secret: + raise HTTPException( + status_code=400, + detail="Add a Last.fm API key and shared secret first", + ) + + token, auth_url = await auth_service.request_token(settings.api_key) + logger.info( + "Last.fm auth token requested", + extra={"step": "token_requested", "status": "success"}, + ) + return LastFmAuthTokenResponse(token=token, auth_url=auth_url) + except HTTPException: + raise + except ConfigurationError as e: + logger.warning( + "Last.fm auth token request failed (config): %s", + e, + extra={"step": "token_requested", "status": "config_error"}, + ) + raise HTTPException(status_code=400, detail="Last.fm settings are incomplete or invalid") + except ExternalServiceError as e: + logger.warning( + "Last.fm auth token request failed (external): %s", + e, + extra={"step": "token_requested", "status": "external_error"}, + ) + raise HTTPException(status_code=502, detail="Couldn't reach Last.fm for a sign-in token") + + +@router.post("/auth/session", response_model=LastFmAuthSessionResponse) +async def exchange_auth_session( + request: LastFmAuthSessionRequest = MsgSpecBody(LastFmAuthSessionRequest), + auth_service: LastFmAuthService = Depends(get_lastfm_auth_service), + preferences_service: PreferencesService = Depends(get_preferences_service), +): + try: + username, session_key, _ = await auth_service.exchange_session(request.token) + + settings = preferences_service.get_lastfm_connection() + updated = LastFmConnectionSettings( + api_key=settings.api_key, + shared_secret=settings.shared_secret, + session_key=session_key, + username=username, + enabled=settings.enabled, + ) + preferences_service.save_lastfm_connection(updated) + get_lastfm_repository.cache_clear() + get_lastfm_auth_service.cache_clear() + clear_lastfm_dependent_caches() + logger.info( + "Last.fm session exchanged for user %s", + username, + extra={"step": "session_exchanged", "status": "success"}, + ) + + return LastFmAuthSessionResponse( + username=username, + success=True, + message=f"Connected as {username}", + ) + except TokenNotAuthorizedError: + message = "Last.fm access hasn't been approved yet. Authorize it in the Last.fm tab, then try again." + error_code = "token_not_authorized" + logger.warning( + "Last.fm session exchange failed: token not authorized", + extra={ + "step": "session_exchanged", + "status": "token_not_authorized", + "error_code": error_code, + }, + ) + raise HTTPException(status_code=502, detail=message) + except ExternalServiceError as e: + message = "Couldn't finish the Last.fm sign-in. Please try again." + error_code = "external_error" + logger.warning( + "Last.fm session exchange failed: %s", + e, + extra={ + "step": "session_exchanged", + "status": "external_error", + "error_code": error_code, + }, + ) + raise HTTPException(status_code=502, detail=message) + except ConfigurationError as e: + logger.warning( + "Last.fm session exchange rejected: %s", + e, + extra={ + "step": "session_exchanged", + "status": "configuration_error", + "error_code": "configuration_error", + }, + ) + raise HTTPException(status_code=422, detail="Last.fm configuration error. Check your settings and try again.") diff --git a/backend/api/v1/routes/library.py b/backend/api/v1/routes/library.py new file mode 100644 index 0000000..8598ab8 --- /dev/null +++ b/backend/api/v1/routes/library.py @@ -0,0 +1,160 @@ +import asyncio +import logging +from fastapi import APIRouter, Depends, HTTPException +from api.v1.schemas.library import ( + LibraryResponse, + LibraryArtistsResponse, + LibraryAlbumsResponse, + PaginatedLibraryAlbumsResponse, + PaginatedLibraryArtistsResponse, + RecentlyAddedResponse, + LibraryStatsResponse, + AlbumRemoveResponse, + AlbumRemovePreviewResponse, + SyncLibraryResponse, + LibraryMbidsResponse, + LibraryGroupedResponse, + TrackResolveRequest, + TrackResolveResponse, +) +from core.dependencies import get_library_service +from core.exceptions import ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecRoute, MsgSpecBody +from services.library_service import LibraryService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/library", tags=["library"]) + + +@router.get("/", response_model=LibraryResponse) +async def get_library( + library_service: LibraryService = Depends(get_library_service) +): + library = await library_service.get_library() + return LibraryResponse(library=library) + + +@router.get("/artists", response_model=PaginatedLibraryArtistsResponse) +async def get_library_artists( + limit: int = 50, + offset: int = 0, + sort_by: str = "name", + sort_order: str = "asc", + q: str | None = None, + library_service: LibraryService = Depends(get_library_service) +): + limit = max(1, min(limit, 100)) + offset = max(0, offset) + allowed_sort = {"name", "album_count", "date_added"} + if sort_by not in allowed_sort: + sort_by = "name" + if sort_order not in ("asc", "desc"): + sort_order = "asc" + artists, total = await library_service.get_artists_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=q, + ) + return PaginatedLibraryArtistsResponse(artists=artists, total=total, offset=offset, limit=limit) + + +@router.get("/albums", response_model=PaginatedLibraryAlbumsResponse) +async def get_library_albums( + limit: int = 50, + offset: int = 0, + sort_by: str = "date_added", + sort_order: str = "desc", + q: str | None = None, + library_service: LibraryService = Depends(get_library_service) +): + limit = max(1, min(limit, 100)) + offset = max(0, offset) + allowed_sort = {"date_added", "artist", "title", "year"} + if sort_by not in allowed_sort: + sort_by = "date_added" + if sort_order not in ("asc", "desc"): + sort_order = "desc" + albums, total = await library_service.get_albums_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=q, + ) + return PaginatedLibraryAlbumsResponse(albums=albums, total=total, offset=offset, limit=limit) + + +@router.get("/recently-added", response_model=RecentlyAddedResponse) +async def get_recently_added( + limit: int = 20, + library_service: LibraryService = Depends(get_library_service) +): + albums = await library_service.get_recently_added(limit=limit) + return RecentlyAddedResponse(albums=albums, artists=[]) + + +@router.post("/sync", response_model=SyncLibraryResponse) +async def sync_library( + library_service: LibraryService = Depends(get_library_service) +): + try: + return await library_service.sync_library(is_manual=True) + except ExternalServiceError as e: + logger.error(f"Couldn't sync the library: {e}") + if "cooldown" in str(e).lower(): + raise HTTPException(status_code=429, detail="Sync is on cooldown, please wait") + raise HTTPException(status_code=503, detail="External service unavailable") + + +@router.get("/stats", response_model=LibraryStatsResponse) +async def get_library_stats( + library_service: LibraryService = Depends(get_library_service) +): + return await library_service.get_stats() + + +@router.get("/mbids", response_model=LibraryMbidsResponse) +async def get_library_mbids( + library_service: LibraryService = Depends(get_library_service) +): + mbids, requested = await asyncio.gather( + library_service.get_library_mbids(), + library_service.get_requested_mbids(), + ) + return LibraryMbidsResponse(mbids=mbids, requested_mbids=requested) + + +@router.get("/grouped", response_model=LibraryGroupedResponse) +async def get_library_grouped( + library_service: LibraryService = Depends(get_library_service) +): + grouped = await library_service.get_library_grouped() + return LibraryGroupedResponse(library=grouped) + + +@router.get("/album/{album_mbid}/removal-preview", response_model=AlbumRemovePreviewResponse) +async def get_album_removal_preview( + album_mbid: str, + library_service: LibraryService = Depends(get_library_service) +): + try: + return await library_service.get_album_removal_preview(album_mbid) + except ExternalServiceError as e: + logger.error(f"Failed to get album removal preview: {e}") + raise HTTPException(status_code=500, detail="Failed to load removal preview") + + +@router.delete("/album/{album_mbid}", response_model=AlbumRemoveResponse) +async def remove_album( + album_mbid: str, + delete_files: bool = False, + library_service: LibraryService = Depends(get_library_service) +): + try: + return await library_service.remove_album(album_mbid, delete_files=delete_files) + except ExternalServiceError as e: + logger.error(f"Couldn't remove album {album_mbid}: {e}") + raise HTTPException(status_code=500, detail="Couldn't remove this album") + + +@router.post("/resolve-tracks", response_model=TrackResolveResponse) +async def resolve_tracks( + body: TrackResolveRequest = MsgSpecBody(TrackResolveRequest), + library_service: LibraryService = Depends(get_library_service), +): + return await library_service.resolve_tracks_batch(body.items) diff --git a/backend/api/v1/routes/local_library.py b/backend/api/v1/routes/local_library.py new file mode 100644 index 0000000..cd5c726 --- /dev/null +++ b/backend/api/v1/routes/local_library.py @@ -0,0 +1,101 @@ +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query + +from api.v1.schemas.local_files import ( + LocalAlbumMatch, + LocalAlbumSummary, + LocalPaginatedResponse, + LocalStorageStats, + LocalTrackInfo, +) +from core.dependencies import get_local_files_service +from core.exceptions import ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.local_files_service import LocalFilesService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/local", tags=["local-files"]) + + +@router.get("/albums", response_model=LocalPaginatedResponse) +async def get_local_albums( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + sort_by: Literal["name", "date_added", "year"] = "name", + sort_order: Literal["asc", "desc"] = Query(default="asc"), + q: str | None = Query(default=None, min_length=1), + service: LocalFilesService = Depends(get_local_files_service), +) -> LocalPaginatedResponse: + try: + return await service.get_albums( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search_query=q + ) + except ExternalServiceError as e: + logger.error("Failed to get local albums: %s", e) + raise HTTPException(status_code=502, detail="Failed to get local albums") + + +@router.get("/albums/match/{musicbrainz_id}", response_model=LocalAlbumMatch) +async def match_local_album( + musicbrainz_id: str, + service: LocalFilesService = Depends(get_local_files_service), +) -> LocalAlbumMatch: + try: + return await service.match_album_by_mbid(musicbrainz_id) + except ExternalServiceError as e: + logger.error("Failed to match local album %s: %s", musicbrainz_id, e) + raise HTTPException(status_code=502, detail="Failed to match local album") + + +@router.get( + "/albums/{album_id}/tracks", response_model=list[LocalTrackInfo] +) +async def get_local_album_tracks( + album_id: int, + service: LocalFilesService = Depends(get_local_files_service), +) -> list[LocalTrackInfo]: + try: + return await service.get_album_tracks_by_id(album_id) + except ExternalServiceError as e: + logger.error("Failed to get local album tracks %d: %s", album_id, e) + raise HTTPException( + status_code=502, detail="Failed to get local album tracks" + ) + + +@router.get("/search", response_model=list[LocalAlbumSummary]) +async def search_local( + q: str = Query(min_length=1), + service: LocalFilesService = Depends(get_local_files_service), +) -> list[LocalAlbumSummary]: + try: + return await service.search(q) + except ExternalServiceError as e: + logger.error("Failed to search local files: %s", e) + raise HTTPException( + status_code=502, detail="Failed to search local files" + ) + + +@router.get("/recent", response_model=list[LocalAlbumSummary]) +async def get_local_recent( + limit: int = Query(default=20, ge=1, le=50), + service: LocalFilesService = Depends(get_local_files_service), +) -> list[LocalAlbumSummary]: + try: + return await service.get_recently_added(limit=limit) + except ExternalServiceError as e: + logger.error("Failed to get recent local albums: %s", e) + raise HTTPException( + status_code=502, detail="Failed to get recent local albums" + ) + + +@router.get("/stats", response_model=LocalStorageStats) +async def get_local_stats( + service: LocalFilesService = Depends(get_local_files_service), +) -> LocalStorageStats: + return await service.get_storage_stats() diff --git a/backend/api/v1/routes/navidrome_library.py b/backend/api/v1/routes/navidrome_library.py new file mode 100644 index 0000000..210f0a0 --- /dev/null +++ b/backend/api/v1/routes/navidrome_library.py @@ -0,0 +1,157 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from api.v1.schemas.navidrome import ( + NavidromeAlbumDetail, + NavidromeAlbumMatch, + NavidromeAlbumPage, + NavidromeAlbumSummary, + NavidromeArtistSummary, + NavidromeLibraryStats, + NavidromeSearchResponse, +) +from core.dependencies import get_navidrome_library_service, get_navidrome_repository +from core.exceptions import ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecRoute +from repositories.navidrome_repository import NavidromeRepository +from services.navidrome_library_service import NavidromeLibraryService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/navidrome", tags=["navidrome-library"]) + + +_SORT_MAP: dict[str, str] = { + "name": "alphabeticalByName", + "date_added": "newest", + "year": "alphabeticalByName", +} + + +@router.get("/albums", response_model=NavidromeAlbumPage) +async def get_navidrome_albums( + limit: int = Query(default=48, ge=1, le=500, alias="limit"), + offset: int = Query(default=0, ge=0), + sort_by: str = Query(default="name"), + genre: str = Query(default=""), + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> NavidromeAlbumPage: + try: + if genre: + subsonic_type = "byGenre" + else: + subsonic_type = _SORT_MAP.get(sort_by, "alphabeticalByName") + items = await service.get_albums(type=subsonic_type, size=limit, offset=offset, genre=genre if genre else None) + stats = await service.get_stats() + total = stats.total_albums if len(items) >= limit else offset + len(items) + return NavidromeAlbumPage(items=items, total=total) + except ExternalServiceError as e: + logger.error("Navidrome service error getting albums: %s", e) + raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome") + + +@router.get("/albums/{album_id}", response_model=NavidromeAlbumDetail) +async def get_navidrome_album_detail( + album_id: str, + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> NavidromeAlbumDetail: + result = await service.get_album_detail(album_id) + if not result: + raise HTTPException(status_code=404, detail="Album not found") + return result + + +@router.get("/artists", response_model=list[NavidromeArtistSummary]) +async def get_navidrome_artists( + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> list[NavidromeArtistSummary]: + return await service.get_artists() + + +@router.get("/artists/{artist_id}") +async def get_navidrome_artist_detail( + artist_id: str, + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> dict: + result = await service.get_artist_detail(artist_id) + if not result: + raise HTTPException(status_code=404, detail="Artist not found") + return result + + +@router.get("/search", response_model=NavidromeSearchResponse) +async def search_navidrome( + q: str = Query(..., min_length=1), + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> NavidromeSearchResponse: + return await service.search(q) + + +@router.get("/recent", response_model=list[NavidromeAlbumSummary]) +async def get_navidrome_recent( + limit: int = Query(default=20, ge=1, le=50), + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> list[NavidromeAlbumSummary]: + return await service.get_recent(limit=limit) + + +@router.get("/favorites", response_model=list[NavidromeAlbumSummary]) +async def get_navidrome_favorites( + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> list[NavidromeAlbumSummary]: + result = await service.get_favorites() + return result.albums + + +@router.get("/genres", response_model=list[str]) +async def get_navidrome_genres( + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> list[str]: + try: + return await service.get_genres() + except ExternalServiceError as e: + logger.error("Navidrome service error getting genres: %s", e) + raise HTTPException(status_code=502, detail="Failed to communicate with Navidrome") + + +@router.get("/stats", response_model=NavidromeLibraryStats) +async def get_navidrome_stats( + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> NavidromeLibraryStats: + return await service.get_stats() + + +@router.get("/cover/{cover_art_id}") +async def get_navidrome_cover( + cover_art_id: str, + size: int = Query(default=500, ge=32, le=1200), + repo: NavidromeRepository = Depends(get_navidrome_repository), +) -> Response: + try: + image_bytes, content_type = await repo.get_cover_art(cover_art_id, size) + return Response( + content=image_bytes, + media_type=content_type, + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) + except ExternalServiceError as e: + logger.warning("Navidrome cover art failed for %s: %s", cover_art_id, e) + raise HTTPException(status_code=502, detail="Failed to fetch cover art") + + +@router.get("/album-match/{album_id}", response_model=NavidromeAlbumMatch) +async def match_navidrome_album( + album_id: str, + name: str = Query(default=""), + artist: str = Query(default=""), + service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> NavidromeAlbumMatch: + try: + return await service.get_album_match( + album_id=album_id, album_name=name, artist_name=artist, + ) + except ExternalServiceError as e: + logger.error("Failed to match Navidrome album %s: %s", album_id, e) + raise HTTPException(status_code=502, detail="Failed to match Navidrome album") diff --git a/backend/api/v1/routes/playlists.py b/backend/api/v1/routes/playlists.py new file mode 100644 index 0000000..b3fde6b --- /dev/null +++ b/backend/api/v1/routes/playlists.py @@ -0,0 +1,360 @@ +import logging +from fastapi import APIRouter, File, UploadFile +from fastapi.responses import FileResponse + +from api.v1.schemas.common import StatusMessageResponse +from api.v1.schemas.playlists import ( + AddTracksRequest, + AddTracksResponse, + CheckTrackMembershipRequest, + CheckTrackMembershipResponse, + CoverUploadResponse, + CreatePlaylistRequest, + PlaylistDetailResponse, + PlaylistListResponse, + PlaylistSummaryResponse, + PlaylistTrackResponse, + RemoveTracksRequest, + ReorderTrackRequest, + ReorderTrackResponse, + ResolveSourcesResponse, + UpdatePlaylistRequest, + UpdateTrackRequest, +) +from core.dependencies import JellyfinLibraryServiceDep, LocalFilesServiceDep, NavidromeLibraryServiceDep, PlaylistServiceDep +from core.exceptions import PlaylistNotFoundError +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute + +logger = logging.getLogger(__name__) + +router = APIRouter( + route_class=MsgSpecRoute, + prefix="/playlists", + tags=["playlists"], +) + + +def _normalize_cover_url(url: str | None) -> str | None: + if url and url.startswith("/api/covers/"): + return "/api/v1/covers/" + url[len("/api/covers/"):] + return url + + +def _normalize_source_type(source_type: str) -> str: + return source_type + + +def _normalize_available_sources(sources: list[str] | None) -> list[str] | None: + if sources is None: + return None + return sources + + +def _custom_cover_url(playlist_id: str, cover_image_path: str | None) -> str | None: + if cover_image_path: + return f"/api/v1/playlists/{playlist_id}/cover" + return None + + +def _track_to_response(t) -> PlaylistTrackResponse: + return PlaylistTrackResponse( + id=t.id, + position=t.position, + track_name=t.track_name, + artist_name=t.artist_name, + album_name=t.album_name, + album_id=t.album_id, + artist_id=t.artist_id, + track_source_id=t.track_source_id, + cover_url=_normalize_cover_url(t.cover_url), + source_type=_normalize_source_type(t.source_type), + available_sources=_normalize_available_sources(t.available_sources), + format=t.format, + track_number=t.track_number, + disc_number=t.disc_number, + duration=t.duration, + created_at=t.created_at, + ) + + +@router.get("", response_model=PlaylistListResponse) +async def list_playlists( + service: PlaylistServiceDep, +) -> PlaylistListResponse: + summaries = await service.get_all_playlists() + return PlaylistListResponse( + playlists=[ + PlaylistSummaryResponse( + id=s.id, + name=s.name, + track_count=s.track_count, + total_duration=s.total_duration, + cover_urls=[_normalize_cover_url(u) for u in s.cover_urls] if s.cover_urls else [], + custom_cover_url=_custom_cover_url(s.id, s.cover_image_path), + created_at=s.created_at, + updated_at=s.updated_at, + ) + for s in summaries + ] + ) + + +@router.post("/check-tracks", response_model=CheckTrackMembershipResponse) +async def check_track_membership( + service: PlaylistServiceDep, + body: CheckTrackMembershipRequest = MsgSpecBody(CheckTrackMembershipRequest), +) -> CheckTrackMembershipResponse: + tracks = [(t.track_name, t.artist_name, t.album_name) for t in body.tracks] + membership = await service.check_track_membership(tracks) + return CheckTrackMembershipResponse(membership=membership) + + +@router.post("", response_model=PlaylistDetailResponse, status_code=201) +async def create_playlist( + service: PlaylistServiceDep, + body: CreatePlaylistRequest = MsgSpecBody(CreatePlaylistRequest), +) -> PlaylistDetailResponse: + playlist = await service.create_playlist(body.name) + return PlaylistDetailResponse( + id=playlist.id, + name=playlist.name, + custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path), + tracks=[], + track_count=0, + total_duration=None, + created_at=playlist.created_at, + updated_at=playlist.updated_at, + ) + + +@router.get("/{playlist_id}", response_model=PlaylistDetailResponse) +async def get_playlist( + playlist_id: str, + service: PlaylistServiceDep, +) -> PlaylistDetailResponse: + playlist, tracks = await service.get_playlist_with_tracks(playlist_id) + track_responses = [_track_to_response(t) for t in tracks] + cover_urls = list(dict.fromkeys(_normalize_cover_url(t.cover_url) for t in tracks if t.cover_url))[:4] + total_duration = sum(t.duration for t in tracks if t.duration) + return PlaylistDetailResponse( + id=playlist.id, + name=playlist.name, + cover_urls=cover_urls, + custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path), + tracks=track_responses, + track_count=len(tracks), + total_duration=total_duration or None, + created_at=playlist.created_at, + updated_at=playlist.updated_at, + ) + + +@router.put("/{playlist_id}", response_model=PlaylistDetailResponse) +async def update_playlist( + playlist_id: str, + service: PlaylistServiceDep, + body: UpdatePlaylistRequest = MsgSpecBody(UpdatePlaylistRequest), +) -> PlaylistDetailResponse: + playlist, tracks = await service.update_playlist_with_detail(playlist_id, name=body.name) + track_responses = [_track_to_response(t) for t in tracks] + cover_urls = list(dict.fromkeys(_normalize_cover_url(t.cover_url) for t in tracks if t.cover_url))[:4] + total_duration = sum(t.duration for t in tracks if t.duration) + return PlaylistDetailResponse( + id=playlist.id, + name=playlist.name, + cover_urls=cover_urls, + custom_cover_url=_custom_cover_url(playlist.id, playlist.cover_image_path), + tracks=track_responses, + track_count=len(tracks), + total_duration=total_duration or None, + created_at=playlist.created_at, + updated_at=playlist.updated_at, + ) + + +@router.delete("/{playlist_id}", response_model=StatusMessageResponse) +async def delete_playlist( + playlist_id: str, + service: PlaylistServiceDep, +) -> StatusMessageResponse: + await service.delete_playlist(playlist_id) + return StatusMessageResponse(status="ok", message="Playlist deleted") + + +@router.post( + "/{playlist_id}/tracks", + response_model=AddTracksResponse, + status_code=201, +) +async def add_tracks( + playlist_id: str, + service: PlaylistServiceDep, + body: AddTracksRequest = MsgSpecBody(AddTracksRequest), +) -> AddTracksResponse: + track_dicts = [ + { + "track_name": t.track_name, + "artist_name": t.artist_name, + "album_name": t.album_name, + "album_id": t.album_id, + "artist_id": t.artist_id, + "track_source_id": t.track_source_id, + "cover_url": t.cover_url, + "source_type": t.source_type, + "available_sources": t.available_sources, + "format": t.format, + "track_number": t.track_number, + "disc_number": t.disc_number, + "duration": int(t.duration) if t.duration is not None else None, + } + for t in body.tracks + ] + created = await service.add_tracks(playlist_id, track_dicts, body.position) + return AddTracksResponse(tracks=[_track_to_response(t) for t in created]) + + +@router.post( + "/{playlist_id}/tracks/remove", + response_model=StatusMessageResponse, +) +async def remove_tracks( + playlist_id: str, + service: PlaylistServiceDep, + body: RemoveTracksRequest = MsgSpecBody(RemoveTracksRequest), +) -> StatusMessageResponse: + removed = await service.remove_tracks(playlist_id, body.track_ids) + return StatusMessageResponse(status="ok", message=f"{removed} track(s) removed") + + +@router.delete( + "/{playlist_id}/tracks/{track_id}", + response_model=StatusMessageResponse, +) +async def remove_track( + playlist_id: str, + track_id: str, + service: PlaylistServiceDep, +) -> StatusMessageResponse: + await service.remove_track(playlist_id, track_id) + return StatusMessageResponse(status="ok", message="Track removed") + + +# Reorder must be registered before the {track_id} PATCH to avoid +# "reorder" being captured as a track_id path parameter. +@router.patch( + "/{playlist_id}/tracks/reorder", + response_model=ReorderTrackResponse, +) +async def reorder_track( + playlist_id: str, + service: PlaylistServiceDep, + body: ReorderTrackRequest = MsgSpecBody(ReorderTrackRequest), +) -> ReorderTrackResponse: + actual_position = await service.reorder_track(playlist_id, body.track_id, body.new_position) + return ReorderTrackResponse( + status="ok", + message="Track reordered", + actual_position=actual_position, + ) + + +@router.patch( + "/{playlist_id}/tracks/{track_id}", + response_model=PlaylistTrackResponse, +) +async def update_track( + playlist_id: str, + track_id: str, + service: PlaylistServiceDep, + jf_service: JellyfinLibraryServiceDep, + local_service: LocalFilesServiceDep, + nd_service: NavidromeLibraryServiceDep, + body: UpdateTrackRequest = MsgSpecBody(UpdateTrackRequest), +) -> PlaylistTrackResponse: + result = await service.update_track_source( + playlist_id, track_id, + source_type=body.source_type, + available_sources=body.available_sources, + jf_service=jf_service, + local_service=local_service, + nd_service=nd_service, + ) + return _track_to_response(result) + + +@router.post( + "/{playlist_id}/resolve-sources", + response_model=ResolveSourcesResponse, +) +async def resolve_sources( + playlist_id: str, + service: PlaylistServiceDep, + jf_service: JellyfinLibraryServiceDep, + local_service: LocalFilesServiceDep, + nd_service: NavidromeLibraryServiceDep, +) -> ResolveSourcesResponse: + sources = await service.resolve_track_sources( + playlist_id, jf_service=jf_service, local_service=local_service, nd_service=nd_service, + ) + return ResolveSourcesResponse(sources=sources) + + +@router.post("/{playlist_id}/cover", response_model=CoverUploadResponse) +async def upload_cover( + playlist_id: str, + service: PlaylistServiceDep, + cover_image: UploadFile = File(...), +) -> CoverUploadResponse: + max_size = 2 * 1024 * 1024 # 2 MB + chunk_size = 8192 + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await cover_image.read(chunk_size) + if not chunk: + break + total += len(chunk) + if total > max_size: + from core.exceptions import InvalidPlaylistDataError + raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB") + chunks.append(chunk) + data = b"".join(chunks) + cover_url = await service.upload_cover( + playlist_id, data, cover_image.content_type or "", + ) + return CoverUploadResponse(cover_url=cover_url) + + +@router.get("/{playlist_id}/cover") +async def get_cover( + playlist_id: str, + service: PlaylistServiceDep, +): + path = await service.get_cover_path(playlist_id) + if path is None: + raise PlaylistNotFoundError("No cover found") + + media_type = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + }.get(path.suffix.lower(), "application/octet-stream") + + return FileResponse( + path, + media_type=media_type, + headers={"Cache-Control": "public, max-age=3600"}, + ) + + +@router.delete( + "/{playlist_id}/cover", + response_model=StatusMessageResponse, +) +async def remove_cover( + playlist_id: str, + service: PlaylistServiceDep, +) -> StatusMessageResponse: + await service.remove_cover(playlist_id) + return StatusMessageResponse(status="ok", message="Cover removed") diff --git a/backend/api/v1/routes/profile.py b/backend/api/v1/routes/profile.py new file mode 100644 index 0000000..54ca235 --- /dev/null +++ b/backend/api/v1/routes/profile.py @@ -0,0 +1,208 @@ +import asyncio +import logging +import uuid +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import FileResponse + +from api.v1.schemas.profile import ( + ProfileResponse, + ProfileSettings, + ProfileUpdateRequest, + ServiceConnection, + LibraryStats, +) +from core.dependencies import ( + get_preferences_service, + get_jellyfin_library_service, + get_local_files_service, + get_navidrome_library_service, + get_settings_service, +) +from core.config import Settings, get_settings +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.preferences_service import PreferencesService +from services.jellyfin_library_service import JellyfinLibraryService +from services.local_files_service import LocalFilesService +from services.navidrome_library_service import NavidromeLibraryService + +logger = logging.getLogger(__name__) + +AVATAR_DIR_NAME = "profile" +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} +MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB + +router = APIRouter(route_class=MsgSpecRoute, prefix="/profile", tags=["profile"]) + + +@router.get("", response_model=ProfileResponse) +async def get_profile( + preferences: PreferencesService = Depends(get_preferences_service), + jellyfin_service: JellyfinLibraryService = Depends(get_jellyfin_library_service), + local_service: LocalFilesService = Depends(get_local_files_service), + navidrome_service: NavidromeLibraryService = Depends(get_navidrome_library_service), +) -> ProfileResponse: + profile = preferences.get_profile_settings() + + services: list[ServiceConnection] = [] + library_stats_list: list[LibraryStats] = [] + + jellyfin_conn = preferences.get_jellyfin_connection() + services.append(ServiceConnection( + name="Jellyfin", + enabled=jellyfin_conn.enabled, + username=jellyfin_conn.user_id, + url=jellyfin_conn.jellyfin_url, + )) + + lb_conn = preferences.get_listenbrainz_connection() + services.append(ServiceConnection( + name="ListenBrainz", + enabled=lb_conn.enabled, + username=lb_conn.username, + url="https://listenbrainz.org", + )) + + lastfm_conn = preferences.get_lastfm_connection() + services.append(ServiceConnection( + name="Last.fm", + enabled=lastfm_conn.enabled, + username=lastfm_conn.username, + url="https://www.last.fm", + )) + + navidrome_conn = preferences.get_navidrome_connection() + services.append(ServiceConnection( + name="Navidrome", + enabled=navidrome_conn.enabled, + username=navidrome_conn.username, + url=navidrome_conn.navidrome_url, + )) + + local_conn = preferences.get_local_files_connection() + + async def _fetch_jellyfin_stats() -> LibraryStats | None: + if not jellyfin_conn.enabled: + return None + try: + s = await jellyfin_service.get_stats() + return LibraryStats(source="Jellyfin", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists) + except Exception as e: + logger.warning("Failed to fetch Jellyfin stats for profile: %s", e) + return None + + async def _fetch_local_stats() -> LibraryStats | None: + if not local_conn.enabled: + return None + try: + s = await local_service.get_storage_stats() + return LibraryStats(source="Local Files", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists, total_size_bytes=s.total_size_bytes, total_size_human=s.total_size_human) + except Exception as e: + logger.warning("Failed to fetch Local Files stats for profile: %s", e) + return None + + async def _fetch_navidrome_stats() -> LibraryStats | None: + if not navidrome_conn.enabled: + return None + try: + s = await navidrome_service.get_stats() + return LibraryStats(source="Navidrome", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists) + except Exception as e: + logger.warning("Failed to fetch Navidrome stats for profile: %s", e) + return None + + results = await asyncio.gather(_fetch_jellyfin_stats(), _fetch_local_stats(), _fetch_navidrome_stats()) + library_stats_list = [r for r in results if r is not None] + + return ProfileResponse( + display_name=profile.display_name, + avatar_url=profile.avatar_url, + services=services, + library_stats=library_stats_list, + ) + + +@router.put("", response_model=ProfileSettings) +async def update_profile( + body: ProfileUpdateRequest = MsgSpecBody(ProfileUpdateRequest), + preferences: PreferencesService = Depends(get_preferences_service), +) -> ProfileSettings: + current = preferences.get_profile_settings() + + updated = ProfileSettings( + display_name=body.display_name if body.display_name is not None else current.display_name, + avatar_url=body.avatar_url if body.avatar_url is not None else current.avatar_url, + ) + + preferences.save_profile_settings(updated) + return updated + + +def _get_avatar_dir() -> Path: + settings = get_settings() + avatar_dir = settings.cache_dir / AVATAR_DIR_NAME + avatar_dir.mkdir(parents=True, exist_ok=True) + return avatar_dir + + +@router.post("/avatar") +async def upload_avatar( + file: UploadFile = File(...), + preferences: PreferencesService = Depends(get_preferences_service), +): + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Invalid image type. Allowed: JPEG, PNG, WebP, GIF") + + data = await file.read() + if len(data) > MAX_AVATAR_SIZE: + raise HTTPException(status_code=400, detail="Image too large. Maximum size is 5 MB") + + ext = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", + }.get(file.content_type, ".jpg") + + avatar_dir = _get_avatar_dir() + + # Remove old avatar files + for old_file in avatar_dir.glob("avatar.*"): + try: + old_file.unlink() + except OSError: + pass + + filename = f"avatar{ext}" + file_path = avatar_dir / filename + file_path.write_bytes(data) + + avatar_url = "/api/v1/profile/avatar" + current = preferences.get_profile_settings() + updated = ProfileSettings( + display_name=current.display_name, + avatar_url=avatar_url, + ) + preferences.save_profile_settings(updated) + + return {"avatar_url": avatar_url} + + +@router.get("/avatar") +async def get_avatar(): + avatar_dir = _get_avatar_dir() + for ext in (".jpg", ".png", ".webp", ".gif"): + file_path = avatar_dir / f"avatar{ext}" + if file_path.exists(): + media_type = { + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", + ".gif": "image/gif", + }[ext] + return FileResponse( + file_path, + media_type=media_type, + headers={"Cache-Control": "public, max-age=3600"}, + ) + raise HTTPException(status_code=404, detail="No avatar found") diff --git a/backend/api/v1/routes/queue.py b/backend/api/v1/routes/queue.py new file mode 100644 index 0000000..924a8a3 --- /dev/null +++ b/backend/api/v1/routes/queue.py @@ -0,0 +1,17 @@ +import logging +from fastapi import APIRouter, Depends, HTTPException +from api.v1.schemas.request import QueueItem +from core.dependencies import get_lidarr_repository +from infrastructure.msgspec_fastapi import MsgSpecRoute +from repositories.lidarr import LidarrRepository + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/queue", tags=["queue"]) + + +@router.get("", response_model=list[QueueItem]) +async def get_queue( + lidarr_repo: LidarrRepository = Depends(get_lidarr_repository) +): + return await lidarr_repo.get_queue() diff --git a/backend/api/v1/routes/requests.py b/backend/api/v1/routes/requests.py new file mode 100644 index 0000000..d5dc2b6 --- /dev/null +++ b/backend/api/v1/routes/requests.py @@ -0,0 +1,30 @@ +import logging +from fastapi import APIRouter, Depends +from api.v1.schemas.request import AlbumRequest, RequestResponse, QueueStatusResponse +from core.dependencies import get_request_service +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.request_service import RequestService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/requests", tags=["requests"]) + + +@router.post("/new", response_model=RequestResponse) +async def request_album( + album_request: AlbumRequest = MsgSpecBody(AlbumRequest), + request_service: RequestService = Depends(get_request_service) +): + return await request_service.request_album( + album_request.musicbrainz_id, + artist=album_request.artist, + album=album_request.album, + year=album_request.year, + ) + + +@router.get("/new/queue-status", response_model=QueueStatusResponse) +async def get_queue_status( + request_service: RequestService = Depends(get_request_service) +): + return request_service.get_queue_status() diff --git a/backend/api/v1/routes/requests_page.py b/backend/api/v1/routes/requests_page.py new file mode 100644 index 0000000..1fbac8e --- /dev/null +++ b/backend/api/v1/routes/requests_page.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import Optional + +from api.v1.schemas.requests_page import ( + ActiveCountResponse, + ActiveRequestsResponse, + CancelRequestResponse, + ClearHistoryResponse, + RequestHistoryResponse, + RetryRequestResponse, +) +from core.dependencies import get_requests_page_service +from infrastructure.validators import validate_mbid +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.requests_page_service import RequestsPageService + +router = APIRouter(route_class=MsgSpecRoute, prefix="/requests", tags=["requests-page"]) + + +@router.get("/active", response_model=ActiveRequestsResponse) +async def get_active_requests( + service: RequestsPageService = Depends(get_requests_page_service), +): + return await service.get_active_requests() + + +@router.get("/active/count", response_model=ActiveCountResponse) +async def get_active_request_count( + service: RequestsPageService = Depends(get_requests_page_service), +): + count = await service.get_active_count() + return ActiveCountResponse(count=count) + + +@router.get("/history", response_model=RequestHistoryResponse) +async def get_request_history( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = Query(None), + sort: Optional[str] = Query(None, pattern="^(newest|oldest|status)$"), + service: RequestsPageService = Depends(get_requests_page_service), +): + return await service.get_request_history( + page=page, page_size=page_size, status_filter=status, sort=sort + ) + + +@router.delete("/active/{musicbrainz_id}", response_model=CancelRequestResponse) +async def cancel_request( + musicbrainz_id: str, + service: RequestsPageService = Depends(get_requests_page_service), +): + try: + musicbrainz_id = validate_mbid(musicbrainz_id, "album") + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid MBID format") + return await service.cancel_request(musicbrainz_id) + + +@router.post("/retry/{musicbrainz_id}", response_model=RetryRequestResponse) +async def retry_request( + musicbrainz_id: str, + service: RequestsPageService = Depends(get_requests_page_service), +): + try: + musicbrainz_id = validate_mbid(musicbrainz_id, "album") + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid MBID format") + return await service.retry_request(musicbrainz_id) + + +@router.delete("/history/{musicbrainz_id}", response_model=ClearHistoryResponse) +async def clear_history_item( + musicbrainz_id: str, + service: RequestsPageService = Depends(get_requests_page_service), +): + try: + musicbrainz_id = validate_mbid(musicbrainz_id, "album") + except ValueError as e: + raise HTTPException(status_code=400, detail="Invalid MBID format") + deleted = await service.clear_history_item(musicbrainz_id) + return ClearHistoryResponse(success=deleted) diff --git a/backend/api/v1/routes/scrobble.py b/backend/api/v1/routes/scrobble.py new file mode 100644 index 0000000..42bf7c9 --- /dev/null +++ b/backend/api/v1/routes/scrobble.py @@ -0,0 +1,47 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException + +from api.v1.schemas.scrobble import ( + NowPlayingRequest, + ScrobbleRequest, + ScrobbleResponse, +) +from core.dependencies import get_scrobble_service +from core.exceptions import ConfigurationError, ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.scrobble_service import ScrobbleService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/scrobble", tags=["scrobble"]) + + +@router.post("/now-playing", response_model=ScrobbleResponse) +async def report_now_playing( + request: NowPlayingRequest = MsgSpecBody(NowPlayingRequest), + scrobble_service: ScrobbleService = Depends(get_scrobble_service), +) -> ScrobbleResponse: + try: + return await scrobble_service.report_now_playing(request) + except ConfigurationError as e: + logger.warning("Scrobble now-playing config error: %s", e) + raise HTTPException(status_code=400, detail="Scrobble not configured") + except ExternalServiceError as e: + logger.warning("Scrobble now-playing service error: %s", e) + raise HTTPException(status_code=502, detail="Scrobble service unavailable") + + +@router.post("/submit", response_model=ScrobbleResponse) +async def submit_scrobble( + request: ScrobbleRequest = MsgSpecBody(ScrobbleRequest), + scrobble_service: ScrobbleService = Depends(get_scrobble_service), +) -> ScrobbleResponse: + try: + return await scrobble_service.submit_scrobble(request) + except ConfigurationError as e: + logger.warning("Scrobble submit config error: %s", e) + raise HTTPException(status_code=400, detail="Scrobble not configured") + except ExternalServiceError as e: + logger.warning("Scrobble submit service error: %s", e) + raise HTTPException(status_code=502, detail="Scrobble service unavailable") diff --git a/backend/api/v1/routes/search.py b/backend/api/v1/routes/search.py new file mode 100644 index 0000000..2eda16c --- /dev/null +++ b/backend/api/v1/routes/search.py @@ -0,0 +1,127 @@ +import logging +import time +from fastapi import APIRouter, Query, Path, BackgroundTasks, Depends, Request +from core.exceptions import ClientDisconnectedError +from api.v1.schemas.search import ( + SearchResponse, + SearchBucketResponse, + EnrichmentResponse, + EnrichmentBatchRequest, + SuggestResponse, +) +from core.dependencies import get_search_service, get_coverart_repository, get_search_enrichment_service +from infrastructure.degradation import try_get_degradation_context +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute + +import msgspec.structs +from services.search_service import SearchService +from services.search_enrichment_service import SearchEnrichmentService +from repositories.coverart_repository import CoverArtRepository + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/search", tags=["search"]) + + +@router.get("", response_model=SearchResponse) +async def search( + request: Request, + background_tasks: BackgroundTasks, + q: str = Query(..., min_length=1, description="Search term"), + limit_per_bucket: int | None = Query( + None, ge=1, le=100, + description="Max items per bucket (deprecated, use limit_artists/limit_albums)" + ), + limit_artists: int = Query(10, ge=0, le=100, description="Max artists to return"), + limit_albums: int = Query(10, ge=0, le=100, description="Max albums to return"), + buckets: str | None = Query( + None, description="Comma-separated subset: artists,albums" + ), + search_service: SearchService = Depends(get_search_service), + coverart_repo: CoverArtRepository = Depends(get_coverart_repository) +): + if await request.is_disconnected(): + raise ClientDisconnectedError("Client disconnected") + + buckets_list = [b.strip().lower() for b in buckets.split(",")] if buckets else None + + final_limit_artists = limit_per_bucket if limit_per_bucket else limit_artists + final_limit_albums = limit_per_bucket if limit_per_bucket else limit_albums + + result = await search_service.search( + query=q, + limit_artists=final_limit_artists, + limit_albums=final_limit_albums, + buckets=buckets_list + ) + + ctx = try_get_degradation_context() + if ctx is not None and ctx.has_degradation(): + result = msgspec.structs.replace(result, service_status=ctx.degraded_summary()) + + album_ids = search_service.schedule_cover_prefetch(result.albums) + if album_ids: + background_tasks.add_task( + coverart_repo.batch_prefetch_covers, + album_ids, + "250" + ) + + return result + + +@router.get("/suggest", response_model=SuggestResponse) +async def suggest( + q: str = Query(..., min_length=2, description="Search query"), + limit: int = Query(5, ge=1, le=10, description="Max results"), + search_service: SearchService = Depends(get_search_service), +) -> SuggestResponse: + stripped = q.strip() + if len(stripped) < 2: + return SuggestResponse() + start = time.monotonic() + result = await search_service.suggest(query=stripped, limit=limit) + elapsed_ms = (time.monotonic() - start) * 1000 + logger.debug("Suggest query_len=%d results=%d time_ms=%.1f", len(stripped), len(result.results), elapsed_ms) + return result + + +@router.get("/{bucket}", response_model=SearchBucketResponse) +async def search_bucket( + bucket: str = Path(..., pattern="^(artists|albums)$"), + q: str = Query(..., min_length=1, description="Search term"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Pagination offset"), + search_service: SearchService = Depends(get_search_service) +): + results, top_result = await search_service.search_bucket( + bucket=bucket, + query=q, + limit=limit, + offset=offset + ) + return SearchBucketResponse(bucket=bucket, limit=limit, offset=offset, results=results, top_result=top_result) + + +@router.get("/enrich/batch", response_model=EnrichmentResponse) +async def enrich_search_results( + artist_mbids: str = Query("", description="Comma-separated artist MBIDs"), + album_mbids: str = Query("", description="Comma-separated album MBIDs"), + enrichment_service: SearchEnrichmentService = Depends(get_search_enrichment_service) +): + artist_list = [m.strip() for m in artist_mbids.split(",") if m.strip()] + album_list = [m.strip() for m in album_mbids.split(",") if m.strip()] + + return await enrichment_service.enrich( + artist_mbids=artist_list, + album_mbids=album_list, + ) + + +@router.post("/enrich/batch", response_model=EnrichmentResponse) +async def enrich_search_results_post( + body: EnrichmentBatchRequest = MsgSpecBody(EnrichmentBatchRequest), + enrichment_service: SearchEnrichmentService = Depends(get_search_enrichment_service), +): + return await enrichment_service.enrich_batch(body) + diff --git a/backend/api/v1/routes/settings.py b/backend/api/v1/routes/settings.py new file mode 100644 index 0000000..c95567e --- /dev/null +++ b/backend/api/v1/routes/settings.py @@ -0,0 +1,484 @@ +import logging +import msgspec +from fastapi import APIRouter, Depends, HTTPException +from api.v1.schemas.settings import ( + UserPreferences, + LidarrSettings, + LidarrConnectionSettings, + JellyfinConnectionSettings, + JellyfinVerifyResponse, + JellyfinUserInfo, + NavidromeConnectionSettings, + ListenBrainzConnectionSettings, + YouTubeConnectionSettings, + HomeSettings, + LidarrVerifyResponse, + LocalFilesConnectionSettings, + LocalFilesVerifyResponse, + LidarrMetadataProfilePreferences, + LidarrMetadataProfileSummary, + LastFmConnectionSettings, + LastFmConnectionSettingsResponse, + LastFmVerifyResponse, + ScrobbleSettings, + PrimaryMusicSourceSettings, +) +from api.v1.schemas.common import VerifyConnectionResponse +from api.v1.schemas.advanced_settings import AdvancedSettingsFrontend, FrontendCacheTTLs, _is_masked_api_key +from core.dependencies import ( + get_preferences_service, + get_settings_service, + get_local_files_service, +) +from core.exceptions import ConfigurationError, ExternalServiceError +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from services.local_files_service import LocalFilesService +from services.preferences_service import PreferencesService +from services.settings_service import SettingsService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/settings", tags=["settings"]) + + +@router.get("/preferences", response_model=UserPreferences) +async def get_preferences( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_preferences() + + +@router.put("/preferences", response_model=UserPreferences) +async def update_preferences( + preferences: UserPreferences = MsgSpecBody(UserPreferences), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_preferences(preferences) + total_cleared = await settings_service.clear_caches_for_preference_change() + logger.info(f"Updated user preferences. Cleared {total_cleared} cache entries.") + return preferences + except ConfigurationError as e: + logger.warning(f"Configuration error updating preferences: {e}") + raise HTTPException(status_code=400, detail="Couldn't save these settings") + + +@router.get("/lidarr", response_model=LidarrSettings) +async def get_lidarr_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_lidarr_settings() + + +@router.put("/lidarr", response_model=LidarrSettings) +async def update_lidarr_settings( + lidarr_settings: LidarrSettings = MsgSpecBody(LidarrSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), +): + try: + preferences_service.save_lidarr_settings(lidarr_settings) + logger.info(f"Updated Lidarr settings: sync_frequency={lidarr_settings.sync_frequency}") + return lidarr_settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating Lidarr settings: {e}") + raise HTTPException(status_code=400, detail="Lidarr settings are incomplete or invalid") + + +@router.get("/cache-ttls", response_model=FrontendCacheTTLs) +async def get_frontend_cache_ttls( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + backend_settings = preferences_service.get_advanced_settings() + return FrontendCacheTTLs( + home=backend_settings.frontend_ttl_home, + discover=backend_settings.frontend_ttl_discover, + library=backend_settings.frontend_ttl_library, + recently_added=backend_settings.frontend_ttl_recently_added, + discover_queue=backend_settings.frontend_ttl_discover_queue, + search=backend_settings.frontend_ttl_search, + local_files_sidebar=backend_settings.frontend_ttl_local_files_sidebar, + jellyfin_sidebar=backend_settings.frontend_ttl_jellyfin_sidebar, + playlist_sources=backend_settings.frontend_ttl_playlist_sources, + discover_queue_polling_interval=backend_settings.discover_queue_polling_interval, + discover_queue_auto_generate=backend_settings.discover_queue_auto_generate, + ) + + +@router.get("/advanced", response_model=AdvancedSettingsFrontend) +async def get_advanced_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + backend_settings = preferences_service.get_advanced_settings() + return AdvancedSettingsFrontend.from_backend(backend_settings) + + +@router.put("/advanced", response_model=AdvancedSettingsFrontend) +async def update_advanced_settings( + settings: AdvancedSettingsFrontend = MsgSpecBody(AdvancedSettingsFrontend), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + backend_settings = settings.to_backend() + if _is_masked_api_key(backend_settings.audiodb_api_key): + current = preferences_service.get_advanced_settings() + backend_settings = msgspec.structs.replace( + backend_settings, audiodb_api_key=current.audiodb_api_key + ) + preferences_service.save_advanced_settings(backend_settings) + await settings_service.on_coverart_settings_changed() + logger.info("Updated advanced settings") + saved = preferences_service.get_advanced_settings() + return AdvancedSettingsFrontend.from_backend(saved) + except ConfigurationError as e: + logger.warning(f"Configuration error updating advanced settings: {e}") + raise HTTPException(status_code=400, detail="Couldn't save these settings") + except ValueError as e: + logger.warning(f"Validation error updating advanced settings: {e}") + raise HTTPException(status_code=400, detail="That settings value isn't valid") + + +@router.get("/lidarr/connection", response_model=LidarrConnectionSettings) +async def get_lidarr_connection( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_lidarr_connection() + + +@router.put("/lidarr/connection", response_model=LidarrConnectionSettings) +async def update_lidarr_connection( + settings: LidarrConnectionSettings = MsgSpecBody(LidarrConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + from repositories.lidarr.base import reset_lidarr_circuit_breaker + + preferences_service.save_lidarr_connection(settings) + reset_lidarr_circuit_breaker() + await settings_service.on_lidarr_settings_changed() + logger.info("Updated Lidarr connection settings") + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating Lidarr connection: {e}") + raise HTTPException(status_code=400, detail="Lidarr connection settings are incomplete or invalid") + + +@router.post("/lidarr/verify", response_model=LidarrVerifyResponse) +async def verify_lidarr_connection( + settings: LidarrConnectionSettings = MsgSpecBody(LidarrConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + return await settings_service.verify_lidarr(settings) + + +@router.get( + "/lidarr/metadata-profiles", + response_model=list[LidarrMetadataProfileSummary], +) +async def list_lidarr_metadata_profiles( + settings_service: SettingsService = Depends(get_settings_service), +): + try: + return await settings_service.list_lidarr_metadata_profiles() + except ExternalServiceError as e: + logger.warning(f"Lidarr metadata profiles list failed: {e}") + raise HTTPException(status_code=502, detail="Couldn't load Lidarr metadata profiles") + + +@router.get( + "/lidarr/metadata-profile/preferences", + response_model=LidarrMetadataProfilePreferences, +) +async def get_lidarr_metadata_profile_preferences( + profile_id: int | None = None, + settings_service: SettingsService = Depends(get_settings_service), +): + try: + return await settings_service.get_lidarr_metadata_profile_preferences( + profile_id=profile_id + ) + except ExternalServiceError as e: + logger.warning(f"Lidarr metadata profile fetch failed: {e}") + raise HTTPException(status_code=502, detail="Couldn't load the Lidarr metadata profile") + + +@router.put( + "/lidarr/metadata-profile/preferences", + response_model=LidarrMetadataProfilePreferences, +) +async def update_lidarr_metadata_profile_preferences( + preferences: UserPreferences = MsgSpecBody(UserPreferences), + profile_id: int | None = None, + settings_service: SettingsService = Depends(get_settings_service), +): + try: + return await settings_service.update_lidarr_metadata_profile( + preferences, profile_id=profile_id + ) + except ExternalServiceError as e: + logger.warning(f"Lidarr metadata profile update failed: {e}") + raise HTTPException(status_code=502, detail="Couldn't update the Lidarr metadata profile") + + +@router.get("/jellyfin", response_model=JellyfinConnectionSettings) +async def get_jellyfin_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_jellyfin_connection() + + +@router.put("/jellyfin", response_model=JellyfinConnectionSettings) +async def update_jellyfin_settings( + settings: JellyfinConnectionSettings = MsgSpecBody(JellyfinConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_jellyfin_connection(settings) + await settings_service.on_jellyfin_settings_changed() + logger.info("Updated Jellyfin connection settings") + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating Jellyfin settings: {e}") + raise HTTPException(status_code=400, detail="Jellyfin settings are incomplete or invalid") + + +@router.post("/jellyfin/verify", response_model=JellyfinVerifyResponse) +async def verify_jellyfin_connection( + settings: JellyfinConnectionSettings = MsgSpecBody(JellyfinConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_jellyfin(settings) + users = [JellyfinUserInfo(id=user.id, name=user.name) for user in (result.users or [])] if result.success else [] + return JellyfinVerifyResponse(success=result.success, message=result.message, users=users) + + +@router.get("/navidrome", response_model=NavidromeConnectionSettings) +async def get_navidrome_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_navidrome_connection() + + +@router.put("/navidrome", response_model=NavidromeConnectionSettings) +async def update_navidrome_settings( + settings: NavidromeConnectionSettings = MsgSpecBody(NavidromeConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_navidrome_connection(settings) + await settings_service.on_navidrome_settings_changed(enabled=settings.enabled) + logger.info("Updated Navidrome connection settings") + return preferences_service.get_navidrome_connection() + except ConfigurationError as e: + logger.warning("Configuration error updating Navidrome settings: %s", e) + raise HTTPException(status_code=400, detail="Navidrome settings are incomplete or invalid") + + +@router.post("/navidrome/verify", response_model=VerifyConnectionResponse) +async def verify_navidrome_connection( + settings: NavidromeConnectionSettings = MsgSpecBody(NavidromeConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_navidrome(settings) + return VerifyConnectionResponse(valid=result.valid, message=result.message) + + +@router.get("/listenbrainz", response_model=ListenBrainzConnectionSettings) +async def get_listenbrainz_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_listenbrainz_connection() + + +@router.put("/listenbrainz", response_model=ListenBrainzConnectionSettings) +async def update_listenbrainz_settings( + settings: ListenBrainzConnectionSettings = MsgSpecBody(ListenBrainzConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_listenbrainz_connection(settings) + await settings_service.on_listenbrainz_settings_changed() + logger.info("Updated ListenBrainz connection settings") + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating ListenBrainz settings: {e}") + raise HTTPException(status_code=400, detail="ListenBrainz settings are incomplete or invalid") + + +@router.post("/listenbrainz/verify", response_model=VerifyConnectionResponse) +async def verify_listenbrainz_connection( + settings: ListenBrainzConnectionSettings = MsgSpecBody(ListenBrainzConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_listenbrainz(settings) + return VerifyConnectionResponse(valid=result.valid, message=result.message) + + +@router.get("/youtube", response_model=YouTubeConnectionSettings) +async def get_youtube_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_youtube_connection() + + +@router.put("/youtube", response_model=YouTubeConnectionSettings) +async def update_youtube_settings( + settings: YouTubeConnectionSettings = MsgSpecBody(YouTubeConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_youtube_connection(settings) + await settings_service.on_youtube_settings_changed() + logger.info("Updated YouTube connection settings") + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating YouTube settings: {e}") + raise HTTPException(status_code=400, detail="YouTube settings are incomplete or invalid") + + +@router.post("/youtube/verify", response_model=VerifyConnectionResponse) +async def verify_youtube_connection( + settings: YouTubeConnectionSettings = MsgSpecBody(YouTubeConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_youtube(settings) + return VerifyConnectionResponse(valid=result.valid, message=result.message) + + +@router.get("/home", response_model=HomeSettings) +async def get_home_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_home_settings() + + +@router.put("/home", response_model=HomeSettings) +async def update_home_settings( + settings: HomeSettings = MsgSpecBody(HomeSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_home_settings(settings) + await settings_service.clear_home_cache() + logger.info("Updated home settings") + return settings + except ConfigurationError as e: + logger.warning(f"Configuration error updating home settings: {e}") + raise HTTPException(status_code=400, detail="Home settings are incomplete or invalid") + + +@router.get("/local-files", response_model=LocalFilesConnectionSettings) +async def get_local_files_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_local_files_connection() + + +@router.put("/local-files", response_model=LocalFilesConnectionSettings) +async def update_local_files_settings( + settings: LocalFilesConnectionSettings = MsgSpecBody(LocalFilesConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_local_files_connection(settings) + await settings_service.on_local_files_settings_changed() + logger.info("Updated local files settings") + return settings + except ConfigurationError as e: + logger.warning("Configuration error updating local files settings: %s", e) + raise HTTPException(status_code=400, detail="Local files settings are incomplete or invalid") + + +@router.post("/local-files/verify", response_model=LocalFilesVerifyResponse) +async def verify_local_files_connection( + settings: LocalFilesConnectionSettings = MsgSpecBody(LocalFilesConnectionSettings), + local_service: LocalFilesService = Depends(get_local_files_service), +) -> LocalFilesVerifyResponse: + return await local_service.verify_path(settings.music_path) + + +@router.get("/lastfm", response_model=LastFmConnectionSettingsResponse) +async def get_lastfm_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + settings = preferences_service.get_lastfm_connection() + return LastFmConnectionSettingsResponse.from_settings(settings) + + +@router.put("/lastfm", response_model=LastFmConnectionSettingsResponse) +async def update_lastfm_settings( + settings: LastFmConnectionSettings = MsgSpecBody(LastFmConnectionSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_lastfm_connection(settings) + await settings_service.on_lastfm_settings_changed() + logger.info("Updated Last.fm connection settings") + saved = preferences_service.get_lastfm_connection() + return LastFmConnectionSettingsResponse.from_settings(saved) + except ConfigurationError as e: + logger.warning("Configuration error updating Last.fm settings: %s", e) + raise HTTPException(status_code=400, detail="Last.fm settings are incomplete or invalid") + + +@router.post("/lastfm/verify", response_model=LastFmVerifyResponse) +async def verify_lastfm_connection( + settings: LastFmConnectionSettings = MsgSpecBody(LastFmConnectionSettings), + settings_service: SettingsService = Depends(get_settings_service), +): + result = await settings_service.verify_lastfm(settings) + return LastFmVerifyResponse(valid=result.valid, message=result.message) + + +@router.get("/scrobble", response_model=ScrobbleSettings) +async def get_scrobble_settings( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_scrobble_settings() + + +@router.put("/scrobble", response_model=ScrobbleSettings) +async def update_scrobble_settings( + settings: ScrobbleSettings = MsgSpecBody(ScrobbleSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), +): + try: + preferences_service.save_scrobble_settings(settings) + logger.info("Updated scrobble settings") + return preferences_service.get_scrobble_settings() + except ConfigurationError as e: + logger.warning("Configuration error updating scrobble settings: %s", e) + raise HTTPException(status_code=400, detail="Scrobbling settings are incomplete or invalid") + + +@router.get("/primary-source", response_model=PrimaryMusicSourceSettings) +async def get_primary_music_source( + preferences_service: PreferencesService = Depends(get_preferences_service), +): + return preferences_service.get_primary_music_source() + + +@router.put("/primary-source", response_model=PrimaryMusicSourceSettings) +async def update_primary_music_source( + settings: PrimaryMusicSourceSettings = MsgSpecBody(PrimaryMusicSourceSettings), + preferences_service: PreferencesService = Depends(get_preferences_service), + settings_service: SettingsService = Depends(get_settings_service), +): + try: + preferences_service.save_primary_music_source(settings) + await settings_service.clear_home_cache() + await settings_service.clear_source_resolution_cache() + logger.info("Updated primary music source to %s", settings.source) + return preferences_service.get_primary_music_source() + except ConfigurationError as e: + logger.warning("Configuration error updating primary music source: %s", e) + raise HTTPException(status_code=400, detail="Invalid primary music source") diff --git a/backend/api/v1/routes/status.py b/backend/api/v1/routes/status.py new file mode 100644 index 0000000..c225ef0 --- /dev/null +++ b/backend/api/v1/routes/status.py @@ -0,0 +1,17 @@ +import logging +from fastapi import APIRouter, Depends +from api.v1.schemas.common import StatusReport +from core.dependencies import get_status_service +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.status_service import StatusService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/status", tags=["status"]) + + +@router.get("", response_model=StatusReport) +async def get_status( + status_service: StatusService = Depends(get_status_service) +): + return await status_service.get_status() diff --git a/backend/api/v1/routes/stream.py b/backend/api/v1/routes/stream.py new file mode 100644 index 0000000..3b3ccec --- /dev/null +++ b/backend/api/v1/routes/stream.py @@ -0,0 +1,255 @@ +import logging + +from fastapi import APIRouter, Body, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse, Response, StreamingResponse + +from api.v1.schemas.stream import ( + JellyfinPlaybackUrlResponse, + PlaybackSessionResponse, + ProgressReportRequest, + StartPlaybackRequest, + StopReportRequest, +) +from core.dependencies import ( + get_jellyfin_repository, + get_jellyfin_playback_service, + get_local_files_service, + get_navidrome_playback_service, +) +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute +from repositories.jellyfin_repository import JellyfinRepository +from services.jellyfin_playback_service import JellyfinPlaybackService +from services.local_files_service import LocalFilesService +from services.navidrome_playback_service import NavidromePlaybackService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/stream", tags=["streaming"]) + + +@router.get("/jellyfin/{item_id}") +async def stream_jellyfin_audio( + item_id: str, + jellyfin_repo: JellyfinRepository = Depends(get_jellyfin_repository), +) -> JellyfinPlaybackUrlResponse: + try: + playback = await jellyfin_repo.get_playback_url(item_id) + logger.info( + "Resolved Jellyfin playback metadata", + extra={ + "item_id": item_id, + "play_method": playback.play_method, + "seekable": playback.seekable, + }, + ) + return JellyfinPlaybackUrlResponse( + url=playback.url, + seekable=playback.seekable, + playSessionId=playback.play_session_id, + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Audio item not found") + except PlaybackNotAllowedError as e: + logger.warning("Playback not allowed for %s: %s", item_id, e) + raise HTTPException(status_code=403, detail="Playback not allowed") + except ExternalServiceError as e: + raise HTTPException(status_code=502, detail="Failed to stream from Jellyfin") + + +@router.head("/jellyfin/{item_id}") +async def head_jellyfin_audio( + item_id: str, + jellyfin_repo: JellyfinRepository = Depends(get_jellyfin_repository), +) -> Response: + try: + playback = await jellyfin_repo.get_playback_url(item_id) + logger.info( + "Resolved Jellyfin playback prefetch redirect", + extra={ + "item_id": item_id, + "play_method": playback.play_method, + "seekable": playback.seekable, + }, + ) + return RedirectResponse( + url=playback.url, + status_code=302, + headers={"Referrer-Policy": "no-referrer"}, + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Audio item not found") + except PlaybackNotAllowedError as e: + logger.warning("Playback not allowed for %s: %s", item_id, e) + raise HTTPException(status_code=403, detail="Playback not allowed") + except ExternalServiceError as e: + logger.error("Jellyfin head stream error for %s: %s", item_id, e) + raise HTTPException(status_code=502, detail="Failed to resolve Jellyfin stream") + + +@router.post("/jellyfin/{item_id}/start", response_model=PlaybackSessionResponse) +async def start_jellyfin_playback( + item_id: str, + body: StartPlaybackRequest | None = Body(default=None), + playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service), +) -> PlaybackSessionResponse: + try: + play_session_id = await playback_service.start_playback( + item_id, + play_session_id=body.play_session_id if body else None, + ) + return PlaybackSessionResponse(play_session_id=play_session_id, item_id=item_id) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Item not found") + except PlaybackNotAllowedError as e: + logger.warning("Playback not allowed for %s: %s", item_id, e) + raise HTTPException(status_code=403, detail="Playback not allowed") + except ExternalServiceError as e: + logger.error("Failed to start playback for %s: %s", item_id, e) + raise HTTPException(status_code=502, detail="Failed to start Jellyfin playback") + + +@router.post("/jellyfin/{item_id}/progress", status_code=204) +async def report_jellyfin_progress( + item_id: str, + body: ProgressReportRequest = MsgSpecBody(ProgressReportRequest), + playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service), +) -> Response: + try: + await playback_service.report_progress( + item_id=item_id, + play_session_id=body.play_session_id, + position_seconds=body.position_seconds, + is_paused=body.is_paused, + ) + return Response(status_code=204) + except ExternalServiceError as e: + logger.warning("Progress report failed for %s: %s", item_id, e) + raise HTTPException(status_code=502, detail="Failed to report progress") + + +@router.post("/jellyfin/{item_id}/stop", status_code=204) +async def stop_jellyfin_playback( + item_id: str, + body: StopReportRequest = MsgSpecBody(StopReportRequest), + playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service), +) -> Response: + try: + await playback_service.stop_playback( + item_id=item_id, + play_session_id=body.play_session_id, + position_seconds=body.position_seconds, + ) + return Response(status_code=204) + except ExternalServiceError as e: + logger.warning("Stop report failed for %s: %s", item_id, e) + raise HTTPException(status_code=502, detail="Failed to report playback stop") + + +@router.head("/local/{track_id}") +async def head_local_file( + track_id: int, + local_service: LocalFilesService = Depends(get_local_files_service), +) -> Response: + try: + headers = await local_service.head_track(track_id) + return Response( + status_code=200, + headers=headers, + media_type=headers.get("Content-Type", "application/octet-stream"), + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found") + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found on disk") + except PermissionError: + raise HTTPException(status_code=403, detail="Access denied — path outside music directory") + except ExternalServiceError as e: + logger.error("Local head error for track %s: %s", track_id, e) + raise HTTPException(status_code=502, detail="Failed to check local file") + except OSError as e: + logger.error("OS error checking local track %s: %s", track_id, e) + raise HTTPException(status_code=500, detail="Failed to read local file") + + +@router.get("/local/{track_id}") +async def stream_local_file( + track_id: int, + request: Request, + local_service: LocalFilesService = Depends(get_local_files_service), +) -> StreamingResponse: + try: + range_header = request.headers.get("Range") + chunks, headers, status_code = await local_service.stream_track( + track_file_id=track_id, + range_header=range_header, + ) + return StreamingResponse( + content=chunks, + status_code=status_code, + headers=headers, + media_type=headers.get("Content-Type", "application/octet-stream"), + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found") + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found on disk") + except PermissionError: + raise HTTPException(status_code=403, detail="Access denied — path outside music directory") + except ExternalServiceError as e: + detail = str(e) + if "Range not satisfiable" in detail: + raise HTTPException(status_code=416, detail="Range not satisfiable") + logger.error("Local stream error for track %s: %s", track_id, e) + raise HTTPException(status_code=502, detail="Failed to stream local file") + except OSError as e: + logger.error("OS error streaming local track %s: %s", track_id, e) + raise HTTPException(status_code=500, detail="Failed to read local file") + + +@router.head("/navidrome/{item_id}") +async def head_navidrome_audio( + item_id: str, + playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service), +) -> Response: + try: + return await playback_service.proxy_head(item_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid stream request") + except ExternalServiceError: + raise HTTPException(status_code=502, detail="Failed to stream from Navidrome") + + +@router.get("/navidrome/{item_id}") +async def stream_navidrome_audio( + item_id: str, + request: Request, + playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service), +) -> StreamingResponse: + try: + return await playback_service.proxy_stream(item_id, request.headers.get("Range")) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid stream request") + except ExternalServiceError as e: + detail = str(e) + if "416" in detail or "Range not satisfiable" in detail: + raise HTTPException(status_code=416, detail="Range not satisfiable") + raise HTTPException(status_code=502, detail="Failed to stream from Navidrome") + + +@router.post("/navidrome/{item_id}/scrobble") +async def scrobble_navidrome( + item_id: str, + playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service), +) -> dict[str, str]: + ok = await playback_service.scrobble(item_id) + return {"status": "ok" if ok else "error"} + + +@router.post("/navidrome/{item_id}/now-playing") +async def navidrome_now_playing( + item_id: str, + playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service), +) -> dict[str, str]: + ok = await playback_service.report_now_playing(item_id) + return {"status": "ok" if ok else "error"} diff --git a/backend/api/v1/routes/youtube.py b/backend/api/v1/routes/youtube.py new file mode 100644 index 0000000..b1159a8 --- /dev/null +++ b/backend/api/v1/routes/youtube.py @@ -0,0 +1,175 @@ +import logging + +from fastapi import APIRouter, Response + +from api.v1.schemas.discover import YouTubeQuotaResponse +from api.v1.schemas.youtube import ( + YouTubeLink, + YouTubeLinkGenerateRequest, + YouTubeLinkResponse, + YouTubeLinkUpdateRequest, + YouTubeManualLinkRequest, + YouTubeTrackLink, + YouTubeTrackLinkBatchGenerateRequest, + YouTubeTrackLinkBatchResponse, + YouTubeTrackLinkGenerateRequest, + YouTubeTrackLinkResponse, +) +from core.dependencies import YouTubeServiceDep +from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/youtube", tags=["YouTube"]) + + +@router.post("/generate", response_model=YouTubeLinkResponse) +async def generate_link( + youtube_service: YouTubeServiceDep, + request: YouTubeLinkGenerateRequest = MsgSpecBody(YouTubeLinkGenerateRequest), +) -> YouTubeLinkResponse: + link = await youtube_service.generate_link( + artist_name=request.artist_name, + album_name=request.album_name, + album_id=request.album_id, + cover_url=request.cover_url, + ) + quota = youtube_service.get_quota_status() + return YouTubeLinkResponse( + link=link, + quota=quota, + ) + + +@router.get("/link/{album_id}", response_model=YouTubeLink | None) +async def get_link( + album_id: str, + youtube_service: YouTubeServiceDep, +) -> YouTubeLink | Response: + link = await youtube_service.get_link(album_id) + if link is None: + return Response(status_code=204) + return link + + +@router.get("/links", response_model=list[YouTubeLink]) +async def get_all_links( + youtube_service: YouTubeServiceDep, +) -> list[YouTubeLink]: + return await youtube_service.get_all_links() + + +@router.delete("/link/{album_id}", status_code=204) +async def delete_link( + album_id: str, + youtube_service: YouTubeServiceDep, +) -> None: + await youtube_service.delete_link(album_id) + + +@router.put("/link/{album_id}", response_model=YouTubeLink) +async def update_link( + album_id: str, + youtube_service: YouTubeServiceDep, + request: YouTubeLinkUpdateRequest = MsgSpecBody(YouTubeLinkUpdateRequest), +) -> YouTubeLink: + return await youtube_service.update_link( + album_id=album_id, + youtube_url=request.youtube_url, + album_name=request.album_name, + artist_name=request.artist_name, + cover_url=request.cover_url, + ) + + +@router.post("/manual", response_model=YouTubeLink) +async def save_manual_link( + youtube_service: YouTubeServiceDep, + request: YouTubeManualLinkRequest = MsgSpecBody(YouTubeManualLinkRequest), +) -> YouTubeLink: + return await youtube_service.save_manual_link( + album_name=request.album_name, + artist_name=request.artist_name, + youtube_url=request.youtube_url, + cover_url=request.cover_url, + album_id=request.album_id, + ) + + +@router.post("/generate-track", response_model=YouTubeTrackLinkResponse) +async def generate_track_link( + youtube_service: YouTubeServiceDep, + request: YouTubeTrackLinkGenerateRequest = MsgSpecBody(YouTubeTrackLinkGenerateRequest), +) -> YouTubeTrackLinkResponse: + track_link = await youtube_service.generate_track_link( + album_id=request.album_id, + album_name=request.album_name, + artist_name=request.artist_name, + track_name=request.track_name, + track_number=request.track_number, + disc_number=request.disc_number, + cover_url=request.cover_url, + ) + quota = youtube_service.get_quota_status() + return YouTubeTrackLinkResponse( + track_link=track_link, + quota=quota, + ) + + +@router.post("/generate-tracks", response_model=YouTubeTrackLinkBatchResponse) +async def generate_track_links_batch( + youtube_service: YouTubeServiceDep, + request: YouTubeTrackLinkBatchGenerateRequest = MsgSpecBody(YouTubeTrackLinkBatchGenerateRequest), +) -> YouTubeTrackLinkBatchResponse: + tracks = [ + {"track_name": t.track_name, "track_number": t.track_number, "disc_number": t.disc_number} + for t in request.tracks + ] + generated, failed = await youtube_service.generate_track_links_batch( + album_id=request.album_id, + album_name=request.album_name, + artist_name=request.artist_name, + tracks=tracks, + cover_url=request.cover_url, + ) + quota = youtube_service.get_quota_status() + return YouTubeTrackLinkBatchResponse( + track_links=generated, + failed=failed, + quota=quota, + ) + + +@router.get("/track-links/{album_id}", response_model=list[YouTubeTrackLink]) +async def get_track_links( + album_id: str, + youtube_service: YouTubeServiceDep, +) -> list[YouTubeTrackLink]: + return await youtube_service.get_track_links(album_id) + + +@router.delete("/track-link/{album_id}/{track_number}", status_code=204, deprecated=True) +async def delete_track_link_legacy( + album_id: str, + track_number: int, + youtube_service: YouTubeServiceDep, +) -> None: + await youtube_service.delete_track_link(album_id, 1, track_number) + + +@router.delete("/track-link/{album_id}/{disc_number}/{track_number}", status_code=204) +async def delete_track_link( + album_id: str, + disc_number: int, + track_number: int, + youtube_service: YouTubeServiceDep, +) -> None: + await youtube_service.delete_track_link(album_id, disc_number, track_number) + + +@router.get("/quota", response_model=YouTubeQuotaResponse) +async def get_quota( + youtube_service: YouTubeServiceDep, +) -> YouTubeQuotaResponse: + return youtube_service.get_quota_status() diff --git a/backend/api/v1/schemas/__init__.py b/backend/api/v1/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/schemas/advanced_settings.py b/backend/api/v1/schemas/advanced_settings.py new file mode 100644 index 0000000..f6e8552 --- /dev/null +++ b/backend/api/v1/schemas/advanced_settings.py @@ -0,0 +1,529 @@ +import msgspec + +from infrastructure.msgspec_fastapi import AppStruct + + +def _validate_range(value: int | float, field_name: str, minimum: int | float, maximum: int | float) -> None: + if value < minimum or value > maximum: + raise msgspec.ValidationError(f"{field_name} must be between {minimum} and {maximum}") + + +def _coerce_positive_int(value: object, field_name: str) -> int: + if value is None: + raise msgspec.ValidationError(f"{field_name} cannot be null") + try: + result = int(float(value)) + except (TypeError, ValueError) as exc: + raise msgspec.ValidationError(f"Invalid integer value for {field_name}: {value}") from exc + if result <= 0: + raise msgspec.ValidationError(f"{field_name} must be positive") + return result + + +def _mask_api_key(key: str) -> str: + if len(key) > 3: + return f"***…{key[-3:]}" + return "***" + + +def _is_masked_api_key(value: str) -> bool: + # Known limitation: a real key starting with "***" would be treated as masked + # and discarded on save, preserving the previous key instead. + return value.startswith("***") + + +class AdvancedSettings(AppStruct): + cache_ttl_album_library: int = 86400 + cache_ttl_album_non_library: int = 21600 + cache_ttl_artist_library: int = 21600 + cache_ttl_artist_non_library: int = 21600 + cache_ttl_artist_discovery_library: int = 21600 + cache_ttl_artist_discovery_non_library: int = 3600 + cache_ttl_search: int = 3600 + cache_ttl_local_files_recently_added: int = 120 + cache_ttl_local_files_storage_stats: int = 300 + cache_ttl_jellyfin_recently_played: int = 300 + cache_ttl_jellyfin_favorites: int = 300 + cache_ttl_jellyfin_genres: int = 3600 + cache_ttl_jellyfin_library_stats: int = 600 + cache_ttl_navidrome_albums: int = 300 + cache_ttl_navidrome_artists: int = 300 + cache_ttl_navidrome_recent: int = 120 + cache_ttl_navidrome_favorites: int = 120 + cache_ttl_navidrome_search: int = 120 + cache_ttl_navidrome_genres: int = 3600 + cache_ttl_navidrome_stats: int = 600 + http_timeout: int = 10 + http_connect_timeout: int = 5 + http_max_connections: int = 200 + batch_artist_images: int = 5 + batch_albums: int = 3 + delay_artist: float = 0.5 + delay_albums: float = 1.0 + artist_discovery_warm_interval: int = 14400 + artist_discovery_warm_delay: float = 0.5 + artist_discovery_precache_delay: float = 0.3 + memory_cache_max_entries: int = 10000 + memory_cache_cleanup_interval: int = 300 + cover_memory_cache_max_entries: int = 128 + cover_memory_cache_max_size_mb: int = 16 + disk_cache_cleanup_interval: int = 600 + recent_metadata_max_size_mb: int = 500 + recent_covers_max_size_mb: int = 1024 + persistent_metadata_ttl_hours: int = 24 + musicbrainz_concurrent_searches: int = 6 + discover_queue_size: int = 10 + discover_queue_ttl: int = 86400 + discover_queue_auto_generate: bool = True + discover_queue_polling_interval: int = 4000 + discover_queue_warm_cycle_build: bool = True + discover_queue_seed_artists: int = 3 + discover_queue_wildcard_slots: int = 2 + discover_queue_similar_artists_limit: int = 15 + discover_queue_albums_per_similar: int = 5 + discover_queue_enrich_ttl: int = 86400 + discover_queue_lastfm_mbid_max_lookups: int = 10 + frontend_ttl_home: int = 300000 + frontend_ttl_discover: int = 1800000 + frontend_ttl_library: int = 300000 + frontend_ttl_recently_added: int = 300000 + frontend_ttl_discover_queue: int = 86400000 + frontend_ttl_search: int = 300000 + frontend_ttl_local_files_sidebar: int = 120000 + frontend_ttl_jellyfin_sidebar: int = 120000 + frontend_ttl_playlist_sources: int = 900000 + audiodb_enabled: bool = True + audiodb_name_search_fallback: bool = False + direct_remote_images_enabled: bool = True + audiodb_api_key: str = "123" + cache_ttl_audiodb_found: int = 604800 + cache_ttl_audiodb_not_found: int = 86400 + cache_ttl_audiodb_library: int = 1209600 + cache_ttl_recently_viewed_bytes: int = 172800 + genre_section_ttl: int = 21600 + request_history_retention_days: int = 180 + ignored_releases_retention_days: int = 365 + orphan_cover_demote_interval_hours: int = 24 + store_prune_interval_hours: int = 6 + + def __post_init__(self) -> None: + if not self.audiodb_api_key or not self.audiodb_api_key.strip(): + self.audiodb_api_key = "123" + ranges: dict[str, tuple[int | float, int | float]] = { + "cache_ttl_album_library": (3600, 604800), + "cache_ttl_album_non_library": (60, 86400), + "cache_ttl_artist_library": (3600, 604800), + "cache_ttl_artist_non_library": (3600, 604800), + "cache_ttl_artist_discovery_library": (3600, 604800), + "cache_ttl_artist_discovery_non_library": (3600, 604800), + "cache_ttl_search": (60, 86400), + "cache_ttl_local_files_recently_added": (60, 3600), + "cache_ttl_local_files_storage_stats": (60, 3600), + "cache_ttl_jellyfin_recently_played": (60, 3600), + "cache_ttl_jellyfin_favorites": (60, 3600), + "cache_ttl_jellyfin_genres": (60, 86400), + "cache_ttl_jellyfin_library_stats": (60, 3600), + "cache_ttl_navidrome_albums": (60, 3600), + "cache_ttl_navidrome_artists": (60, 3600), + "cache_ttl_navidrome_recent": (60, 3600), + "cache_ttl_navidrome_favorites": (60, 3600), + "cache_ttl_navidrome_search": (60, 3600), + "cache_ttl_navidrome_genres": (60, 86400), + "cache_ttl_navidrome_stats": (60, 3600), + "http_timeout": (5, 60), + "http_connect_timeout": (1, 30), + "http_max_connections": (50, 500), + "batch_artist_images": (1, 20), + "batch_albums": (1, 20), + "delay_artist": (0.0, 5.0), + "delay_albums": (0.0, 5.0), + "artist_discovery_warm_interval": (300, 604800), + "artist_discovery_warm_delay": (0.0, 5.0), + "artist_discovery_precache_delay": (0.0, 5.0), + "memory_cache_max_entries": (1000, 100000), + "memory_cache_cleanup_interval": (60, 3600), + "cover_memory_cache_max_entries": (16, 2048), + "cover_memory_cache_max_size_mb": (1, 1024), + "disk_cache_cleanup_interval": (60, 3600), + "recent_metadata_max_size_mb": (100, 5000), + "recent_covers_max_size_mb": (100, 10000), + "persistent_metadata_ttl_hours": (1, 168), + "musicbrainz_concurrent_searches": (2, 10), + "discover_queue_size": (1, 20), + "discover_queue_ttl": (3600, 604800), + "discover_queue_polling_interval": (1000, 30000), + "discover_queue_seed_artists": (1, 10), + "discover_queue_wildcard_slots": (0, 10), + "discover_queue_similar_artists_limit": (5, 50), + "discover_queue_albums_per_similar": (1, 20), + "discover_queue_enrich_ttl": (3600, 604800), + "discover_queue_lastfm_mbid_max_lookups": (1, 50), + "frontend_ttl_home": (60000, 3600000), + "frontend_ttl_discover": (60000, 86400000), + "frontend_ttl_library": (60000, 3600000), + "frontend_ttl_recently_added": (60000, 3600000), + "frontend_ttl_discover_queue": (3600000, 604800000), + "frontend_ttl_search": (60000, 3600000), + "frontend_ttl_local_files_sidebar": (60000, 3600000), + "frontend_ttl_jellyfin_sidebar": (60000, 3600000), + "frontend_ttl_playlist_sources": (60000, 3600000), + "cache_ttl_audiodb_found": (3600, 2592000), + "cache_ttl_audiodb_not_found": (3600, 604800), + "cache_ttl_audiodb_library": (86400, 2592000), + "cache_ttl_recently_viewed_bytes": (3600, 604800), + "genre_section_ttl": (3600, 604800), + "request_history_retention_days": (30, 3650), + "ignored_releases_retention_days": (30, 3650), + "orphan_cover_demote_interval_hours": (1, 168), + "store_prune_interval_hours": (1, 168), + } + for field_name, (minimum, maximum) in ranges.items(): + _validate_range(getattr(self, field_name), field_name, minimum, maximum) + + +class FrontendCacheTTLs(AppStruct): + home: int = 300000 + discover: int = 1800000 + library: int = 300000 + recently_added: int = 300000 + discover_queue: int = 86400000 + search: int = 300000 + local_files_sidebar: int = 120000 + jellyfin_sidebar: int = 120000 + playlist_sources: int = 900000 + discover_queue_polling_interval: int = 4000 + discover_queue_auto_generate: bool = True + + +class AdvancedSettingsFrontend(AppStruct): + cache_ttl_album_library: int = 24 + cache_ttl_album_non_library: int = 6 + cache_ttl_artist_library: int = 6 + cache_ttl_artist_non_library: int = 6 + cache_ttl_artist_discovery_library: int = 6 + cache_ttl_artist_discovery_non_library: int = 1 + cache_ttl_search: int = 60 + cache_ttl_local_files_recently_added: int = 2 + cache_ttl_local_files_storage_stats: int = 5 + cache_ttl_jellyfin_recently_played: int = 5 + cache_ttl_jellyfin_favorites: int = 5 + cache_ttl_jellyfin_genres: int = 60 + cache_ttl_jellyfin_library_stats: int = 10 + cache_ttl_navidrome_albums: int = 5 + cache_ttl_navidrome_artists: int = 5 + cache_ttl_navidrome_recent: int = 2 + cache_ttl_navidrome_favorites: int = 2 + cache_ttl_navidrome_search: int = 2 + cache_ttl_navidrome_genres: int = 60 + cache_ttl_navidrome_stats: int = 10 + http_timeout: int = 10 + http_connect_timeout: int = 5 + http_max_connections: int = 200 + batch_artist_images: int = 5 + batch_albums: int = 3 + delay_artist: float = 0.5 + delay_albums: float = 1.0 + artist_discovery_warm_interval: int = 240 + artist_discovery_warm_delay: float = 0.5 + artist_discovery_precache_delay: float = 0.3 + memory_cache_max_entries: int = 10000 + memory_cache_cleanup_interval: int = 300 + cover_memory_cache_max_entries: int = 128 + cover_memory_cache_max_size_mb: int = 16 + disk_cache_cleanup_interval: int = 10 + recent_metadata_max_size_mb: int = 500 + recent_covers_max_size_mb: int = 1024 + persistent_metadata_ttl_hours: int = 24 + musicbrainz_concurrent_searches: int = 6 + discover_queue_size: int = 10 + discover_queue_ttl: int = 24 + discover_queue_auto_generate: bool = True + discover_queue_polling_interval: int = 4 + discover_queue_warm_cycle_build: bool = True + discover_queue_seed_artists: int = 3 + discover_queue_wildcard_slots: int = 2 + discover_queue_similar_artists_limit: int = 15 + discover_queue_albums_per_similar: int = 5 + discover_queue_enrich_ttl: int = 24 + discover_queue_lastfm_mbid_max_lookups: int = 10 + frontend_ttl_home: int = 5 + frontend_ttl_discover: int = 30 + frontend_ttl_library: int = 5 + frontend_ttl_recently_added: int = 5 + frontend_ttl_discover_queue: int = 1440 + frontend_ttl_search: int = 5 + frontend_ttl_local_files_sidebar: int = 2 + frontend_ttl_jellyfin_sidebar: int = 2 + frontend_ttl_playlist_sources: int = 15 + audiodb_enabled: bool = True + audiodb_name_search_fallback: bool = False + direct_remote_images_enabled: bool = True + audiodb_api_key: str = "123" + cache_ttl_audiodb_found: int = 168 + cache_ttl_audiodb_not_found: int = 24 + cache_ttl_audiodb_library: int = 336 + cache_ttl_recently_viewed_bytes: int = 48 + genre_section_ttl: int = 6 + request_history_retention_days: int = 180 + ignored_releases_retention_days: int = 365 + orphan_cover_demote_interval_hours: int = 24 + store_prune_interval_hours: int = 6 + + def __post_init__(self) -> None: + int_coerce_fields = [ + "cache_ttl_album_library", + "cache_ttl_album_non_library", + "cache_ttl_artist_library", + "cache_ttl_artist_non_library", + "cache_ttl_artist_discovery_library", + "cache_ttl_artist_discovery_non_library", + "cache_ttl_search", + "cache_ttl_local_files_recently_added", + "cache_ttl_local_files_storage_stats", + "cache_ttl_jellyfin_recently_played", + "cache_ttl_jellyfin_favorites", + "cache_ttl_jellyfin_genres", + "cache_ttl_jellyfin_library_stats", + "cache_ttl_navidrome_albums", + "cache_ttl_navidrome_artists", + "cache_ttl_navidrome_recent", + "cache_ttl_navidrome_favorites", + "cache_ttl_navidrome_search", + "cache_ttl_navidrome_genres", + "cache_ttl_navidrome_stats", + "cache_ttl_audiodb_found", + "cache_ttl_audiodb_not_found", + "cache_ttl_audiodb_library", + "cache_ttl_recently_viewed_bytes", + "genre_section_ttl", + "request_history_retention_days", + "ignored_releases_retention_days", + "orphan_cover_demote_interval_hours", + "store_prune_interval_hours", + ] + for field_name in int_coerce_fields: + setattr(self, field_name, _coerce_positive_int(getattr(self, field_name), field_name)) + + ranges: dict[str, tuple[int | float, int | float]] = { + "cache_ttl_album_library": (1, 168), + "cache_ttl_album_non_library": (1, 24), + "cache_ttl_artist_library": (1, 168), + "cache_ttl_artist_non_library": (1, 168), + "cache_ttl_artist_discovery_library": (1, 168), + "cache_ttl_artist_discovery_non_library": (1, 168), + "cache_ttl_search": (1, 1440), + "cache_ttl_local_files_recently_added": (1, 60), + "cache_ttl_local_files_storage_stats": (1, 60), + "cache_ttl_jellyfin_recently_played": (1, 60), + "cache_ttl_jellyfin_favorites": (1, 60), + "cache_ttl_jellyfin_genres": (1, 1440), + "cache_ttl_jellyfin_library_stats": (1, 60), + "cache_ttl_navidrome_albums": (1, 60), + "cache_ttl_navidrome_artists": (1, 60), + "cache_ttl_navidrome_recent": (1, 60), + "cache_ttl_navidrome_favorites": (1, 60), + "cache_ttl_navidrome_search": (1, 60), + "cache_ttl_navidrome_genres": (1, 1440), + "cache_ttl_navidrome_stats": (1, 60), + "http_timeout": (5, 60), + "http_connect_timeout": (1, 30), + "http_max_connections": (50, 500), + "batch_artist_images": (1, 20), + "batch_albums": (1, 20), + "delay_artist": (0.0, 5.0), + "delay_albums": (0.0, 5.0), + "artist_discovery_warm_interval": (5, 10080), + "artist_discovery_warm_delay": (0.0, 5.0), + "artist_discovery_precache_delay": (0.0, 5.0), + "memory_cache_max_entries": (1000, 100000), + "memory_cache_cleanup_interval": (60, 3600), + "cover_memory_cache_max_entries": (16, 2048), + "cover_memory_cache_max_size_mb": (1, 1024), + "disk_cache_cleanup_interval": (1, 60), + "recent_metadata_max_size_mb": (100, 5000), + "recent_covers_max_size_mb": (100, 10000), + "persistent_metadata_ttl_hours": (1, 168), + "musicbrainz_concurrent_searches": (2, 10), + "discover_queue_size": (1, 20), + "discover_queue_ttl": (1, 168), + "discover_queue_polling_interval": (1, 30), + "discover_queue_seed_artists": (1, 10), + "discover_queue_wildcard_slots": (0, 10), + "discover_queue_similar_artists_limit": (5, 50), + "discover_queue_albums_per_similar": (1, 20), + "discover_queue_enrich_ttl": (1, 168), + "discover_queue_lastfm_mbid_max_lookups": (1, 50), + "frontend_ttl_home": (1, 60), + "frontend_ttl_discover": (1, 1440), + "frontend_ttl_library": (1, 60), + "frontend_ttl_recently_added": (1, 60), + "frontend_ttl_discover_queue": (60, 10080), + "frontend_ttl_search": (1, 60), + "frontend_ttl_local_files_sidebar": (1, 60), + "frontend_ttl_jellyfin_sidebar": (1, 60), + "frontend_ttl_playlist_sources": (1, 60), + "cache_ttl_audiodb_found": (1, 720), + "cache_ttl_audiodb_not_found": (1, 168), + "cache_ttl_audiodb_library": (24, 720), + "cache_ttl_recently_viewed_bytes": (1, 168), + "genre_section_ttl": (1, 168), + "request_history_retention_days": (30, 3650), + "ignored_releases_retention_days": (30, 3650), + "orphan_cover_demote_interval_hours": (1, 168), + "store_prune_interval_hours": (1, 168), + } + for field_name, (minimum, maximum) in ranges.items(): + _validate_range(getattr(self, field_name), field_name, minimum, maximum) + + @staticmethod + def from_backend(settings: AdvancedSettings) -> "AdvancedSettingsFrontend": + return AdvancedSettingsFrontend( + cache_ttl_album_library=settings.cache_ttl_album_library // 3600, + cache_ttl_album_non_library=settings.cache_ttl_album_non_library // 3600, + cache_ttl_artist_library=settings.cache_ttl_artist_library // 3600, + cache_ttl_artist_non_library=settings.cache_ttl_artist_non_library // 3600, + cache_ttl_artist_discovery_library=settings.cache_ttl_artist_discovery_library // 3600, + cache_ttl_artist_discovery_non_library=settings.cache_ttl_artist_discovery_non_library // 3600, + cache_ttl_search=settings.cache_ttl_search // 60, + cache_ttl_local_files_recently_added=settings.cache_ttl_local_files_recently_added // 60, + cache_ttl_local_files_storage_stats=settings.cache_ttl_local_files_storage_stats // 60, + cache_ttl_jellyfin_recently_played=settings.cache_ttl_jellyfin_recently_played // 60, + cache_ttl_jellyfin_favorites=settings.cache_ttl_jellyfin_favorites // 60, + cache_ttl_jellyfin_genres=settings.cache_ttl_jellyfin_genres // 60, + cache_ttl_jellyfin_library_stats=settings.cache_ttl_jellyfin_library_stats // 60, + cache_ttl_navidrome_albums=settings.cache_ttl_navidrome_albums // 60, + cache_ttl_navidrome_artists=settings.cache_ttl_navidrome_artists // 60, + cache_ttl_navidrome_recent=settings.cache_ttl_navidrome_recent // 60, + cache_ttl_navidrome_favorites=settings.cache_ttl_navidrome_favorites // 60, + cache_ttl_navidrome_search=settings.cache_ttl_navidrome_search // 60, + cache_ttl_navidrome_genres=settings.cache_ttl_navidrome_genres // 60, + cache_ttl_navidrome_stats=settings.cache_ttl_navidrome_stats // 60, + http_timeout=settings.http_timeout, + http_connect_timeout=settings.http_connect_timeout, + http_max_connections=settings.http_max_connections, + batch_artist_images=settings.batch_artist_images, + batch_albums=settings.batch_albums, + delay_artist=settings.delay_artist, + delay_albums=settings.delay_albums, + artist_discovery_warm_interval=settings.artist_discovery_warm_interval // 60, + artist_discovery_warm_delay=settings.artist_discovery_warm_delay, + artist_discovery_precache_delay=settings.artist_discovery_precache_delay, + memory_cache_max_entries=settings.memory_cache_max_entries, + memory_cache_cleanup_interval=settings.memory_cache_cleanup_interval, + cover_memory_cache_max_entries=settings.cover_memory_cache_max_entries, + cover_memory_cache_max_size_mb=settings.cover_memory_cache_max_size_mb, + disk_cache_cleanup_interval=settings.disk_cache_cleanup_interval // 60, + recent_metadata_max_size_mb=settings.recent_metadata_max_size_mb, + recent_covers_max_size_mb=settings.recent_covers_max_size_mb, + persistent_metadata_ttl_hours=settings.persistent_metadata_ttl_hours, + musicbrainz_concurrent_searches=settings.musicbrainz_concurrent_searches, + discover_queue_size=settings.discover_queue_size, + discover_queue_ttl=settings.discover_queue_ttl // 3600, + discover_queue_auto_generate=settings.discover_queue_auto_generate, + discover_queue_polling_interval=settings.discover_queue_polling_interval // 1000, + discover_queue_warm_cycle_build=settings.discover_queue_warm_cycle_build, + discover_queue_seed_artists=settings.discover_queue_seed_artists, + discover_queue_wildcard_slots=settings.discover_queue_wildcard_slots, + discover_queue_similar_artists_limit=settings.discover_queue_similar_artists_limit, + discover_queue_albums_per_similar=settings.discover_queue_albums_per_similar, + discover_queue_enrich_ttl=settings.discover_queue_enrich_ttl // 3600, + discover_queue_lastfm_mbid_max_lookups=settings.discover_queue_lastfm_mbid_max_lookups, + frontend_ttl_home=settings.frontend_ttl_home // 60000, + frontend_ttl_discover=settings.frontend_ttl_discover // 60000, + frontend_ttl_library=settings.frontend_ttl_library // 60000, + frontend_ttl_recently_added=settings.frontend_ttl_recently_added // 60000, + frontend_ttl_discover_queue=settings.frontend_ttl_discover_queue // 60000, + frontend_ttl_search=settings.frontend_ttl_search // 60000, + frontend_ttl_local_files_sidebar=settings.frontend_ttl_local_files_sidebar // 60000, + frontend_ttl_jellyfin_sidebar=settings.frontend_ttl_jellyfin_sidebar // 60000, + frontend_ttl_playlist_sources=settings.frontend_ttl_playlist_sources // 60000, + audiodb_enabled=settings.audiodb_enabled, + audiodb_name_search_fallback=settings.audiodb_name_search_fallback, + direct_remote_images_enabled=settings.direct_remote_images_enabled, + audiodb_api_key=_mask_api_key(settings.audiodb_api_key), + cache_ttl_audiodb_found=settings.cache_ttl_audiodb_found // 3600, + cache_ttl_audiodb_not_found=settings.cache_ttl_audiodb_not_found // 3600, + cache_ttl_audiodb_library=settings.cache_ttl_audiodb_library // 3600, + cache_ttl_recently_viewed_bytes=settings.cache_ttl_recently_viewed_bytes // 3600, + genre_section_ttl=settings.genre_section_ttl // 3600, + request_history_retention_days=settings.request_history_retention_days, + ignored_releases_retention_days=settings.ignored_releases_retention_days, + orphan_cover_demote_interval_hours=settings.orphan_cover_demote_interval_hours, + store_prune_interval_hours=settings.store_prune_interval_hours, + ) + + def to_backend(self) -> AdvancedSettings: + return AdvancedSettings( + cache_ttl_album_library=self.cache_ttl_album_library * 3600, + cache_ttl_album_non_library=self.cache_ttl_album_non_library * 3600, + cache_ttl_artist_library=self.cache_ttl_artist_library * 3600, + cache_ttl_artist_non_library=self.cache_ttl_artist_non_library * 3600, + cache_ttl_artist_discovery_library=self.cache_ttl_artist_discovery_library * 3600, + cache_ttl_artist_discovery_non_library=self.cache_ttl_artist_discovery_non_library * 3600, + cache_ttl_search=self.cache_ttl_search * 60, + cache_ttl_local_files_recently_added=self.cache_ttl_local_files_recently_added * 60, + cache_ttl_local_files_storage_stats=self.cache_ttl_local_files_storage_stats * 60, + cache_ttl_jellyfin_recently_played=self.cache_ttl_jellyfin_recently_played * 60, + cache_ttl_jellyfin_favorites=self.cache_ttl_jellyfin_favorites * 60, + cache_ttl_jellyfin_genres=self.cache_ttl_jellyfin_genres * 60, + cache_ttl_jellyfin_library_stats=self.cache_ttl_jellyfin_library_stats * 60, + cache_ttl_navidrome_albums=self.cache_ttl_navidrome_albums * 60, + cache_ttl_navidrome_artists=self.cache_ttl_navidrome_artists * 60, + cache_ttl_navidrome_recent=self.cache_ttl_navidrome_recent * 60, + cache_ttl_navidrome_favorites=self.cache_ttl_navidrome_favorites * 60, + cache_ttl_navidrome_search=self.cache_ttl_navidrome_search * 60, + cache_ttl_navidrome_genres=self.cache_ttl_navidrome_genres * 60, + cache_ttl_navidrome_stats=self.cache_ttl_navidrome_stats * 60, + http_timeout=self.http_timeout, + http_connect_timeout=self.http_connect_timeout, + http_max_connections=self.http_max_connections, + batch_artist_images=self.batch_artist_images, + batch_albums=self.batch_albums, + delay_artist=self.delay_artist, + delay_albums=self.delay_albums, + artist_discovery_warm_interval=self.artist_discovery_warm_interval * 60, + artist_discovery_warm_delay=self.artist_discovery_warm_delay, + artist_discovery_precache_delay=self.artist_discovery_precache_delay, + memory_cache_max_entries=self.memory_cache_max_entries, + memory_cache_cleanup_interval=self.memory_cache_cleanup_interval, + cover_memory_cache_max_entries=self.cover_memory_cache_max_entries, + cover_memory_cache_max_size_mb=self.cover_memory_cache_max_size_mb, + disk_cache_cleanup_interval=self.disk_cache_cleanup_interval * 60, + recent_metadata_max_size_mb=self.recent_metadata_max_size_mb, + recent_covers_max_size_mb=self.recent_covers_max_size_mb, + persistent_metadata_ttl_hours=self.persistent_metadata_ttl_hours, + musicbrainz_concurrent_searches=self.musicbrainz_concurrent_searches, + discover_queue_size=self.discover_queue_size, + discover_queue_ttl=self.discover_queue_ttl * 3600, + discover_queue_auto_generate=self.discover_queue_auto_generate, + discover_queue_polling_interval=self.discover_queue_polling_interval * 1000, + discover_queue_warm_cycle_build=self.discover_queue_warm_cycle_build, + discover_queue_seed_artists=self.discover_queue_seed_artists, + discover_queue_wildcard_slots=self.discover_queue_wildcard_slots, + discover_queue_similar_artists_limit=self.discover_queue_similar_artists_limit, + discover_queue_albums_per_similar=self.discover_queue_albums_per_similar, + discover_queue_enrich_ttl=self.discover_queue_enrich_ttl * 3600, + discover_queue_lastfm_mbid_max_lookups=self.discover_queue_lastfm_mbid_max_lookups, + frontend_ttl_home=self.frontend_ttl_home * 60000, + frontend_ttl_discover=self.frontend_ttl_discover * 60000, + frontend_ttl_library=self.frontend_ttl_library * 60000, + frontend_ttl_recently_added=self.frontend_ttl_recently_added * 60000, + frontend_ttl_discover_queue=self.frontend_ttl_discover_queue * 60000, + frontend_ttl_search=self.frontend_ttl_search * 60000, + frontend_ttl_local_files_sidebar=self.frontend_ttl_local_files_sidebar * 60000, + frontend_ttl_jellyfin_sidebar=self.frontend_ttl_jellyfin_sidebar * 60000, + frontend_ttl_playlist_sources=self.frontend_ttl_playlist_sources * 60000, + audiodb_enabled=self.audiodb_enabled, + audiodb_name_search_fallback=self.audiodb_name_search_fallback, + direct_remote_images_enabled=self.direct_remote_images_enabled, + audiodb_api_key=self.audiodb_api_key, + cache_ttl_audiodb_found=self.cache_ttl_audiodb_found * 3600, + cache_ttl_audiodb_not_found=self.cache_ttl_audiodb_not_found * 3600, + cache_ttl_audiodb_library=self.cache_ttl_audiodb_library * 3600, + cache_ttl_recently_viewed_bytes=self.cache_ttl_recently_viewed_bytes * 3600, + genre_section_ttl=self.genre_section_ttl * 3600, + request_history_retention_days=self.request_history_retention_days, + ignored_releases_retention_days=self.ignored_releases_retention_days, + orphan_cover_demote_interval_hours=self.orphan_cover_demote_interval_hours, + store_prune_interval_hours=self.store_prune_interval_hours, + ) diff --git a/backend/api/v1/schemas/album.py b/backend/api/v1/schemas/album.py new file mode 100644 index 0000000..8dd82a2 --- /dev/null +++ b/backend/api/v1/schemas/album.py @@ -0,0 +1,38 @@ +from api.v1.schemas.common import LastFmTagSchema +from models.album import AlbumInfo as AlbumInfo +from models.album import Track as Track +from infrastructure.msgspec_fastapi import AppStruct + + +class AlbumBasicInfo(AppStruct): + """Minimal album info for fast initial load - no tracks.""" + title: str + musicbrainz_id: str + artist_name: str + artist_id: str + release_date: str | None = None + year: int | None = None + type: str | None = None + disambiguation: str | None = None + in_library: bool = False + requested: bool = False + cover_url: str | None = None + album_thumb_url: str | None = None + + +class AlbumTracksInfo(AppStruct): + """Track list and extended details - loaded asynchronously.""" + tracks: list[Track] = [] + total_tracks: int = 0 + total_length: int | None = None + label: str | None = None + barcode: str | None = None + country: str | None = None + + +class LastFmAlbumEnrichment(AppStruct): + summary: str | None = None + tags: list[LastFmTagSchema] = [] + listeners: int = 0 + playcount: int = 0 + url: str | None = None diff --git a/backend/api/v1/schemas/artist.py b/backend/api/v1/schemas/artist.py new file mode 100644 index 0000000..91b7040 --- /dev/null +++ b/backend/api/v1/schemas/artist.py @@ -0,0 +1,36 @@ +from api.v1.schemas.common import LastFmTagSchema +from models.artist import ArtistInfo as ArtistInfo +from models.artist import ExternalLink as ExternalLink +from models.artist import LifeSpan as LifeSpan +from models.artist import ReleaseItem as ReleaseItem +from infrastructure.msgspec_fastapi import AppStruct + + +class ArtistExtendedInfo(AppStruct): + description: str | None = None + image: str | None = None + + +class ArtistReleases(AppStruct): + albums: list[ReleaseItem] = [] + singles: list[ReleaseItem] = [] + eps: list[ReleaseItem] = [] + total_count: int = 0 + has_more: bool = False + + +class LastFmSimilarArtistSchema(AppStruct): + name: str + mbid: str | None = None + match: float = 0.0 + url: str | None = None + + +class LastFmArtistEnrichment(AppStruct): + bio: str | None = None + summary: str | None = None + tags: list[LastFmTagSchema] = [] + listeners: int = 0 + playcount: int = 0 + similar_artists: list[LastFmSimilarArtistSchema] = [] + url: str | None = None diff --git a/backend/api/v1/schemas/cache.py b/backend/api/v1/schemas/cache.py new file mode 100644 index 0000000..b03f1f0 --- /dev/null +++ b/backend/api/v1/schemas/cache.py @@ -0,0 +1,31 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class CacheStats(AppStruct): + memory_entries: int + memory_size_bytes: int + memory_size_mb: float + disk_metadata_count: int + disk_metadata_albums: int + disk_metadata_artists: int + disk_cover_count: int + disk_cover_size_bytes: int + disk_cover_size_mb: float + library_db_artist_count: int + library_db_album_count: int + library_db_size_bytes: int + library_db_size_mb: float + total_size_bytes: int + total_size_mb: float + library_db_last_sync: int | None = None + disk_audiodb_artist_count: int = 0 + disk_audiodb_album_count: int = 0 + + +class CacheClearResponse(AppStruct): + success: bool + message: str + cleared_memory_entries: int = 0 + cleared_disk_files: int = 0 + cleared_library_artists: int = 0 + cleared_library_albums: int = 0 diff --git a/backend/api/v1/schemas/cache_status.py b/backend/api/v1/schemas/cache_status.py new file mode 100644 index 0000000..fcaf1d1 --- /dev/null +++ b/backend/api/v1/schemas/cache_status.py @@ -0,0 +1,16 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class CacheSyncStatus(AppStruct): + is_syncing: bool + phase: str | None = None + total_items: int = 0 + processed_items: int = 0 + progress_percent: int = 0 + current_item: str | None = None + started_at: float | None = None + error_message: str | None = None + total_artists: int = 0 + processed_artists: int = 0 + total_albums: int = 0 + processed_albums: int = 0 diff --git a/backend/api/v1/schemas/common.py b/backend/api/v1/schemas/common.py new file mode 100644 index 0000000..f2fc568 --- /dev/null +++ b/backend/api/v1/schemas/common.py @@ -0,0 +1,37 @@ +from typing import Literal + +from models.common import ServiceStatus as ServiceStatus +from infrastructure.msgspec_fastapi import AppStruct + + +GenreArtistMap = dict[str, str | None] + + +class IntegrationStatus(AppStruct): + listenbrainz: bool + jellyfin: bool + lidarr: bool + youtube: bool + lastfm: bool + navidrome: bool = False + youtube_api: bool = False + + +class StatusReport(AppStruct): + status: Literal["ok", "degraded", "error"] + services: dict[str, ServiceStatus] + + +class LastFmTagSchema(AppStruct): + name: str + url: str | None = None + + +class StatusMessageResponse(AppStruct): + status: str + message: str + + +class VerifyConnectionResponse(AppStruct): + valid: bool + message: str diff --git a/backend/api/v1/schemas/discover.py b/backend/api/v1/schemas/discover.py new file mode 100644 index 0000000..da35614 --- /dev/null +++ b/backend/api/v1/schemas/discover.py @@ -0,0 +1,159 @@ +from api.v1.schemas.home import HomeArtist, HomeSection, ServicePrompt +from api.v1.schemas.common import GenreArtistMap, IntegrationStatus +from api.v1.schemas.weekly_exploration import WeeklyExplorationSection +from models.youtube import YouTubeQuotaResponse as YouTubeQuotaResponse +from infrastructure.msgspec_fastapi import AppStruct + + +class BecauseYouListenTo(AppStruct): + seed_artist: str + seed_artist_mbid: str + section: HomeSection + listen_count: int = 0 + banner_url: str | None = None + wide_thumb_url: str | None = None + fanart_url: str | None = None + + +class DiscoverQueueItemLight(AppStruct): + release_group_mbid: str + album_name: str + artist_name: str + artist_mbid: str + recommendation_reason: str + cover_url: str | None = None + is_wildcard: bool = False + in_library: bool = False + + +class DiscoverQueueEnrichment(AppStruct): + artist_mbid: str | None = None + release_date: str | None = None + country: str | None = None + tags: list[str] = [] + youtube_url: str | None = None + youtube_search_url: str = "" + youtube_search_available: bool = False + artist_description: str | None = None + listen_count: int | None = None + + +class DiscoverQueueItemFull(DiscoverQueueItemLight): + enrichment: DiscoverQueueEnrichment | None = None + + +class YouTubeSearchResponse(AppStruct): + video_id: str | None = None + embed_url: str | None = None + error: str | None = None + cached: bool = False + + +class TrackCacheCheckItem(AppStruct): + artist: str + track: str + + +class TrackCacheCheckRequest(AppStruct): + items: list[TrackCacheCheckItem] = [] + + +class TrackCacheCheckResponseItem(AppStruct): + artist: str + track: str + cached: bool = False + + +class TrackCacheCheckResponse(AppStruct): + items: list[TrackCacheCheckResponseItem] = [] + + +class DiscoverQueueResponse(AppStruct): + items: list[DiscoverQueueItemLight | DiscoverQueueItemFull] = [] + queue_id: str = "" + + +class DiscoverQueueIgnoreRequest(AppStruct): + release_group_mbid: str + artist_mbid: str + release_name: str + artist_name: str + + +class DiscoverQueueValidateRequest(AppStruct): + release_group_mbids: list[str] + + +class DiscoverQueueValidateResponse(AppStruct): + in_library: list[str] = [] + + +class QueueSettings(AppStruct): + queue_size: int + queue_ttl: int + seed_artists: int + wildcard_slots: int + similar_artists_limit: int + albums_per_similar: int + enrich_ttl: int + lastfm_mbid_max_lookups: int + + +class DiscoverQueueStatusResponse(AppStruct): + status: str + source: str + queue_id: str | None = None + item_count: int | None = None + built_at: float | None = None + stale: bool | None = None + error: str | None = None + + +class QueueGenerateRequest(AppStruct): + source: str | None = None + force: bool = False + + +class QueueGenerateResponse(AppStruct): + action: str + status: str + source: str + queue_id: str | None = None + item_count: int | None = None + built_at: float | None = None + stale: bool | None = None + error: str | None = None + + +class DiscoverIgnoredRelease(AppStruct): + release_group_mbid: str + artist_mbid: str + release_name: str + artist_name: str + ignored_at: float + + +class DiscoverIntegrationStatus(IntegrationStatus): + pass + + +class DiscoverResponse(AppStruct): + because_you_listen_to: list[BecauseYouListenTo] = [] + discover_queue_enabled: bool = True + fresh_releases: HomeSection | None = None + missing_essentials: HomeSection | None = None + rediscover: HomeSection | None = None + artists_you_might_like: HomeSection | None = None + popular_in_your_genres: HomeSection | None = None + genre_list: HomeSection | None = None + globally_trending: HomeSection | None = None + weekly_exploration: WeeklyExplorationSection | None = None + integration_status: DiscoverIntegrationStatus | None = None + service_prompts: list[ServicePrompt] = [] + genre_artists: GenreArtistMap = {} + genre_artist_images: GenreArtistMap = {} + lastfm_weekly_artist_chart: HomeSection | None = None + lastfm_weekly_album_chart: HomeSection | None = None + lastfm_recent_scrobbles: HomeSection | None = None + refreshing: bool = False + service_status: dict[str, str] | None = None diff --git a/backend/api/v1/schemas/discovery.py b/backend/api/v1/schemas/discovery.py new file mode 100644 index 0000000..45d4ad5 --- /dev/null +++ b/backend/api/v1/schemas/discovery.py @@ -0,0 +1,72 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class SimilarArtist(AppStruct): + musicbrainz_id: str + name: str + listen_count: int = 0 + in_library: bool = False + image_url: str | None = None + + +class SimilarArtistsResponse(AppStruct): + similar_artists: list[SimilarArtist] = [] + source: str = "listenbrainz" + configured: bool = True + + +class TopSong(AppStruct): + title: str + artist_name: str + recording_mbid: str | None = None + release_group_mbid: str | None = None + original_release_mbid: str | None = None + release_name: str | None = None + listen_count: int = 0 + disc_number: int | None = None + track_number: int | None = None + + +class TopSongsResponse(AppStruct): + songs: list[TopSong] = [] + source: str = "listenbrainz" + configured: bool = True + + +class TopAlbum(AppStruct): + title: str + artist_name: str + release_group_mbid: str | None = None + year: int | None = None + listen_count: int = 0 + in_library: bool = False + requested: bool = False + cover_url: str | None = None + + +class TopAlbumsResponse(AppStruct): + albums: list[TopAlbum] = [] + source: str = "listenbrainz" + configured: bool = True + + +class DiscoveryAlbum(AppStruct): + musicbrainz_id: str + title: str + artist_name: str + artist_id: str | None = None + year: int | None = None + in_library: bool = False + requested: bool = False + cover_url: str | None = None + + +class SimilarAlbumsResponse(AppStruct): + albums: list[DiscoveryAlbum] = [] + source: str = "listenbrainz" + configured: bool = True + + +class MoreByArtistResponse(AppStruct): + albums: list[DiscoveryAlbum] = [] + artist_name: str = "" diff --git a/backend/api/v1/schemas/home.py b/backend/api/v1/schemas/home.py new file mode 100644 index 0000000..c84af7e --- /dev/null +++ b/backend/api/v1/schemas/home.py @@ -0,0 +1,171 @@ +from api.v1.schemas.common import GenreArtistMap, IntegrationStatus +from api.v1.schemas.weekly_exploration import WeeklyExplorationSection +from infrastructure.msgspec_fastapi import AppStruct + + +class HomeArtist(AppStruct): + name: str + mbid: str | None = None + image_url: str | None = None + listen_count: int | None = None + in_library: bool = False + source: str | None = None + + +class HomeAlbum(AppStruct): + name: str + mbid: str | None = None + artist_name: str | None = None + artist_mbid: str | None = None + image_url: str | None = None + release_date: str | None = None + listen_count: int | None = None + in_library: bool = False + requested: bool = False + source: str | None = None + + +class HomeTrack(AppStruct): + name: str + mbid: str | None = None + artist_name: str | None = None + artist_mbid: str | None = None + album_name: str | None = None + listen_count: int | None = None + listened_at: str | None = None + image_url: str | None = None + + +class HomeGenre(AppStruct): + name: str + listen_count: int | None = None + artist_count: int | None = None + artist_mbid: str | None = None + + +class HomeSection(AppStruct): + title: str + type: str + items: list[HomeArtist | HomeAlbum | HomeTrack | HomeGenre] = [] + source: str | None = None + fallback_message: str | None = None + connect_service: str | None = None + + +class ServicePrompt(AppStruct): + service: str + title: str + description: str + icon: str + color: str + features: list[str] = [] + + +class HomeIntegrationStatus(IntegrationStatus): + localfiles: bool = False + + +class DiscoverPreview(AppStruct): + seed_artist: str + seed_artist_mbid: str + items: list[HomeArtist] = [] + + +class HomeResponse(AppStruct): + recently_added: HomeSection | None = None + library_artists: HomeSection | None = None + library_albums: HomeSection | None = None + recommended_artists: HomeSection | None = None + trending_artists: HomeSection | None = None + popular_albums: HomeSection | None = None + recently_played: HomeSection | None = None + top_genres: HomeSection | None = None + genre_list: HomeSection | None = None + fresh_releases: HomeSection | None = None + favorite_artists: HomeSection | None = None + your_top_albums: HomeSection | None = None + weekly_exploration: WeeklyExplorationSection | None = None + service_prompts: list[ServicePrompt] = [] + integration_status: HomeIntegrationStatus | None = None + genre_artists: GenreArtistMap = {} + genre_artist_images: GenreArtistMap = {} + discover_preview: DiscoverPreview | None = None + service_status: dict[str, str] | None = None + + +class GenreLibrarySection(AppStruct): + artists: list[HomeArtist] = [] + albums: list[HomeAlbum] = [] + artist_count: int = 0 + album_count: int = 0 + + +class GenrePopularSection(AppStruct): + artists: list[HomeArtist] = [] + albums: list[HomeAlbum] = [] + has_more_artists: bool = False + has_more_albums: bool = False + + +class GenreDetailResponse(AppStruct): + genre: str + library: GenreLibrarySection | None = None + popular: GenrePopularSection | None = None + artists: list[HomeArtist] = [] + total_count: int | None = None + + +class TrendingTimeRange(AppStruct): + range_key: str + label: str + featured: HomeArtist | None = None + items: list[HomeArtist] = [] + total_count: int = 0 + + +class TrendingArtistsResponse(AppStruct): + this_week: TrendingTimeRange + this_month: TrendingTimeRange + this_year: TrendingTimeRange + all_time: TrendingTimeRange + + +class PopularTimeRange(AppStruct): + range_key: str + label: str + featured: HomeAlbum | None = None + items: list[HomeAlbum] = [] + total_count: int = 0 + + +class PopularAlbumsResponse(AppStruct): + this_week: PopularTimeRange + this_month: PopularTimeRange + this_year: PopularTimeRange + all_time: PopularTimeRange + + +class TrendingArtistsRangeResponse(AppStruct): + range_key: str + label: str + items: list[HomeArtist] = [] + offset: int = 0 + limit: int = 25 + has_more: bool = False + + +class PopularAlbumsRangeResponse(AppStruct): + range_key: str + label: str + items: list[HomeAlbum] = [] + offset: int = 0 + limit: int = 25 + has_more: bool = False + + +class GenreArtistResponse(AppStruct): + artist_mbid: str | None = None + + +class GenreArtistsBatchResponse(AppStruct): + genre_artists: dict[str, str | None] = {} diff --git a/backend/api/v1/schemas/jellyfin.py b/backend/api/v1/schemas/jellyfin.py new file mode 100644 index 0000000..565869c --- /dev/null +++ b/backend/api/v1/schemas/jellyfin.py @@ -0,0 +1,69 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class JellyfinTrackInfo(AppStruct): + jellyfin_id: str + title: str + track_number: int + duration_seconds: float + disc_number: int = 1 + album_name: str = "" + artist_name: str = "" + codec: str | None = None + bitrate: int | None = None + + +class JellyfinAlbumSummary(AppStruct): + jellyfin_id: str + name: str + artist_name: str = "" + year: int | None = None + track_count: int = 0 + image_url: str | None = None + musicbrainz_id: str | None = None + artist_musicbrainz_id: str | None = None + + +class JellyfinAlbumDetail(AppStruct): + jellyfin_id: str + name: str + artist_name: str = "" + year: int | None = None + track_count: int = 0 + image_url: str | None = None + musicbrainz_id: str | None = None + artist_musicbrainz_id: str | None = None + tracks: list[JellyfinTrackInfo] = [] + + +class JellyfinAlbumMatch(AppStruct): + found: bool + jellyfin_album_id: str | None = None + tracks: list[JellyfinTrackInfo] = [] + + +class JellyfinArtistSummary(AppStruct): + jellyfin_id: str + name: str + image_url: str | None = None + album_count: int = 0 + musicbrainz_id: str | None = None + + +class JellyfinLibraryStats(AppStruct): + total_tracks: int = 0 + total_albums: int = 0 + total_artists: int = 0 + + +class JellyfinSearchResponse(AppStruct): + albums: list[JellyfinAlbumSummary] = [] + artists: list[JellyfinArtistSummary] = [] + tracks: list[JellyfinTrackInfo] = [] + + +class JellyfinPaginatedResponse(AppStruct): + items: list[JellyfinAlbumSummary] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 diff --git a/backend/api/v1/schemas/library.py b/backend/api/v1/schemas/library.py new file mode 100644 index 0000000..3550932 --- /dev/null +++ b/backend/api/v1/schemas/library.py @@ -0,0 +1,104 @@ +from models.library import LibraryAlbum as LibraryAlbum +from models.library import LibraryGroupedAlbum as LibraryGroupedAlbum +from models.library import LibraryGroupedArtist as LibraryGroupedArtist +from infrastructure.msgspec_fastapi import AppStruct + + +class LibraryArtist(AppStruct): + mbid: str + name: str + album_count: int = 0 + date_added: int | None = None + + +class LibraryResponse(AppStruct): + library: list[LibraryAlbum] + + +class LibraryArtistsResponse(AppStruct): + artists: list[LibraryArtist] + total: int + + +class LibraryAlbumsResponse(AppStruct): + albums: list[LibraryAlbum] + total: int + + +class PaginatedLibraryAlbumsResponse(AppStruct): + albums: list[LibraryAlbum] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 + + +class PaginatedLibraryArtistsResponse(AppStruct): + artists: list[LibraryArtist] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 + + +class RecentlyAddedResponse(AppStruct): + albums: list[LibraryAlbum] = [] + artists: list[LibraryArtist] = [] + + +class LibraryStatsResponse(AppStruct): + artist_count: int + album_count: int + db_size_bytes: int + db_size_mb: float + last_sync: int | None = None + + +class AlbumRemoveResponse(AppStruct): + success: bool + artist_removed: bool = False + artist_name: str | None = None + + +class AlbumRemovePreviewResponse(AppStruct): + success: bool + artist_will_be_removed: bool = False + artist_name: str | None = None + + +class SyncLibraryResponse(AppStruct): + status: str + artists: int + albums: int + + +class LibraryMbidsResponse(AppStruct): + mbids: list[str] = [] + requested_mbids: list[str] = [] + + +class LibraryGroupedResponse(AppStruct): + library: list[LibraryGroupedArtist] = [] + + +class TrackResolveItem(AppStruct): + release_group_mbid: str | None = None + disc_number: int | None = None + track_number: int | None = None + + +class TrackResolveRequest(AppStruct): + items: list[TrackResolveItem] = [] + + +class ResolvedTrack(AppStruct): + release_group_mbid: str | None = None + disc_number: int | None = None + track_number: int | None = None + source: str | None = None + track_source_id: str | None = None + stream_url: str | None = None + format: str | None = None + duration: float | None = None + + +class TrackResolveResponse(AppStruct): + items: list[ResolvedTrack] = [] diff --git a/backend/api/v1/schemas/local_files.py b/backend/api/v1/schemas/local_files.py new file mode 100644 index 0000000..68427a5 --- /dev/null +++ b/backend/api/v1/schemas/local_files.py @@ -0,0 +1,58 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class LocalTrackInfo(AppStruct): + track_file_id: int + title: str + track_number: int + disc_number: int = 1 + duration_seconds: float | None = None + size_bytes: int = 0 + format: str = "" + bitrate: int | None = None + date_added: str | None = None + + +class LocalAlbumMatch(AppStruct): + found: bool + tracks: list[LocalTrackInfo] = [] + total_size_bytes: int = 0 + primary_format: str | None = None + + +class LocalAlbumSummary(AppStruct): + lidarr_album_id: int + musicbrainz_id: str + name: str + artist_name: str + artist_mbid: str | None = None + year: int | None = None + track_count: int = 0 + total_size_bytes: int = 0 + primary_format: str | None = None + cover_url: str | None = None + date_added: str | None = None + + +class LocalPaginatedResponse(AppStruct): + items: list[LocalAlbumSummary] = [] + total: int = 0 + offset: int = 0 + limit: int = 50 + + +class FormatInfo(AppStruct): + count: int = 0 + size_bytes: int = 0 + size_human: str = "0 B" + + +class LocalStorageStats(AppStruct): + total_tracks: int = 0 + total_albums: int = 0 + total_artists: int = 0 + total_size_bytes: int = 0 + total_size_human: str = "0 B" + disk_free_bytes: int = 0 + disk_free_human: str = "0 B" + format_breakdown: dict[str, FormatInfo] = {} diff --git a/backend/api/v1/schemas/navidrome.py b/backend/api/v1/schemas/navidrome.py new file mode 100644 index 0000000..b8b3a74 --- /dev/null +++ b/backend/api/v1/schemas/navidrome.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from infrastructure.msgspec_fastapi import AppStruct + + +class NavidromeTrackInfo(AppStruct): + navidrome_id: str + title: str + track_number: int + duration_seconds: float + disc_number: int = 1 + album_name: str = "" + artist_name: str = "" + codec: str | None = None + bitrate: int | None = None + + +class NavidromeAlbumSummary(AppStruct): + navidrome_id: str + name: str + artist_name: str = "" + year: int | None = None + track_count: int = 0 + image_url: str | None = None + musicbrainz_id: str | None = None + artist_musicbrainz_id: str | None = None + + +class NavidromeAlbumDetail(AppStruct): + navidrome_id: str + name: str + artist_name: str = "" + year: int | None = None + track_count: int = 0 + image_url: str | None = None + musicbrainz_id: str | None = None + artist_musicbrainz_id: str | None = None + tracks: list[NavidromeTrackInfo] = [] + + +class NavidromeAlbumMatch(AppStruct): + found: bool + navidrome_album_id: str | None = None + tracks: list[NavidromeTrackInfo] = [] + + +class NavidromeArtistSummary(AppStruct): + navidrome_id: str + name: str + image_url: str | None = None + album_count: int = 0 + musicbrainz_id: str | None = None + + +class NavidromeLibraryStats(AppStruct): + total_tracks: int = 0 + total_albums: int = 0 + total_artists: int = 0 + + +class NavidromeSearchResponse(AppStruct): + albums: list[NavidromeAlbumSummary] = [] + artists: list[NavidromeArtistSummary] = [] + tracks: list[NavidromeTrackInfo] = [] + + +class NavidromeAlbumPage(AppStruct): + items: list[NavidromeAlbumSummary] = [] + total: int = 0 diff --git a/backend/api/v1/schemas/playlists.py b/backend/api/v1/schemas/playlists.py new file mode 100644 index 0000000..973d41d --- /dev/null +++ b/backend/api/v1/schemas/playlists.py @@ -0,0 +1,124 @@ +import msgspec +from infrastructure.msgspec_fastapi import AppStruct + + +class PlaylistTrackResponse(AppStruct): + id: str + position: int + track_name: str + artist_name: str + album_name: str + album_id: str | None = None + artist_id: str | None = None + track_source_id: str | None = None + cover_url: str | None = None + source_type: str = "" + available_sources: list[str] | None = None + format: str | None = None + track_number: int | None = None + disc_number: int | None = None + duration: int | None = None + created_at: str = "" + + +class PlaylistSummaryResponse(AppStruct): + id: str + name: str + track_count: int = 0 + total_duration: int | None = None + cover_urls: list[str] = msgspec.field(default_factory=list) + custom_cover_url: str | None = None + created_at: str = "" + updated_at: str = "" + + +class PlaylistDetailResponse(AppStruct): + # Frontend PlaylistDetail extends PlaylistSummary — keep fields in sync with PlaylistSummaryResponse + id: str + name: str + cover_urls: list[str] = msgspec.field(default_factory=list) + custom_cover_url: str | None = None + tracks: list[PlaylistTrackResponse] = msgspec.field(default_factory=list) + track_count: int = 0 + total_duration: int | None = None + created_at: str = "" + updated_at: str = "" + + +class PlaylistListResponse(AppStruct): + playlists: list[PlaylistSummaryResponse] = msgspec.field(default_factory=list) + + +class CreatePlaylistRequest(AppStruct): + name: str + + +class UpdatePlaylistRequest(AppStruct): + name: str | None = None + + +class TrackDataRequest(AppStruct): + track_name: str + artist_name: str + album_name: str + album_id: str | None = None + artist_id: str | None = None + track_source_id: str | None = None + cover_url: str | None = None + source_type: str = "" + available_sources: list[str] | None = None + format: str | None = None + track_number: int | None = None + disc_number: int | None = None + duration: float | int | None = None + + +class AddTracksRequest(AppStruct): + tracks: list[TrackDataRequest] + position: int | None = None + + +class RemoveTracksRequest(AppStruct): + track_ids: list[str] + + +class ReorderTrackRequest(AppStruct): + track_id: str + new_position: int + + +class ReorderTrackResponse(AppStruct): + status: str = "ok" + message: str = "Track reordered" + actual_position: int = 0 + + +class UpdateTrackRequest(AppStruct): + source_type: str | None = None + available_sources: list[str] | None = None + + +class AddTracksResponse(AppStruct): + tracks: list[PlaylistTrackResponse] = msgspec.field(default_factory=list) + + +class CoverUploadResponse(AppStruct): + cover_url: str + + +class TrackIdentifier(AppStruct): + track_name: str + artist_name: str + album_name: str + + +class CheckTrackMembershipRequest(AppStruct): + tracks: list[TrackIdentifier] + + +class CheckTrackMembershipResponse(AppStruct): + membership: dict[str, list[int]] + + +class ResolveSourcesResponse(AppStruct): + sources: dict[str, list[str]] diff --git a/backend/api/v1/schemas/profile.py b/backend/api/v1/schemas/profile.py new file mode 100644 index 0000000..ed5e301 --- /dev/null +++ b/backend/api/v1/schemas/profile.py @@ -0,0 +1,35 @@ +import msgspec +from infrastructure.msgspec_fastapi import AppStruct + + +class ProfileSettings(AppStruct): + display_name: str = "" + avatar_url: str = "" + + +class ServiceConnection(AppStruct): + name: str + enabled: bool = False + username: str = "" + url: str = "" + + +class LibraryStats(AppStruct): + source: str + total_tracks: int = 0 + total_albums: int = 0 + total_artists: int = 0 + total_size_bytes: int = 0 + total_size_human: str = "" + + +class ProfileResponse(AppStruct): + display_name: str = "" + avatar_url: str = "" + services: list[ServiceConnection] = msgspec.field(default_factory=list) + library_stats: list[LibraryStats] = msgspec.field(default_factory=list) + + +class ProfileUpdateRequest(AppStruct): + display_name: str | None = None + avatar_url: str | None = None diff --git a/backend/api/v1/schemas/request.py b/backend/api/v1/schemas/request.py new file mode 100644 index 0000000..d1003fc --- /dev/null +++ b/backend/api/v1/schemas/request.py @@ -0,0 +1,20 @@ +from models.request import QueueItem as QueueItem +from infrastructure.msgspec_fastapi import AppStruct + + +class AlbumRequest(AppStruct): + musicbrainz_id: str + artist: str | None = None + album: str | None = None + year: int | None = None + + +class RequestResponse(AppStruct): + success: bool + message: str + lidarr_response: dict | None = None + + +class QueueStatusResponse(AppStruct): + queue_size: int + processing: bool diff --git a/backend/api/v1/schemas/requests_page.py b/backend/api/v1/schemas/requests_page.py new file mode 100644 index 0000000..d50c8a0 --- /dev/null +++ b/backend/api/v1/schemas/requests_page.py @@ -0,0 +1,74 @@ +from datetime import datetime +from infrastructure.msgspec_fastapi import AppStruct + + +class StatusMessage(AppStruct): + title: str | None = None + messages: list[str] = [] + + +class ActiveRequestItem(AppStruct): + musicbrainz_id: str + artist_name: str + album_title: str + requested_at: datetime + status: str + artist_mbid: str | None = None + year: int | None = None + cover_url: str | None = None + progress: float | None = None + eta: datetime | None = None + size: float | None = None + size_remaining: float | None = None + download_status: str | None = None + download_state: str | None = None + status_messages: list[StatusMessage] | None = None + error_message: str | None = None + lidarr_queue_id: int | None = None + quality: str | None = None + protocol: str | None = None + download_client: str | None = None + + +class RequestHistoryItem(AppStruct): + musicbrainz_id: str + artist_name: str + album_title: str + requested_at: datetime + status: str + artist_mbid: str | None = None + year: int | None = None + cover_url: str | None = None + completed_at: datetime | None = None + in_library: bool = False + + +class ActiveRequestsResponse(AppStruct): + items: list[ActiveRequestItem] + count: int + + +class RequestHistoryResponse(AppStruct): + items: list[RequestHistoryItem] + total: int + page: int + page_size: int + total_pages: int + + +class CancelRequestResponse(AppStruct): + success: bool + message: str + + +class RetryRequestResponse(AppStruct): + success: bool + message: str + + +class ClearHistoryResponse(AppStruct): + success: bool + + +class ActiveCountResponse(AppStruct): + count: int diff --git a/backend/api/v1/schemas/scrobble.py b/backend/api/v1/schemas/scrobble.py new file mode 100644 index 0000000..6a6e0d2 --- /dev/null +++ b/backend/api/v1/schemas/scrobble.py @@ -0,0 +1,46 @@ +import time + +import msgspec + +from infrastructure.msgspec_fastapi import AppStruct + + +class NowPlayingRequest(AppStruct): + track_name: str + artist_name: str + album_name: str = "" + duration_ms: int = 0 + mbid: str | None = None + + def __post_init__(self) -> None: + if self.duration_ms < 0: + raise ValueError("duration_ms must be >= 0") + + +class ScrobbleRequest(AppStruct): + track_name: str + artist_name: str + timestamp: int + album_name: str = "" + duration_ms: int = 0 + mbid: str | None = None + + def __post_init__(self) -> None: + now = int(time.time()) + max_age = 14 * 24 * 60 * 60 + if self.duration_ms < 0: + raise ValueError("duration_ms must be >= 0") + if self.timestamp > now + 60: + raise ValueError("Timestamp cannot be in the future") + if self.timestamp < now - max_age: + raise ValueError("Timestamp cannot be older than 14 days") + + +class ServiceResult(AppStruct): + success: bool + error: str | None = None + + +class ScrobbleResponse(AppStruct): + accepted: bool + services: dict[str, ServiceResult] = {} diff --git a/backend/api/v1/schemas/search.py b/backend/api/v1/schemas/search.py new file mode 100644 index 0000000..3daf331 --- /dev/null +++ b/backend/api/v1/schemas/search.py @@ -0,0 +1,72 @@ +from typing import Literal + +from models.search import SearchResult as SearchResult +from infrastructure.msgspec_fastapi import AppStruct + +EnrichmentSource = Literal["listenbrainz", "lastfm", "none"] + + +class SearchResponse(AppStruct): + artists: list[SearchResult] = [] + albums: list[SearchResult] = [] + top_artist: SearchResult | None = None + top_album: SearchResult | None = None + service_status: dict[str, str] | None = None + + +class SearchBucketResponse(AppStruct): + bucket: str + limit: int + offset: int + results: list[SearchResult] = [] + top_result: SearchResult | None = None + + +class ArtistEnrichment(AppStruct): + musicbrainz_id: str + release_group_count: int | None = None + listen_count: int | None = None + + +class AlbumEnrichment(AppStruct): + musicbrainz_id: str + track_count: int | None = None + listen_count: int | None = None + + +class ArtistEnrichmentRequest(AppStruct): + musicbrainz_id: str + name: str = "" + + +class AlbumEnrichmentRequest(AppStruct): + musicbrainz_id: str + artist_name: str = "" + album_name: str = "" + + +class EnrichmentBatchRequest(AppStruct): + artists: list[ArtistEnrichmentRequest] = [] + albums: list[AlbumEnrichmentRequest] = [] + + +class EnrichmentResponse(AppStruct): + artists: list[ArtistEnrichment] = [] + albums: list[AlbumEnrichment] = [] + source: EnrichmentSource = "none" + + +class SuggestResult(AppStruct): + type: Literal["artist", "album"] + title: str + musicbrainz_id: str + artist: str | None = None + year: int | None = None + in_library: bool = False + requested: bool = False + disambiguation: str | None = None + score: int = 0 + + +class SuggestResponse(AppStruct): + results: list[SuggestResult] = [] diff --git a/backend/api/v1/schemas/settings.py b/backend/api/v1/schemas/settings.py new file mode 100644 index 0000000..65c32cf --- /dev/null +++ b/backend/api/v1/schemas/settings.py @@ -0,0 +1,205 @@ +from typing import Literal + +import msgspec + +from infrastructure.msgspec_fastapi import AppStruct + +LASTFM_SECRET_MASK = "••••••••" + + +def _mask_secret(value: str) -> str: + if not value: + return "" + if len(value) <= 4: + return LASTFM_SECRET_MASK + return LASTFM_SECRET_MASK + value[-4:] + + +class LastFmConnectionSettings(AppStruct): + api_key: str = "" + shared_secret: str = "" + session_key: str = "" + username: str = "" + enabled: bool = False + + +class LastFmConnectionSettingsResponse(AppStruct): + api_key: str = "" + shared_secret: str = "" + session_key: str = "" + username: str = "" + enabled: bool = False + + @classmethod + def from_settings(cls, settings: LastFmConnectionSettings) -> "LastFmConnectionSettingsResponse": + return cls( + api_key=settings.api_key, + shared_secret=_mask_secret(settings.shared_secret), + session_key=_mask_secret(settings.session_key), + username=settings.username, + enabled=settings.enabled, + ) + + +class LastFmVerifyResponse(AppStruct): + valid: bool + message: str + + +class LastFmAuthTokenResponse(AppStruct): + token: str + auth_url: str + + +class LastFmAuthSessionRequest(AppStruct): + token: str + + +class LastFmAuthSessionResponse(AppStruct): + success: bool + message: str + username: str = "" + + +class UserPreferences(AppStruct): + primary_types: list[str] = msgspec.field(default_factory=lambda: ["album", "ep", "single"]) + secondary_types: list[str] = msgspec.field(default_factory=lambda: ["studio"]) + release_statuses: list[str] = msgspec.field(default_factory=lambda: ["official"]) + + +class LidarrConnectionSettings(AppStruct): + lidarr_url: str = "http://lidarr:8686" + lidarr_api_key: str = "" + quality_profile_id: int = 1 + metadata_profile_id: int = 1 + root_folder_path: str = "/music" + + def __post_init__(self) -> None: + self.lidarr_url = self.lidarr_url.rstrip("/") + if self.quality_profile_id < 1: + raise msgspec.ValidationError("quality_profile_id must be >= 1") + if self.metadata_profile_id < 1: + raise msgspec.ValidationError("metadata_profile_id must be >= 1") + + +class JellyfinConnectionSettings(AppStruct): + jellyfin_url: str = "http://jellyfin:8096" + api_key: str = "" + user_id: str = "" + enabled: bool = False + + def __post_init__(self) -> None: + self.jellyfin_url = self.jellyfin_url.rstrip("/") + + +NAVIDROME_PASSWORD_MASK = "********" + + +class NavidromeConnectionSettings(AppStruct): + navidrome_url: str = "" + username: str = "" + password: str = "" + enabled: bool = False + + def __post_init__(self) -> None: + self.navidrome_url = self.navidrome_url.rstrip("/") if self.navidrome_url else "" + + +class JellyfinUserInfo(AppStruct): + id: str + name: str + + +class JellyfinVerifyResponse(AppStruct): + success: bool + message: str + users: list[JellyfinUserInfo] = [] + + +class ListenBrainzConnectionSettings(AppStruct): + username: str = "" + user_token: str = "" + enabled: bool = False + + +class YouTubeConnectionSettings(AppStruct): + api_key: str = "" + enabled: bool = False + api_enabled: bool = False + daily_quota_limit: int = 80 + + def __post_init__(self) -> None: + if self.daily_quota_limit < 1 or self.daily_quota_limit > 10000: + raise msgspec.ValidationError("daily_quota_limit must be between 1 and 10000") + + def has_valid_api_key(self) -> bool: + return bool(self.api_key and self.api_key.strip()) + + +class HomeSettings(AppStruct): + cache_ttl_trending: int = 3600 + cache_ttl_personal: int = 300 + + def __post_init__(self) -> None: + if self.cache_ttl_trending < 300 or self.cache_ttl_trending > 86400: + raise msgspec.ValidationError("cache_ttl_trending must be between 300 and 86400") + if self.cache_ttl_personal < 60 or self.cache_ttl_personal > 3600: + raise msgspec.ValidationError("cache_ttl_personal must be between 60 and 3600") + + +class LocalFilesConnectionSettings(AppStruct): + enabled: bool = False + music_path: str = "/music" + lidarr_root_path: str = "/music" + + +class LocalFilesVerifyResponse(AppStruct): + success: bool + message: str + track_count: int = 0 + + +class LidarrSettings(AppStruct): + sync_frequency: Literal["manual", "5min", "10min", "30min", "1hr"] = "10min" + last_sync: int | None = None + last_sync_success: bool = True + + +class LidarrProfileSummary(AppStruct): + id: int + name: str + + +class LidarrRootFolderSummary(AppStruct): + id: str + path: str + + +class LidarrVerifyResponse(AppStruct): + success: bool + message: str + quality_profiles: list[LidarrProfileSummary] = [] + metadata_profiles: list[LidarrProfileSummary] = [] + root_folders: list[LidarrRootFolderSummary] = [] + + +class LidarrMetadataProfileSummary(AppStruct): + id: int + name: str + + +class ScrobbleSettings(AppStruct): + scrobble_to_lastfm: bool = False + scrobble_to_listenbrainz: bool = False + + +class PrimaryMusicSourceSettings(AppStruct): + source: Literal["listenbrainz", "lastfm"] = "listenbrainz" + + +class LidarrMetadataProfilePreferences(AppStruct): + profile_id: int + profile_name: str + primary_types: list[str] = [] + secondary_types: list[str] = [] + release_statuses: list[str] = [] diff --git a/backend/api/v1/schemas/stream.py b/backend/api/v1/schemas/stream.py new file mode 100644 index 0000000..11139ce --- /dev/null +++ b/backend/api/v1/schemas/stream.py @@ -0,0 +1,27 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class PlaybackSessionResponse(AppStruct): + play_session_id: str + item_id: str + + +class StartPlaybackRequest(AppStruct): + play_session_id: str | None = None + + +class JellyfinPlaybackUrlResponse(AppStruct): + url: str + seekable: bool + playSessionId: str + + +class ProgressReportRequest(AppStruct): + play_session_id: str + position_seconds: float + is_paused: bool = False + + +class StopReportRequest(AppStruct): + play_session_id: str + position_seconds: float diff --git a/backend/api/v1/schemas/weekly_exploration.py b/backend/api/v1/schemas/weekly_exploration.py new file mode 100644 index 0000000..f1bfa06 --- /dev/null +++ b/backend/api/v1/schemas/weekly_exploration.py @@ -0,0 +1,19 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class WeeklyExplorationTrack(AppStruct): + title: str + artist_name: str + album_name: str + recording_mbid: str | None = None + artist_mbid: str | None = None + release_group_mbid: str | None = None + cover_url: str | None = None + duration_ms: int | None = None + + +class WeeklyExplorationSection(AppStruct): + title: str + playlist_date: str + tracks: list[WeeklyExplorationTrack] = [] + source_url: str = "" diff --git a/backend/api/v1/schemas/youtube.py b/backend/api/v1/schemas/youtube.py new file mode 100644 index 0000000..63663f6 --- /dev/null +++ b/backend/api/v1/schemas/youtube.py @@ -0,0 +1,97 @@ +import msgspec + +from api.v1.schemas.discover import YouTubeQuotaResponse +from infrastructure.msgspec_fastapi import AppStruct + + +class YouTubeLinkGenerateRequest(AppStruct): + artist_name: str + album_name: str + album_id: str + cover_url: str | None = None + + +class YouTubeTrackLink(AppStruct): + album_id: str + track_number: int + track_name: str + video_id: str + artist_name: str + embed_url: str + created_at: str + disc_number: int = 1 + album_name: str = "" + + +class YouTubeLink(AppStruct): + album_id: str + album_name: str + artist_name: str + created_at: str + video_id: str | None = None + embed_url: str | None = None + cover_url: str | None = None + is_manual: bool = False + track_count: int = 0 + + +class YouTubeLinkResponse(AppStruct): + link: YouTubeLink + quota: YouTubeQuotaResponse + + +class YouTubeTrackLinkGenerateRequest(AppStruct): + album_id: str + album_name: str + artist_name: str + track_name: str + track_number: int + disc_number: int = 1 + cover_url: str | None = None + + +class TrackInput(AppStruct): + track_name: str + track_number: int + disc_number: int = 1 + + +class YouTubeTrackLinkBatchGenerateRequest(AppStruct): + album_id: str + album_name: str + artist_name: str + tracks: list[TrackInput] + cover_url: str | None = None + + +class YouTubeTrackLinkResponse(AppStruct): + track_link: YouTubeTrackLink + quota: YouTubeQuotaResponse + + +class YouTubeTrackLinkFailure(AppStruct): + track_number: int + track_name: str + reason: str + disc_number: int = 1 + + +class YouTubeTrackLinkBatchResponse(AppStruct): + track_links: list[YouTubeTrackLink] + quota: YouTubeQuotaResponse + failed: list[YouTubeTrackLinkFailure] = [] + + +class YouTubeManualLinkRequest(AppStruct): + album_name: str + artist_name: str + youtube_url: str + cover_url: str | None = None + album_id: str | None = None + + +class YouTubeLinkUpdateRequest(AppStruct): + youtube_url: str | None = None + album_name: str | None = None + artist_name: str | None = None + cover_url: str | None | msgspec.UnsetType = msgspec.UNSET diff --git a/backend/cache/.gitignore-check b/backend/cache/.gitignore-check new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..43f5a04 --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,251 @@ +from pathlib import Path +from pydantic import Field, TypeAdapter, ValidationError as PydanticValidationError, field_validator, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import Self +import logging +import msgspec +from core.exceptions import ConfigurationError +from infrastructure.file_utils import atomic_write_json, read_json + +logger = logging.getLogger(__name__) + +_VALID_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="allow" + ) + + lidarr_url: str = Field(default="http://lidarr:8686") + lidarr_api_key: str = Field(default="") + + jellyfin_url: str = Field(default="http://jellyfin:8096") + + contact_email: str = Field( + default="contact@musicseerr.com", + description="Contact email for MusicBrainz API User-Agent. Override with your own if desired." + ) + + quality_profile_id: int = Field(default=1) + metadata_profile_id: int = Field(default=1) + root_folder_path: str = Field(default="/music") + + port: int = Field(default=8688) + debug: bool = Field(default=False) + log_level: str = Field(default="INFO") + + cache_ttl_default: int = Field(default=60) + cache_ttl_artist: int = Field(default=3600) + cache_ttl_album: int = Field(default=3600) + cache_ttl_covers: int = Field(default=86400, description="Cover cache TTL in seconds (default: 24 hours)") + cache_cleanup_interval: int = Field(default=300) + + cache_dir: Path = Field(default=Path("/app/cache"), description="Root directory for all cache files") + library_db_path: Path = Field(default=Path("/app/cache/library.db"), description="SQLite library database path") + cover_cache_max_size_mb: int = Field(default=500, description="Maximum cover cache size in MB") + queue_db_path: Path = Field(default=Path("/app/cache/queue.db"), description="SQLite queue database path") + shutdown_grace_period: float = Field(default=10.0, description="Seconds to wait for tasks on shutdown") + + http_timeout: float = Field(default=10.0) + http_connect_timeout: float = Field(default=5.0) + http_max_connections: int = Field(default=200) + http_max_keepalive: int = Field(default=50) + + config_file_path: Path = Field(default=Path("/app/config/config.json")) + audiodb_api_key: str = Field(default="123") + audiodb_premium: bool = Field(default=False, description="Set to true if using a premium AudioDB API key") + + @field_validator("log_level") + @classmethod + def validate_log_level(cls, v: str) -> str: + normalised = v.upper() + if normalised not in _VALID_LOG_LEVELS: + raise ValueError( + f"Invalid log_level '{v}'. Must be one of: {', '.join(sorted(_VALID_LOG_LEVELS))}" + ) + return normalised + + @field_validator("lidarr_url", "jellyfin_url") + @classmethod + def validate_url(cls, v: str) -> str: + return v.rstrip("/") + + @model_validator(mode='after') + def validate_config(self) -> Self: + errors = [] + warnings = [] + + for url_field in ['lidarr_url', 'jellyfin_url']: + url = getattr(self, url_field, '') + if url and not url.startswith(('http://', 'https://')): + errors.append(f"{url_field} must start with http:// or https://") + + if self.http_max_connections < self.http_max_keepalive * 2: + warnings.append( + f"http_max_connections ({self.http_max_connections}) should be " + f"at least 2x http_max_keepalive ({self.http_max_keepalive})" + ) + + if not self.lidarr_api_key: + warnings.append("LIDARR_API_KEY is not set - Lidarr features will not work") + + for warning in warnings: + logger.warning(warning) + + if errors: + raise ConfigurationError( + f"Critical configuration errors: {'; '.join(errors)}" + ) + + return self + + def get_user_agent(self) -> str: + return f"Musicseerr/1.0 ({self.contact_email}; https://www.musicseerr.com)" + + def load_from_file(self) -> None: + if not self.config_file_path.exists(): + self._create_default_config() + return + + try: + config_data = read_json(self.config_file_path, default={}) + if not isinstance(config_data, dict): + raise ValueError("Config file JSON root must be an object") + + type_errors: list[str] = [] + model_fields = type(self).model_fields + validated_values: dict[str, object] = {} + for key, value in config_data.items(): + if key not in model_fields: + logger.warning("Unknown config key '%s' — ignoring", key) + continue + try: + field_info = model_fields[key] + adapter = TypeAdapter(field_info.annotation) + validated_values[key] = adapter.validate_python(value) + except PydanticValidationError as e: + type_errors.append( + f"'{key}': {e.errors()[0].get('msg', str(e))}" + ) + except (TypeError, ValueError) as e: + type_errors.append(f"'{key}': {e}") + + if type_errors: + raise ConfigurationError( + f"Config file type errors: {'; '.join(type_errors)}" + ) + + # Run field validators that TypeAdapter doesn't invoke + try: + for url_field in ('lidarr_url', 'jellyfin_url'): + if url_field in validated_values: + validated_values[url_field] = type(self).validate_url( + validated_values[url_field] + ) + if 'log_level' in validated_values: + validated_values['log_level'] = type(self).validate_log_level( + validated_values['log_level'] + ) + except ValueError as e: + raise ConfigurationError(f"Config file validation error: {e}") + + # Dry-run cross-field validation on merged candidate state + self._validate_merged(validated_values) + + # All validation passed — apply atomically + for key, value in validated_values.items(): + setattr(self, key, value) + + logger.info(f"Loaded configuration from {self.config_file_path}") + except (ConfigurationError, ValueError): + raise + except msgspec.DecodeError as e: + logger.error(f"Invalid JSON in config file: {e}") + raise ValueError(f"Config file is not valid JSON: {e}") + except Exception as e: + logger.error(f"Failed to load config: {e}") + raise + + def _validate_merged(self, overrides: dict[str, object]) -> None: + """Validate cross-field constraints against candidate merged state without mutating self.""" + errors = [] + + def _get(field: str) -> object: + return overrides.get(field, getattr(self, field)) + + for url_field in ('lidarr_url', 'jellyfin_url'): + url = _get(url_field) + if url and not str(url).startswith(('http://', 'https://')): + errors.append(f"{url_field} must start with http:// or https://") + + if errors: + raise ConfigurationError( + f"Critical configuration errors: {'; '.join(errors)}" + ) + + def _create_default_config(self) -> None: + self.config_file_path.parent.mkdir(parents=True, exist_ok=True) + config_data = { + "lidarr_url": self.lidarr_url, + "lidarr_api_key": self.lidarr_api_key, + "jellyfin_url": self.jellyfin_url, + "contact_email": self.contact_email, + "quality_profile_id": self.quality_profile_id, + "metadata_profile_id": self.metadata_profile_id, + "root_folder_path": self.root_folder_path, + "port": self.port, + "audiodb_api_key": self.audiodb_api_key, + "audiodb_premium": self.audiodb_premium, + "user_preferences": { + "primary_types": ["album", "ep", "single"], + "secondary_types": ["studio"], + "release_statuses": ["official"], + }, + } + atomic_write_json(self.config_file_path, config_data) + logger.info(f"Created default config at {self.config_file_path}") + + def save_to_file(self) -> None: + try: + self.config_file_path.parent.mkdir(parents=True, exist_ok=True) + + config_data = {} + if self.config_file_path.exists(): + loaded = read_json(self.config_file_path, default={}) + config_data = loaded if isinstance(loaded, dict) else {} + + config_data.update({ + "lidarr_url": self.lidarr_url, + "lidarr_api_key": self.lidarr_api_key, + "jellyfin_url": self.jellyfin_url, + "contact_email": self.contact_email, + "quality_profile_id": self.quality_profile_id, + "metadata_profile_id": self.metadata_profile_id, + "root_folder_path": self.root_folder_path, + "port": self.port, + "audiodb_api_key": self.audiodb_api_key, + "audiodb_premium": self.audiodb_premium, + }) + + atomic_write_json(self.config_file_path, config_data) + + logger.info(f"Saved config to {self.config_file_path}") + except Exception as e: + logger.error(f"Failed to save config: {e}") + raise + + +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + settings = Settings() + settings.load_from_file() + _settings = settings + return _settings diff --git a/backend/core/dependencies/__init__.py b/backend/core/dependencies/__init__.py new file mode 100644 index 0000000..ab48af5 --- /dev/null +++ b/backend/core/dependencies/__init__.py @@ -0,0 +1,122 @@ +"""Dependency injection providers for the MusicSeerr backend. + +This package replaces the former monolithic ``core/dependencies.py``. +All public names are re-exported here so that existing imports like +``from core.dependencies import get_home_service`` continue to work. +""" + +from .cache_providers import ( # noqa: F401 + get_cache, + get_disk_cache, + get_library_db, + get_genre_index, + get_youtube_store, + get_mbid_store, + get_sync_state_store, + get_persistence_write_lock, + get_preferences_service, + get_cache_service, + get_cache_status_service, +) + +from .repo_providers import ( # noqa: F401 + get_lidarr_repository, + get_musicbrainz_repository, + get_wikidata_repository, + get_listenbrainz_repository, + get_jellyfin_repository, + get_navidrome_repository, + get_coverart_repository, + get_youtube_repo, + get_audiodb_repository, + get_audiodb_image_service, + get_audiodb_browse_queue, + get_lastfm_repository, + get_playlist_repository, + get_request_history_store, +) + +from .service_providers import ( # noqa: F401 + get_search_service, + get_search_enrichment_service, + get_artist_service, + get_album_service, + get_request_queue, + get_request_service, + get_requests_page_service, + get_playlist_service, + get_library_service, + get_status_service, + get_home_service, + get_genre_cover_prewarm_service, + get_home_charts_service, + get_settings_service, + get_artist_discovery_service, + get_artist_enrichment_service, + get_album_enrichment_service, + get_album_discovery_service, + get_youtube_service, + get_lastfm_auth_service, + get_scrobble_service, + get_discover_service, + get_discover_queue_manager, + get_jellyfin_playback_service, + get_local_files_service, + get_jellyfin_library_service, + get_navidrome_library_service, + get_navidrome_playback_service, +) + +from .type_aliases import ( # noqa: F401 + SettingsDep, + CacheDep, + DiskCacheDep, + PreferencesServiceDep, + LidarrRepositoryDep, + MusicBrainzRepositoryDep, + WikidataRepositoryDep, + ListenBrainzRepositoryDep, + JellyfinRepositoryDep, + CoverArtRepositoryDep, + SearchServiceDep, + SearchEnrichmentServiceDep, + ArtistServiceDep, + AlbumServiceDep, + RequestQueueDep, + RequestServiceDep, + LibraryServiceDep, + StatusServiceDep, + CacheServiceDep, + HomeServiceDep, + HomeChartsServiceDep, + SettingsServiceDep, + ArtistDiscoveryServiceDep, + AlbumDiscoveryServiceDep, + DiscoverServiceDep, + DiscoverQueueManagerDep, + YouTubeRepositoryDep, + YouTubeServiceDep, + RequestHistoryStoreDep, + RequestsPageServiceDep, + JellyfinPlaybackServiceDep, + LocalFilesServiceDep, + JellyfinLibraryServiceDep, + LastFmRepositoryDep, + LastFmAuthServiceDep, + ScrobbleServiceDep, + PlaylistRepositoryDep, + PlaylistServiceDep, + NavidromeRepositoryDep, + NavidromeLibraryServiceDep, + NavidromePlaybackServiceDep, + CacheStatusServiceDep, +) + +from .cleanup import ( # noqa: F401 + init_app_state, + cleanup_app_state, + clear_lastfm_dependent_caches, + clear_listenbrainz_dependent_caches, +) + +from ._registry import clear_all_singletons, _singleton_registry # noqa: F401 diff --git a/backend/core/dependencies/_registry.py b/backend/core/dependencies/_registry.py new file mode 100644 index 0000000..1cb11f9 --- /dev/null +++ b/backend/core/dependencies/_registry.py @@ -0,0 +1,35 @@ +"""Singleton decorator and automatic cleanup registry for DI providers.""" + +from __future__ import annotations + +from functools import lru_cache, wraps +from typing import Callable, TypeVar + +F = TypeVar("F", bound=Callable) + +_singleton_registry: list[Callable] = [] + + +def singleton(fn: F) -> F: + """Wrap *fn* with ``@lru_cache(maxsize=1)`` and register it for automatic cleanup.""" + cached = lru_cache(maxsize=1)(fn) + _singleton_registry.append(cached) + + @wraps(fn) + def wrapper(*args, **kwargs): + return cached(*args, **kwargs) + + # Expose cache_clear so callers can invalidate individual singletons + wrapper.cache_clear = cached.cache_clear # type: ignore[attr-defined] + wrapper.cache_info = cached.cache_info # type: ignore[attr-defined] + wrapper._cached = cached # type: ignore[attr-defined] + _singleton_registry[-1] = wrapper # replace with the wrapper so clear_all hits wrapper + return wrapper # type: ignore[return-value] + + +def clear_all_singletons() -> None: + """Call ``cache_clear()`` on every registered singleton provider.""" + for fn in _singleton_registry: + cache_clear = getattr(fn, "cache_clear", None) + if callable(cache_clear): + cache_clear() diff --git a/backend/core/dependencies/cache_providers.py b/backend/core/dependencies/cache_providers.py new file mode 100644 index 0000000..c47838f --- /dev/null +++ b/backend/core/dependencies/cache_providers.py @@ -0,0 +1,111 @@ +"""Tier 2 — Cache layer, persistence stores, and foundation providers.""" + +from __future__ import annotations + +import logging +import threading + +from core.config import get_settings +from infrastructure.cache.memory_cache import InMemoryCache, CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.persistence import ( + LibraryDB, + GenreIndex, + YouTubeStore, + MBIDStore, + SyncStateStore, +) + +from ._registry import singleton + +logger = logging.getLogger(__name__) + + +@singleton +def get_cache() -> CacheInterface: + preferences_service = get_preferences_service() + advanced = preferences_service.get_advanced_settings() + max_entries = advanced.memory_cache_max_entries + logger.info(f"Initialized RAM cache with max {max_entries} entries") + return InMemoryCache(max_entries=max_entries) + + +@singleton +def get_disk_cache() -> DiskMetadataCache: + settings = get_settings() + preferences_service = get_preferences_service() + advanced = preferences_service.get_advanced_settings() + cache_dir = settings.cache_dir / "metadata" + logger.info(f"Initialized disk metadata cache at {cache_dir}") + return DiskMetadataCache( + base_path=cache_dir, + recent_metadata_max_size_mb=advanced.recent_metadata_max_size_mb, + recent_covers_max_size_mb=advanced.recent_covers_max_size_mb, + persistent_metadata_ttl_hours=advanced.persistent_metadata_ttl_hours, + ) + + +# -- Persistence store providers (shared write lock + DB path) -- + +@singleton +def get_persistence_write_lock() -> threading.Lock: + return threading.Lock() + + +@singleton +def get_library_db() -> LibraryDB: + settings = get_settings() + lock = get_persistence_write_lock() + return LibraryDB(db_path=settings.library_db_path, write_lock=lock) + + +@singleton +def get_genre_index() -> GenreIndex: + settings = get_settings() + lock = get_persistence_write_lock() + return GenreIndex(db_path=settings.library_db_path, write_lock=lock) + + +@singleton +def get_youtube_store() -> YouTubeStore: + settings = get_settings() + lock = get_persistence_write_lock() + return YouTubeStore(db_path=settings.library_db_path, write_lock=lock) + + +@singleton +def get_mbid_store() -> MBIDStore: + settings = get_settings() + lock = get_persistence_write_lock() + return MBIDStore(db_path=settings.library_db_path, write_lock=lock) + + +@singleton +def get_sync_state_store() -> SyncStateStore: + settings = get_settings() + lock = get_persistence_write_lock() + return SyncStateStore(db_path=settings.library_db_path, write_lock=lock) + + +@singleton +def get_preferences_service() -> "PreferencesService": + from services.preferences_service import PreferencesService + + settings = get_settings() + return PreferencesService(settings) + + +@singleton +def get_cache_service() -> "CacheService": + from services.cache_service import CacheService + + cache = get_cache() + library_db = get_library_db() + disk_cache = get_disk_cache() + return CacheService(cache, library_db, disk_cache) + + +def get_cache_status_service() -> "CacheStatusService": + from services.cache_status_service import CacheStatusService + + return CacheStatusService() diff --git a/backend/core/dependencies/cleanup.py b/backend/core/dependencies/cleanup.py new file mode 100644 index 0000000..915a9dc --- /dev/null +++ b/backend/core/dependencies/cleanup.py @@ -0,0 +1,80 @@ +"""Application lifecycle and targeted cache invalidation.""" + +from __future__ import annotations + +import logging + +from infrastructure.http.client import close_http_clients + +from ._registry import clear_all_singletons +from .service_providers import ( + get_artist_discovery_service, + get_artist_enrichment_service, + get_album_enrichment_service, + get_album_discovery_service, + get_search_enrichment_service, + get_scrobble_service, + get_home_charts_service, + get_home_service, + get_discover_service, + get_discover_queue_manager, + get_lastfm_auth_service, + get_genre_cover_prewarm_service, +) +from .repo_providers import get_listenbrainz_repository + +logger = logging.getLogger(__name__) + + +def clear_lastfm_dependent_caches() -> None: + """Clear LRU caches for all services that hold a reference to LastFmRepository.""" + get_artist_discovery_service.cache_clear() + get_artist_enrichment_service.cache_clear() + get_album_enrichment_service.cache_clear() + get_search_enrichment_service.cache_clear() + get_scrobble_service.cache_clear() + get_home_charts_service.cache_clear() + get_home_service.cache_clear() + get_discover_service.cache_clear() + get_discover_queue_manager.cache_clear() + get_lastfm_auth_service.cache_clear() + + +def clear_listenbrainz_dependent_caches() -> None: + """Clear LRU caches for all services that hold a reference to ListenBrainzRepository.""" + get_listenbrainz_repository.cache_clear() + get_artist_discovery_service.cache_clear() + get_album_discovery_service.cache_clear() + get_search_enrichment_service.cache_clear() + get_scrobble_service.cache_clear() + get_home_charts_service.cache_clear() + get_home_service.cache_clear() + get_discover_service.cache_clear() + get_discover_queue_manager.cache_clear() + + +async def init_app_state(app) -> None: + logger.info("Application state initialized") + + +async def cleanup_app_state() -> None: + # Graceful service shutdown + try: + queue_mgr = get_discover_queue_manager() + queue_mgr.invalidate() + except (AttributeError, RuntimeError) as exc: + logger.error("Failed to invalidate discover queue manager during cleanup: %s", exc) + + await close_http_clients() + + # Shutdown genre prewarm service before clearing singletons + try: + prewarm_svc = get_genre_cover_prewarm_service() + await prewarm_svc.shutdown() + except (AttributeError, RuntimeError, OSError) as exc: + logger.error("Failed to shut down genre prewarm service during cleanup: %s", exc) + + # Automatic cleanup via registry — no manual list needed + clear_all_singletons() + + logger.info("Application state cleaned up") diff --git a/backend/core/dependencies/repo_providers.py b/backend/core/dependencies/repo_providers.py new file mode 100644 index 0000000..367fd86 --- /dev/null +++ b/backend/core/dependencies/repo_providers.py @@ -0,0 +1,239 @@ +"""Tier 3 — Repository providers and infrastructure services.""" + +from __future__ import annotations + +import logging + +import httpx + +from core.config import get_settings +from infrastructure.http.client import get_http_client, get_listenbrainz_http_client + +from ._registry import singleton +from .cache_providers import ( + get_cache, + get_disk_cache, + get_mbid_store, + get_preferences_service, +) + +logger = logging.getLogger(__name__) + + +def _get_configured_http_client() -> httpx.AsyncClient: + settings = get_settings() + advanced = get_preferences_service().get_advanced_settings() + return get_http_client( + settings, + timeout=float(advanced.http_timeout), + connect_timeout=float(advanced.http_connect_timeout), + max_connections=advanced.http_max_connections, + ) + + +@singleton +def get_lidarr_repository() -> "LidarrRepository": + from repositories.lidarr import LidarrRepository + + settings = get_settings() + cache = get_cache() + http_client = _get_configured_http_client() + return LidarrRepository(settings, http_client, cache) + + +@singleton +def get_musicbrainz_repository() -> "MusicBrainzRepository": + from repositories.musicbrainz_repository import MusicBrainzRepository + + cache = get_cache() + preferences_service = get_preferences_service() + http_client = _get_configured_http_client() + return MusicBrainzRepository(http_client, cache, preferences_service) + + +@singleton +def get_wikidata_repository() -> "WikidataRepository": + from repositories.wikidata_repository import WikidataRepository + + cache = get_cache() + http_client = _get_configured_http_client() + return WikidataRepository(http_client, cache) + + +@singleton +def get_listenbrainz_repository() -> "ListenBrainzRepository": + from repositories.listenbrainz_repository import ListenBrainzRepository + + cache = get_cache() + http_client = get_listenbrainz_http_client( + settings=get_settings(), + timeout=float(get_preferences_service().get_advanced_settings().http_timeout), + connect_timeout=float(get_preferences_service().get_advanced_settings().http_connect_timeout), + ) + preferences = get_preferences_service() + lb_settings = preferences.get_listenbrainz_connection() + return ListenBrainzRepository( + http_client=http_client, + cache=cache, + username=lb_settings.username if lb_settings.enabled else "", + user_token=lb_settings.user_token if lb_settings.enabled else "", + ) + + +@singleton +def get_jellyfin_repository() -> "JellyfinRepository": + from repositories.jellyfin_repository import JellyfinRepository + + cache = get_cache() + mbid_store = get_mbid_store() + http_client = _get_configured_http_client() + preferences = get_preferences_service() + jf_settings = preferences.get_jellyfin_connection() + return JellyfinRepository( + http_client=http_client, + cache=cache, + base_url=jf_settings.jellyfin_url if jf_settings.enabled else "", + api_key=jf_settings.api_key if jf_settings.enabled else "", + user_id=jf_settings.user_id if jf_settings.enabled else "", + mbid_store=mbid_store, + ) + + +@singleton +def get_navidrome_repository() -> "NavidromeRepository": + from repositories.navidrome_repository import NavidromeRepository + + cache = get_cache() + http_client = _get_configured_http_client() + preferences = get_preferences_service() + nd_settings = preferences.get_navidrome_connection_raw() + repo = NavidromeRepository(http_client=http_client, cache=cache) + if nd_settings.enabled: + repo.configure( + url=nd_settings.navidrome_url, + username=nd_settings.username, + password=nd_settings.password, + ) + adv = preferences.get_advanced_settings() + repo.configure_cache_ttls( + list_ttl=getattr(adv, "cache_ttl_navidrome_albums", 300), + search_ttl=getattr(adv, "cache_ttl_navidrome_search", 120), + genres_ttl=getattr(adv, "cache_ttl_navidrome_genres", 3600), + detail_ttl=getattr(adv, "cache_ttl_navidrome_albums", 300), + ) + return repo + + +@singleton +def get_youtube_repo() -> "YouTubeRepository": + from repositories.youtube import YouTubeRepository + + http_client = _get_configured_http_client() + preferences_service = get_preferences_service() + yt_settings = preferences_service.get_youtube_connection() + api_key = yt_settings.api_key.strip() if (yt_settings.enabled and yt_settings.api_enabled and yt_settings.has_valid_api_key()) else "" + return YouTubeRepository( + http_client=http_client, + api_key=api_key, + daily_quota_limit=yt_settings.daily_quota_limit, + ) + + +@singleton +def get_audiodb_repository() -> "AudioDBRepository": + from repositories.audiodb_repository import AudioDBRepository + + settings = get_settings() + http_client = _get_configured_http_client() + preferences_service = get_preferences_service() + return AudioDBRepository( + http_client=http_client, + preferences_service=preferences_service, + api_key=settings.audiodb_api_key, + premium=settings.audiodb_premium, + ) + + +@singleton +def get_audiodb_image_service() -> "AudioDBImageService": + from services.audiodb_image_service import AudioDBImageService + + audiodb_repo = get_audiodb_repository() + disk_cache = get_disk_cache() + preferences_service = get_preferences_service() + memory_cache = get_cache() + return AudioDBImageService( + audiodb_repo=audiodb_repo, + disk_cache=disk_cache, + preferences_service=preferences_service, + memory_cache=memory_cache, + ) + + +@singleton +def get_audiodb_browse_queue() -> "AudioDBBrowseQueue": + from services.audiodb_browse_queue import AudioDBBrowseQueue + + return AudioDBBrowseQueue() + + +@singleton +def get_lastfm_repository() -> "LastFmRepository": + from repositories.lastfm_repository import LastFmRepository + + http_client = _get_configured_http_client() + preferences = get_preferences_service() + lf_settings = preferences.get_lastfm_connection() + cache = get_cache() + return LastFmRepository( + http_client=http_client, + cache=cache, + api_key=lf_settings.api_key, + shared_secret=lf_settings.shared_secret, + session_key=lf_settings.session_key, + ) + + +@singleton +def get_playlist_repository() -> "PlaylistRepository": + from repositories.playlist_repository import PlaylistRepository + + settings = get_settings() + return PlaylistRepository(db_path=settings.library_db_path) + + +@singleton +def get_request_history_store() -> "RequestHistoryStore": + from infrastructure.persistence.request_history import RequestHistoryStore + from .cache_providers import get_persistence_write_lock + + settings = get_settings() + return RequestHistoryStore(db_path=settings.library_db_path, write_lock=get_persistence_write_lock()) + + +@singleton +def get_coverart_repository() -> "CoverArtRepository": + from repositories.coverart_repository import CoverArtRepository + + settings = get_settings() + advanced = get_preferences_service().get_advanced_settings() + cache = get_cache() + mb_repo = get_musicbrainz_repository() + lidarr_repo = get_lidarr_repository() + jellyfin_repo = get_jellyfin_repository() + audiodb_service = get_audiodb_image_service() + http_client = _get_configured_http_client() + cache_dir = settings.cache_dir / "covers" + return CoverArtRepository( + http_client, + cache, + mb_repo, + lidarr_repo, + jellyfin_repo, + audiodb_service=audiodb_service, + cache_dir=cache_dir, + cover_cache_max_size_mb=settings.cover_cache_max_size_mb, + cover_memory_cache_max_entries=advanced.cover_memory_cache_max_entries, + cover_memory_cache_max_bytes=advanced.cover_memory_cache_max_size_mb * 1024 * 1024, + cover_non_monitored_ttl_seconds=advanced.cache_ttl_recently_viewed_bytes, + ) diff --git a/backend/core/dependencies/service_providers.py b/backend/core/dependencies/service_providers.py new file mode 100644 index 0000000..fe54c94 --- /dev/null +++ b/backend/core/dependencies/service_providers.py @@ -0,0 +1,514 @@ +"""Tier 4 — Business-logic service providers.""" + +from __future__ import annotations + +import asyncio +import logging + +from infrastructure.cache.cache_keys import ( + lidarr_raw_albums_key, + lidarr_requested_mbids_key, + HOME_RESPONSE_PREFIX, + ALBUM_INFO_PREFIX, + ARTIST_INFO_PREFIX, + LIDARR_PREFIX, + LIDARR_ALBUM_DETAILS_PREFIX, +) +from infrastructure.persistence.request_history import RequestHistoryRecord + +from ._registry import singleton +from .cache_providers import ( + get_cache, + get_disk_cache, + get_library_db, + get_genre_index, + get_youtube_store, + get_mbid_store, + get_sync_state_store, + get_preferences_service, + get_cache_status_service, +) +from .repo_providers import ( + get_lidarr_repository, + get_musicbrainz_repository, + get_wikidata_repository, + get_listenbrainz_repository, + get_jellyfin_repository, + get_navidrome_repository, + get_coverart_repository, + get_youtube_repo, + get_audiodb_image_service, + get_audiodb_browse_queue, + get_lastfm_repository, + get_playlist_repository, + get_request_history_store, +) + +logger = logging.getLogger(__name__) + + +@singleton +def get_search_service() -> "SearchService": + from services.search_service import SearchService + + mb_repo = get_musicbrainz_repository() + lidarr_repo = get_lidarr_repository() + coverart_repo = get_coverart_repository() + preferences_service = get_preferences_service() + audiodb_image_service = get_audiodb_image_service() + browse_queue = get_audiodb_browse_queue() + return SearchService(mb_repo, lidarr_repo, coverart_repo, preferences_service, audiodb_image_service, browse_queue) + + +@singleton +def get_artist_service() -> "ArtistService": + from services.artist_service import ArtistService + + mb_repo = get_musicbrainz_repository() + lidarr_repo = get_lidarr_repository() + wikidata_repo = get_wikidata_repository() + preferences_service = get_preferences_service() + memory_cache = get_cache() + disk_cache = get_disk_cache() + audiodb_image_service = get_audiodb_image_service() + browse_queue = get_audiodb_browse_queue() + library_db = get_library_db() + return ArtistService(mb_repo, lidarr_repo, wikidata_repo, preferences_service, memory_cache, disk_cache, audiodb_image_service, browse_queue, library_db) + + +@singleton +def get_album_service() -> "AlbumService": + from services.album_service import AlbumService + + lidarr_repo = get_lidarr_repository() + mb_repo = get_musicbrainz_repository() + library_db = get_library_db() + memory_cache = get_cache() + disk_cache = get_disk_cache() + preferences_service = get_preferences_service() + audiodb_image_service = get_audiodb_image_service() + browse_queue = get_audiodb_browse_queue() + return AlbumService(lidarr_repo, mb_repo, library_db, memory_cache, disk_cache, preferences_service, audiodb_image_service, browse_queue) + + +@singleton +def get_request_queue() -> "RequestQueue": + from infrastructure.queue.request_queue import RequestQueue + from infrastructure.queue.queue_store import QueueStore + from core.config import get_settings + settings = get_settings() + + lidarr_repo = get_lidarr_repository() + disk_cache = get_disk_cache() + cover_repo = get_coverart_repository() + + async def processor(album_mbid: str) -> dict: + result = await lidarr_repo.add_album(album_mbid) + + payload = result.get("payload", {}) + if payload and isinstance(payload, dict): + is_monitored = payload.get("monitored", False) + + if is_monitored: + logger.info(f"Album {album_mbid[:8]}... successfully monitored - promoting cache entries to persistent") + + try: + await disk_cache.promote_album_to_persistent(album_mbid) + await cover_repo.promote_cover_to_persistent(album_mbid, identifier_type="album") + + artist_data = payload.get("artist", {}) + if artist_data: + artist_mbid = artist_data.get("foreignArtistId") or artist_data.get("mbId") + if artist_mbid: + await disk_cache.promote_artist_to_persistent(artist_mbid) + await cover_repo.promote_cover_to_persistent(artist_mbid, identifier_type="artist") + + logger.info(f"Cache promotion complete for album {album_mbid[:8]}...") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to promote cache entries for album {album_mbid[:8]}...: {e}") + else: + logger.warning(f"Album {album_mbid[:8]}... added but not monitored - skipping cache promotion") + + return result + + store = QueueStore(db_path=settings.queue_db_path) + return RequestQueue(processor, store=store) + + +@singleton +def get_request_service() -> "RequestService": + from services.request_service import RequestService + + lidarr_repo = get_lidarr_repository() + request_queue = get_request_queue() + request_history = get_request_history_store() + return RequestService(lidarr_repo, request_queue, request_history) + + +@singleton +def get_requests_page_service() -> "RequestsPageService": + from services.requests_page_service import RequestsPageService + + lidarr_repo = get_lidarr_repository() + request_history = get_request_history_store() + memory_cache = get_cache() + disk_cache = get_disk_cache() + library_db = get_library_db() + + async def on_import(record: RequestHistoryRecord) -> None: + invalidations = [ + memory_cache.delete(lidarr_raw_albums_key()), + memory_cache.clear_prefix(f"{LIDARR_PREFIX}library:"), + memory_cache.delete(lidarr_requested_mbids_key()), + memory_cache.clear_prefix(HOME_RESPONSE_PREFIX), + memory_cache.delete(f"{ALBUM_INFO_PREFIX}{record.musicbrainz_id}"), + memory_cache.delete(f"{LIDARR_ALBUM_DETAILS_PREFIX}{record.musicbrainz_id}"), + ] + if record.artist_mbid: + invalidations.append( + memory_cache.delete(f"{ARTIST_INFO_PREFIX}{record.artist_mbid}") + ) + await asyncio.gather(*invalidations, return_exceptions=True) + if record.artist_mbid: + await asyncio.gather( + disk_cache.delete_album(record.musicbrainz_id), + disk_cache.delete_artist(record.artist_mbid), + return_exceptions=True, + ) + else: + try: + await disk_cache.delete_album(record.musicbrainz_id) + except OSError as exc: + logger.warning( + "Failed to delete disk cache album %s during import invalidation: %s", + record.musicbrainz_id, + exc, + ) + try: + await library_db.upsert_album({ + "mbid": record.musicbrainz_id, + "artist_mbid": record.artist_mbid or "", + "artist_name": record.artist_name or "", + "title": record.album_title or "", + "year": record.year, + "cover_url": record.cover_url or "", + "monitored": True, + }) + except Exception as ex: # noqa: BLE001 + logger.warning("Failed to upsert album into library cache: %s", ex) + logger.info( + "Invalidated caches after import: album=%s artist=%s", + record.musicbrainz_id[:8], + (record.artist_mbid or "?")[:8], + ) + + return RequestsPageService( + lidarr_repo=lidarr_repo, + request_history=request_history, + library_mbids_fn=lidarr_repo.get_library_mbids, + on_import_callback=on_import, + ) + + +@singleton +def get_playlist_service() -> "PlaylistService": + from services.playlist_service import PlaylistService + from core.config import get_settings + + settings = get_settings() + playlist_repo = get_playlist_repository() + return PlaylistService( + repo=playlist_repo, + cache_dir=settings.cache_dir, + cache=get_cache(), + ) + + +@singleton +def get_library_service() -> "LibraryService": + from services.library_service import LibraryService + + lidarr_repo = get_lidarr_repository() + library_db = get_library_db() + cover_repo = get_coverart_repository() + preferences_service = get_preferences_service() + memory_cache = get_cache() + disk_cache = get_disk_cache() + artist_discovery_service = get_artist_discovery_service() + audiodb_image_service = get_audiodb_image_service() + local_files_service = get_local_files_service() + jellyfin_library_service = get_jellyfin_library_service() + navidrome_library_service = get_navidrome_library_service() + sync_state_store = get_sync_state_store() + genre_index = get_genre_index() + return LibraryService( + lidarr_repo, library_db, cover_repo, preferences_service, + memory_cache, disk_cache, + artist_discovery_service=artist_discovery_service, + audiodb_image_service=audiodb_image_service, + local_files_service=local_files_service, + jellyfin_library_service=jellyfin_library_service, + navidrome_library_service=navidrome_library_service, + sync_state_store=sync_state_store, + genre_index=genre_index, + ) + + +@singleton +def get_status_service() -> "StatusService": + from services.status_service import StatusService + + lidarr_repo = get_lidarr_repository() + return StatusService(lidarr_repo) + + +@singleton +def get_home_service() -> "HomeService": + from services.home_service import HomeService + from core.config import get_settings + + settings = get_settings() + listenbrainz_repo = get_listenbrainz_repository() + jellyfin_repo = get_jellyfin_repository() + lidarr_repo = get_lidarr_repository() + musicbrainz_repo = get_musicbrainz_repository() + preferences_service = get_preferences_service() + memory_cache = get_cache() + lastfm_repo = get_lastfm_repository() + audiodb_image_service = get_audiodb_image_service() + return HomeService( + listenbrainz_repo=listenbrainz_repo, + jellyfin_repo=jellyfin_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=musicbrainz_repo, + preferences_service=preferences_service, + memory_cache=memory_cache, + lastfm_repo=lastfm_repo, + audiodb_image_service=audiodb_image_service, + cache_dir=settings.cache_dir, + ) + + +@singleton +def get_genre_cover_prewarm_service() -> "GenreCoverPrewarmService": + from services.genre_cover_prewarm_service import GenreCoverPrewarmService + + cover_repo = get_coverart_repository() + return GenreCoverPrewarmService(cover_repo=cover_repo) + + +@singleton +def get_home_charts_service() -> "HomeChartsService": + from services.home_charts_service import HomeChartsService + + listenbrainz_repo = get_listenbrainz_repository() + lidarr_repo = get_lidarr_repository() + musicbrainz_repo = get_musicbrainz_repository() + genre_index = get_genre_index() + lastfm_repo = get_lastfm_repository() + preferences_service = get_preferences_service() + prewarm_service = get_genre_cover_prewarm_service() + return HomeChartsService( + listenbrainz_repo=listenbrainz_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=musicbrainz_repo, + genre_index=genre_index, + lastfm_repo=lastfm_repo, + preferences_service=preferences_service, + prewarm_service=prewarm_service, + ) + + +@singleton +def get_settings_service() -> "SettingsService": + from services.settings_service import SettingsService + + preferences_service = get_preferences_service() + cache = get_cache() + return SettingsService(preferences_service, cache) + + +@singleton +def get_artist_discovery_service() -> "ArtistDiscoveryService": + from services.artist_discovery_service import ArtistDiscoveryService + + listenbrainz_repo = get_listenbrainz_repository() + musicbrainz_repo = get_musicbrainz_repository() + library_db = get_library_db() + lidarr_repo = get_lidarr_repository() + lastfm_repo = get_lastfm_repository() + preferences_service = get_preferences_service() + memory_cache = get_cache() + return ArtistDiscoveryService( + listenbrainz_repo=listenbrainz_repo, + musicbrainz_repo=musicbrainz_repo, + library_db=library_db, + lidarr_repo=lidarr_repo, + memory_cache=memory_cache, + lastfm_repo=lastfm_repo, + preferences_service=preferences_service, + ) + + +@singleton +def get_artist_enrichment_service() -> "ArtistEnrichmentService": + from services.artist_enrichment_service import ArtistEnrichmentService + + lastfm_repo = get_lastfm_repository() + preferences_service = get_preferences_service() + return ArtistEnrichmentService( + lastfm_repo=lastfm_repo, + preferences_service=preferences_service, + ) + + +@singleton +def get_album_enrichment_service() -> "AlbumEnrichmentService": + from services.album_enrichment_service import AlbumEnrichmentService + + lastfm_repo = get_lastfm_repository() + preferences_service = get_preferences_service() + return AlbumEnrichmentService( + lastfm_repo=lastfm_repo, + preferences_service=preferences_service, + ) + + +@singleton +def get_album_discovery_service() -> "AlbumDiscoveryService": + from services.album_discovery_service import AlbumDiscoveryService + + listenbrainz_repo = get_listenbrainz_repository() + musicbrainz_repo = get_musicbrainz_repository() + library_db = get_library_db() + lidarr_repo = get_lidarr_repository() + return AlbumDiscoveryService( + listenbrainz_repo=listenbrainz_repo, + musicbrainz_repo=musicbrainz_repo, + library_db=library_db, + lidarr_repo=lidarr_repo, + ) + + +@singleton +def get_search_enrichment_service() -> "SearchEnrichmentService": + from services.search_enrichment_service import SearchEnrichmentService + + mb_repo = get_musicbrainz_repository() + lb_repo = get_listenbrainz_repository() + preferences_service = get_preferences_service() + lastfm_repo = get_lastfm_repository() + return SearchEnrichmentService(mb_repo, lb_repo, preferences_service, lastfm_repo) + + +@singleton +def get_youtube_service() -> "YouTubeService": + from services.youtube_service import YouTubeService + + youtube_repo = get_youtube_repo() + youtube_store = get_youtube_store() + return YouTubeService(youtube_repo=youtube_repo, youtube_store=youtube_store) + + +@singleton +def get_lastfm_auth_service() -> "LastFmAuthService": + from services.lastfm_auth_service import LastFmAuthService + + lastfm_repo = get_lastfm_repository() + return LastFmAuthService(lastfm_repo=lastfm_repo) + + +@singleton +def get_scrobble_service() -> "ScrobbleService": + from services.scrobble_service import ScrobbleService + + lastfm_repo = get_lastfm_repository() + listenbrainz_repo = get_listenbrainz_repository() + preferences_service = get_preferences_service() + return ScrobbleService(lastfm_repo, listenbrainz_repo, preferences_service) + + +@singleton +def get_discover_service() -> "DiscoverService": + from services.discover_service import DiscoverService + + listenbrainz_repo = get_listenbrainz_repository() + jellyfin_repo = get_jellyfin_repository() + lidarr_repo = get_lidarr_repository() + musicbrainz_repo = get_musicbrainz_repository() + preferences_service = get_preferences_service() + memory_cache = get_cache() + library_db = get_library_db() + mbid_store = get_mbid_store() + wikidata_repo = get_wikidata_repository() + lastfm_repo = get_lastfm_repository() + audiodb_image_service = get_audiodb_image_service() + return DiscoverService( + listenbrainz_repo=listenbrainz_repo, + jellyfin_repo=jellyfin_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=musicbrainz_repo, + preferences_service=preferences_service, + memory_cache=memory_cache, + library_db=library_db, + mbid_store=mbid_store, + wikidata_repo=wikidata_repo, + lastfm_repo=lastfm_repo, + audiodb_image_service=audiodb_image_service, + ) + + +@singleton +def get_discover_queue_manager() -> "DiscoverQueueManager": + from services.discover_queue_manager import DiscoverQueueManager + + discover_service = get_discover_service() + preferences_service = get_preferences_service() + cover_repo = get_coverart_repository() + return DiscoverQueueManager(discover_service, preferences_service, cover_repo=cover_repo) + + +@singleton +def get_jellyfin_playback_service() -> "JellyfinPlaybackService": + from services.jellyfin_playback_service import JellyfinPlaybackService + + jellyfin_repo = get_jellyfin_repository() + return JellyfinPlaybackService(jellyfin_repo) + + +@singleton +def get_local_files_service() -> "LocalFilesService": + from services.local_files_service import LocalFilesService + + lidarr_repo = get_lidarr_repository() + preferences_service = get_preferences_service() + cache = get_cache() + return LocalFilesService(lidarr_repo, preferences_service, cache) + + +@singleton +def get_jellyfin_library_service() -> "JellyfinLibraryService": + from services.jellyfin_library_service import JellyfinLibraryService + + jellyfin_repo = get_jellyfin_repository() + preferences_service = get_preferences_service() + return JellyfinLibraryService(jellyfin_repo, preferences_service) + + +@singleton +def get_navidrome_library_service() -> "NavidromeLibraryService": + from services.navidrome_library_service import NavidromeLibraryService + + navidrome_repo = get_navidrome_repository() + preferences_service = get_preferences_service() + library_db = get_library_db() + mbid_store = get_mbid_store() + return NavidromeLibraryService(navidrome_repo, preferences_service, library_db, mbid_store) + + +@singleton +def get_navidrome_playback_service() -> "NavidromePlaybackService": + from services.navidrome_playback_service import NavidromePlaybackService + + navidrome_repo = get_navidrome_repository() + return NavidromePlaybackService(navidrome_repo) diff --git a/backend/core/dependencies/type_aliases.py b/backend/core/dependencies/type_aliases.py new file mode 100644 index 0000000..8f64915 --- /dev/null +++ b/backend/core/dependencies/type_aliases.py @@ -0,0 +1,142 @@ +"""FastAPI ``Annotated[..., Depends()]`` type aliases for route handlers.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends + +from core.config import Settings, get_settings +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.queue.request_queue import RequestQueue +from infrastructure.persistence.request_history import RequestHistoryStore +from repositories.lidarr import LidarrRepository +from repositories.musicbrainz_repository import MusicBrainzRepository +from repositories.wikidata_repository import WikidataRepository +from repositories.listenbrainz_repository import ListenBrainzRepository +from repositories.jellyfin_repository import JellyfinRepository +from repositories.coverart_repository import CoverArtRepository +from repositories.youtube import YouTubeRepository +from repositories.lastfm_repository import LastFmRepository +from repositories.playlist_repository import PlaylistRepository +from repositories.navidrome_repository import NavidromeRepository +from services.preferences_service import PreferencesService +from services.search_service import SearchService +from services.search_enrichment_service import SearchEnrichmentService +from services.artist_service import ArtistService +from services.album_service import AlbumService +from services.request_service import RequestService +from services.library_service import LibraryService +from services.status_service import StatusService +from services.cache_service import CacheService +from services.home_service import HomeService +from services.home_charts_service import HomeChartsService +from services.settings_service import SettingsService +from services.artist_discovery_service import ArtistDiscoveryService +from services.album_discovery_service import AlbumDiscoveryService +from services.discover_service import DiscoverService +from services.discover_queue_manager import DiscoverQueueManager +from services.youtube_service import YouTubeService +from services.requests_page_service import RequestsPageService +from services.jellyfin_playback_service import JellyfinPlaybackService +from services.local_files_service import LocalFilesService +from services.jellyfin_library_service import JellyfinLibraryService +from services.navidrome_library_service import NavidromeLibraryService +from services.navidrome_playback_service import NavidromePlaybackService +from services.playlist_service import PlaylistService +from services.lastfm_auth_service import LastFmAuthService +from services.scrobble_service import ScrobbleService +from services.cache_status_service import CacheStatusService + +from .cache_providers import ( + get_cache, + get_disk_cache, + get_preferences_service, + get_cache_service, + get_cache_status_service, +) +from .repo_providers import ( + get_lidarr_repository, + get_musicbrainz_repository, + get_wikidata_repository, + get_listenbrainz_repository, + get_jellyfin_repository, + get_coverart_repository, + get_youtube_repo, + get_lastfm_repository, + get_playlist_repository, + get_request_history_store, + get_navidrome_repository, +) +from .service_providers import ( + get_search_service, + get_search_enrichment_service, + get_artist_service, + get_album_service, + get_request_queue, + get_request_service, + get_requests_page_service, + get_playlist_service, + get_library_service, + get_status_service, + get_home_service, + get_home_charts_service, + get_settings_service, + get_artist_discovery_service, + get_album_discovery_service, + get_discover_service, + get_discover_queue_manager, + get_youtube_service, + get_lastfm_auth_service, + get_scrobble_service, + get_jellyfin_playback_service, + get_local_files_service, + get_jellyfin_library_service, + get_navidrome_library_service, + get_navidrome_playback_service, +) + + +SettingsDep = Annotated[Settings, Depends(get_settings)] +CacheDep = Annotated[CacheInterface, Depends(get_cache)] +DiskCacheDep = Annotated[DiskMetadataCache, Depends(get_disk_cache)] +PreferencesServiceDep = Annotated[PreferencesService, Depends(get_preferences_service)] +LidarrRepositoryDep = Annotated[LidarrRepository, Depends(get_lidarr_repository)] +MusicBrainzRepositoryDep = Annotated[MusicBrainzRepository, Depends(get_musicbrainz_repository)] +WikidataRepositoryDep = Annotated[WikidataRepository, Depends(get_wikidata_repository)] +ListenBrainzRepositoryDep = Annotated[ListenBrainzRepository, Depends(get_listenbrainz_repository)] +JellyfinRepositoryDep = Annotated[JellyfinRepository, Depends(get_jellyfin_repository)] +CoverArtRepositoryDep = Annotated[CoverArtRepository, Depends(get_coverart_repository)] +SearchServiceDep = Annotated[SearchService, Depends(get_search_service)] +SearchEnrichmentServiceDep = Annotated[SearchEnrichmentService, Depends(get_search_enrichment_service)] +ArtistServiceDep = Annotated[ArtistService, Depends(get_artist_service)] +AlbumServiceDep = Annotated[AlbumService, Depends(get_album_service)] +RequestQueueDep = Annotated[RequestQueue, Depends(get_request_queue)] +RequestServiceDep = Annotated[RequestService, Depends(get_request_service)] +LibraryServiceDep = Annotated[LibraryService, Depends(get_library_service)] +StatusServiceDep = Annotated[StatusService, Depends(get_status_service)] +CacheServiceDep = Annotated[CacheService, Depends(get_cache_service)] +HomeServiceDep = Annotated[HomeService, Depends(get_home_service)] +HomeChartsServiceDep = Annotated[HomeChartsService, Depends(get_home_charts_service)] +SettingsServiceDep = Annotated[SettingsService, Depends(get_settings_service)] +ArtistDiscoveryServiceDep = Annotated[ArtistDiscoveryService, Depends(get_artist_discovery_service)] +AlbumDiscoveryServiceDep = Annotated[AlbumDiscoveryService, Depends(get_album_discovery_service)] +DiscoverServiceDep = Annotated[DiscoverService, Depends(get_discover_service)] +DiscoverQueueManagerDep = Annotated[DiscoverQueueManager, Depends(get_discover_queue_manager)] +YouTubeRepositoryDep = Annotated[YouTubeRepository, Depends(get_youtube_repo)] +YouTubeServiceDep = Annotated[YouTubeService, Depends(get_youtube_service)] +RequestHistoryStoreDep = Annotated[RequestHistoryStore, Depends(get_request_history_store)] +RequestsPageServiceDep = Annotated[RequestsPageService, Depends(get_requests_page_service)] +JellyfinPlaybackServiceDep = Annotated[JellyfinPlaybackService, Depends(get_jellyfin_playback_service)] +LocalFilesServiceDep = Annotated[LocalFilesService, Depends(get_local_files_service)] +JellyfinLibraryServiceDep = Annotated[JellyfinLibraryService, Depends(get_jellyfin_library_service)] +LastFmRepositoryDep = Annotated[LastFmRepository, Depends(get_lastfm_repository)] +LastFmAuthServiceDep = Annotated[LastFmAuthService, Depends(get_lastfm_auth_service)] +ScrobbleServiceDep = Annotated[ScrobbleService, Depends(get_scrobble_service)] +PlaylistRepositoryDep = Annotated[PlaylistRepository, Depends(get_playlist_repository)] +PlaylistServiceDep = Annotated[PlaylistService, Depends(get_playlist_service)] +NavidromeRepositoryDep = Annotated[NavidromeRepository, Depends(get_navidrome_repository)] +NavidromeLibraryServiceDep = Annotated[NavidromeLibraryService, Depends(get_navidrome_library_service)] +NavidromePlaybackServiceDep = Annotated[NavidromePlaybackService, Depends(get_navidrome_playback_service)] +CacheStatusServiceDep = Annotated[CacheStatusService, Depends(get_cache_status_service)] diff --git a/backend/core/exception_handlers.py b/backend/core/exception_handlers.py new file mode 100644 index 0000000..663fdcf --- /dev/null +++ b/backend/core/exception_handlers.py @@ -0,0 +1,95 @@ +import logging +from fastapi import Request, HTTPException, status +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.responses import Response + +from core.exceptions import ( + ResourceNotFoundError, + ExternalServiceError, + SourceResolutionError, + ValidationError, + ConfigurationError, + ClientDisconnectedError, +) +from infrastructure.msgspec_fastapi import MsgSpecJSONResponse +from infrastructure.resilience.retry import CircuitOpenError +from models.error import ( + error_response, + VALIDATION_ERROR, + NOT_FOUND, + EXTERNAL_SERVICE_UNAVAILABLE, + SERVICE_UNAVAILABLE, + CONFIGURATION_ERROR, + SOURCE_RESOLUTION_ERROR, + INTERNAL_ERROR, + STATUS_TO_CODE, +) + +logger = logging.getLogger(__name__) + + +async def resource_not_found_handler(request: Request, exc: ResourceNotFoundError) -> MsgSpecJSONResponse: + logger.warning("Resource not found: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_404_NOT_FOUND, NOT_FOUND, str(exc)) + + +async def external_service_error_handler(request: Request, exc: ExternalServiceError) -> MsgSpecJSONResponse: + logger.error("External service error: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_503_SERVICE_UNAVAILABLE, EXTERNAL_SERVICE_UNAVAILABLE, "External service unavailable") + + +async def circuit_open_error_handler(request: Request, exc: CircuitOpenError) -> MsgSpecJSONResponse: + logger.error("Circuit breaker open: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_503_SERVICE_UNAVAILABLE, SERVICE_UNAVAILABLE, "Service temporarily unavailable") + + +async def validation_error_handler(request: Request, exc: ValidationError) -> MsgSpecJSONResponse: + logger.warning("Validation error: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_400_BAD_REQUEST, VALIDATION_ERROR, str(exc)) + + +async def configuration_error_handler(request: Request, exc: ConfigurationError) -> MsgSpecJSONResponse: + logger.warning("Configuration error: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_400_BAD_REQUEST, CONFIGURATION_ERROR, str(exc)) + + +async def source_resolution_error_handler(request: Request, exc: SourceResolutionError) -> MsgSpecJSONResponse: + logger.warning("Source resolution error: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_422_UNPROCESSABLE_ENTITY, SOURCE_RESOLUTION_ERROR, str(exc)) + + +async def general_exception_handler(request: Request, exc: Exception) -> MsgSpecJSONResponse: + logger.exception("Unexpected error: %s - %s %s", exc, request.method, request.url.path) + return error_response(status.HTTP_500_INTERNAL_SERVER_ERROR, INTERNAL_ERROR, "Internal server error") + + +async def http_exception_handler(request: Request, exc: HTTPException) -> MsgSpecJSONResponse: + code = STATUS_TO_CODE.get(exc.status_code, INTERNAL_ERROR) + message = exc.detail if isinstance(exc.detail, str) else "Request failed" + return error_response(exc.status_code, code, message) + + +async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException) -> MsgSpecJSONResponse: + code = STATUS_TO_CODE.get(exc.status_code, INTERNAL_ERROR) + message = exc.detail if isinstance(exc.detail, str) else "Request failed" + return error_response(exc.status_code, code, message) + + +async def request_validation_error_handler(request: Request, exc: RequestValidationError) -> MsgSpecJSONResponse: + logger.warning("Request validation error: %s %s", request.method, request.url.path) + clean_errors = [ + {k: v for k, v in err.items() if k != "ctx"} + for err in exc.errors() + ] + return error_response( + status.HTTP_422_UNPROCESSABLE_ENTITY, + VALIDATION_ERROR, + "Validation failed", + details=clean_errors, + ) + + +async def client_disconnected_handler(request: Request, exc: ClientDisconnectedError) -> Response: + logger.debug("Client disconnected: %s %s", request.method, request.url.path) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/core/exceptions.py b/backend/core/exceptions.py new file mode 100644 index 0000000..c29ccd4 --- /dev/null +++ b/backend/core/exceptions.py @@ -0,0 +1,83 @@ +from typing import Any + + +class MusicseerrException(Exception): + def __init__(self, message: str, details: Any = None): + self.message = message + self.details = details + super().__init__(message) + + def __str__(self) -> str: + if self.details: + return f"{self.message}: {self.details}" + return self.message + + +class ExternalServiceError(MusicseerrException): + pass + + +class RateLimitedError(ExternalServiceError): + def __init__( + self, + message: str, + details: Any = None, + retry_after_seconds: float | None = None, + ): + super().__init__(message, details) + self.retry_after_seconds = retry_after_seconds + + +class ResourceNotFoundError(MusicseerrException): + pass + + +class ValidationError(MusicseerrException): + pass + + +class PlaylistNotFoundError(ResourceNotFoundError): + pass + + +class InvalidPlaylistDataError(ValidationError): + pass + + +class SourceResolutionError(ValidationError): + pass + + +class ConfigurationError(MusicseerrException): + pass + + +class CacheError(MusicseerrException): + pass + + +class PlaybackNotAllowedError(ExternalServiceError): + pass + + +class TokenNotAuthorizedError(ExternalServiceError): + pass + + +class NavidromeApiError(ExternalServiceError): + def __init__( + self, + message: str, + details: Any = None, + code: int | None = None, + ): + super().__init__(message, details) + self.code = code + + +class NavidromeAuthError(NavidromeApiError): + pass + + +class ClientDisconnectedError(MusicseerrException): + pass diff --git a/backend/core/task_registry.py b/backend/core/task_registry.py new file mode 100644 index 0000000..53cebbd --- /dev/null +++ b/backend/core/task_registry.py @@ -0,0 +1,74 @@ +import asyncio +import logging +import threading +from typing import ClassVar + +logger = logging.getLogger(__name__) + + +class TaskRegistry: + _instance: ClassVar["TaskRegistry | None"] = None + _instance_lock: ClassVar[threading.Lock] = threading.Lock() + + def __init__(self) -> None: + self._tasks: dict[str, asyncio.Task] = {} + self._lock = threading.Lock() + + @classmethod + def get_instance(cls) -> "TaskRegistry": + if cls._instance is None: + with cls._instance_lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def register(self, name: str, task: asyncio.Task) -> asyncio.Task: + with self._lock: + existing = self._tasks.get(name) + if existing is not None and not existing.done(): + raise RuntimeError(f"Task '{name}' is already running") + self._tasks[name] = task + task.add_done_callback(lambda _t, _name=name: self._auto_unregister(_name, _t)) + return task + + def _auto_unregister(self, name: str, task: asyncio.Task) -> None: + with self._lock: + if self._tasks.get(name) is task: + del self._tasks[name] + + def unregister(self, name: str) -> None: + with self._lock: + self._tasks.pop(name, None) + + async def cancel_all(self, grace_period: float = 10.0) -> None: + with self._lock: + tasks = dict(self._tasks) + self._tasks.clear() + + if not tasks: + return + + for name, task in tasks.items(): + if not task.done(): + task.cancel() + + done, pending = await asyncio.wait( + tasks.values(), timeout=grace_period, return_when=asyncio.ALL_COMPLETED + ) + + for name, task in tasks.items(): + if task in pending: + logger.warning("Task '%s' did not finish within grace period", name) + + def get_all(self) -> dict[str, asyncio.Task]: + with self._lock: + return dict(self._tasks) + + def is_running(self, name: str) -> bool: + with self._lock: + task = self._tasks.get(name) + return task is not None and not task.done() + + def reset(self) -> None: + with self._lock: + self._tasks.clear() diff --git a/backend/core/tasks.py b/backend/core/tasks.py new file mode 100644 index 0000000..535ec44 --- /dev/null +++ b/backend/core/tasks.py @@ -0,0 +1,728 @@ +import asyncio +import logging +from time import time +from typing import TYPE_CHECKING, Optional +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.serialization import clone_with_updates +from infrastructure.validators import is_unknown_mbid +from services.library_service import LibraryService +from services.preferences_service import PreferencesService +from core.task_registry import TaskRegistry + +if TYPE_CHECKING: + from services.album_service import AlbumService + from services.audiodb_image_service import AudioDBImageService + from services.home_service import HomeService + from services.discover_service import DiscoverService + from services.discover_queue_manager import DiscoverQueueManager + from services.artist_discovery_service import ArtistDiscoveryService + from services.library_precache_service import LibraryPrecacheService + from infrastructure.persistence import LibraryDB + from infrastructure.persistence.request_history import RequestHistoryStore + from infrastructure.persistence.mbid_store import MBIDStore + from infrastructure.persistence.youtube_store import YouTubeStore + from services.requests_page_service import RequestsPageService + from repositories.coverart_disk_cache import CoverDiskCache + +logger = logging.getLogger(__name__) + + +async def cleanup_cache_periodically(cache: CacheInterface, interval: int = 300) -> None: + while True: + try: + await asyncio.sleep(interval) + await cache.cleanup_expired() + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Cache cleanup task failed: %s", e, exc_info=True) + + +def start_cache_cleanup_task(cache: CacheInterface, interval: int = 300) -> asyncio.Task: + task = asyncio.create_task(cleanup_cache_periodically(cache, interval=interval)) + TaskRegistry.get_instance().register("cache-cleanup", task) + return task + + +async def cleanup_disk_cache_periodically( + disk_cache: DiskMetadataCache, + interval: int = 600, + cover_disk_cache: Optional["CoverDiskCache"] = None, +) -> None: + while True: + try: + await asyncio.sleep(interval) + logger.debug("Running disk cache cleanup...") + await disk_cache.cleanup_expired_recent() + await disk_cache.enforce_recent_size_limits() + await disk_cache.cleanup_expired_covers() + await disk_cache.enforce_cover_size_limits() + if cover_disk_cache: + await cover_disk_cache.enforce_size_limit(force=True) + expired = await asyncio.to_thread(cover_disk_cache.cleanup_expired) + if expired: + logger.info("Cover expiry sweep removed %d expired covers", expired) + logger.debug("Disk cache cleanup complete") + except asyncio.CancelledError: + break + except Exception as e: + logger.error("Disk cache cleanup task failed: %s", e, exc_info=True) + + +def start_disk_cache_cleanup_task( + disk_cache: DiskMetadataCache, + interval: int = 600, + cover_disk_cache: Optional["CoverDiskCache"] = None, +) -> asyncio.Task: + task = asyncio.create_task( + cleanup_disk_cache_periodically(disk_cache, interval=interval, cover_disk_cache=cover_disk_cache) + ) + TaskRegistry.get_instance().register("disk-cache-cleanup", task) + return task + + +async def sync_library_periodically( + library_service: LibraryService, + preferences_service: PreferencesService +) -> None: + while True: + try: + if not library_service._lidarr_repo.is_configured(): + await asyncio.sleep(3600) + continue + + lidarr_settings = preferences_service.get_lidarr_settings() + sync_freq = lidarr_settings.sync_frequency + + if sync_freq == "manual": + await asyncio.sleep(3600) + continue + elif sync_freq == "5min": + interval = 300 + elif sync_freq == "10min": + interval = 600 + elif sync_freq == "30min": + interval = 1800 + elif sync_freq == "1hr": + interval = 3600 + else: + interval = 600 + + await asyncio.sleep(interval) + + logger.info(f"Auto-syncing library (frequency: {sync_freq})") + sync_success = False + try: + result = await library_service.sync_library() + if result.status == "skipped": + logger.info("Auto-sync skipped - sync already in progress") + continue + sync_success = True + logger.info("Auto-sync completed successfully") + + except Exception as e: + logger.error("Auto-sync library call failed: %s", e, exc_info=True) + sync_success = False + + finally: + lidarr_settings = preferences_service.get_lidarr_settings() + updated_settings = clone_with_updates(lidarr_settings, { + 'last_sync': int(time()), + 'last_sync_success': sync_success + }) + preferences_service.save_lidarr_settings(updated_settings) + + except asyncio.CancelledError: + logger.info("Library sync task cancelled") + break + except Exception as e: + logger.error("Library sync task failed: %s", e, exc_info=True) + await asyncio.sleep(60) + + +def start_library_sync_task( + library_service: LibraryService, + preferences_service: PreferencesService +) -> asyncio.Task: + task = asyncio.create_task(sync_library_periodically(library_service, preferences_service)) + TaskRegistry.get_instance().register("library-sync", task) + return task + + +async def warm_library_cache( + library_service: LibraryService, + album_service: 'AlbumService', + library_db: 'LibraryDB' +) -> None: + try: + logger.info("Warming cache with recently-added library albums...") + + await asyncio.sleep(5) + + albums_data = await library_db.get_albums() + + if not albums_data: + logger.info("No library albums to warm cache with") + return + + max_warm = 30 + albums_to_warm = albums_data[:max_warm] + + logger.info(f"Warming cache with {len(albums_to_warm)} of {len(albums_data)} library albums (first {max_warm})") + + warmed = 0 + for i, album_data in enumerate(albums_to_warm): + mbid = album_data.get('mbid') + if mbid and not is_unknown_mbid(mbid): + try: + if not await album_service.is_album_cached(mbid): + await album_service.get_album_info(mbid) + warmed += 1 + + if i % 5 == 0: + await asyncio.sleep(1) + + except Exception as e: + logger.error( + "Library cache warm item failed album=%s mbid=%s error=%s", + album_data.get('title'), + mbid, + e, + exc_info=True, + ) + continue + + logger.info(f"Cache warming complete: {warmed} albums fetched, {len(albums_to_warm) - warmed} already cached") + + except Exception as e: + logger.error("Library cache warming failed: %s", e, exc_info=True) + + +async def warm_home_cache_periodically( + home_service: 'HomeService', + interval: int = 240 +) -> None: + await asyncio.sleep(10) + + while True: + try: + for src in ("listenbrainz", "lastfm"): + try: + logger.debug("Warming home page cache (source=%s)...", src) + await home_service.get_home_data(source=src) + logger.debug("Home cache warming complete (source=%s)", src) + except Exception as e: + logger.error( + "Home cache warming failed (source=%s): %s", + src, + e, + exc_info=True, + ) + except asyncio.CancelledError: + logger.info("Home cache warming task cancelled") + break + + await asyncio.sleep(interval) + + +def start_home_cache_warming_task(home_service: 'HomeService') -> asyncio.Task: + task = asyncio.create_task(warm_home_cache_periodically(home_service)) + TaskRegistry.get_instance().register("home-cache-warming", task) + return task + + +async def warm_genre_cache_periodically( + home_service: 'HomeService', + interval: int = 21600, +) -> None: + from api.v1.schemas.home import HomeGenre + + RETRY_INTERVAL = 60 + + await asyncio.sleep(30) + + while True: + warmed = 0 + try: + for src in ("listenbrainz", "lastfm"): + try: + cached_home = await home_service.get_cached_home_data(source=src) + if not cached_home or not cached_home.genre_list or not cached_home.genre_list.items: + logger.debug("No cached home data for genre warming (source=%s), skipping", src) + continue + genre_names = [ + g.name for g in cached_home.genre_list.items[:20] + if isinstance(g, HomeGenre) + ] + if genre_names: + logger.debug("Warming genre cache (source=%s, %d genres)...", src, len(genre_names)) + await home_service._genre.build_and_cache_genre_section(src, genre_names) + logger.debug("Genre cache warming complete (source=%s)", src) + warmed += 1 + except Exception as e: + logger.error( + "Genre cache warming failed (source=%s): %s", + src, + e, + exc_info=True, + ) + except asyncio.CancelledError: + logger.info("Genre cache warming task cancelled") + break + + if warmed == 0: + await asyncio.sleep(RETRY_INTERVAL) + else: + try: + ttl = home_service._genre._get_genre_section_ttl() + except Exception: # noqa: BLE001 + ttl = interval + await asyncio.sleep(ttl) + + +def start_genre_cache_warming_task(home_service: 'HomeService') -> asyncio.Task: + task = asyncio.create_task(warm_genre_cache_periodically(home_service)) + TaskRegistry.get_instance().register("genre-cache-warming", task) + return task + + +async def warm_discover_cache_periodically( + discover_service: 'DiscoverService', + interval: int = 43200, + queue_manager: 'DiscoverQueueManager | None' = None, + preferences_service: 'PreferencesService | None' = None, +) -> None: + await asyncio.sleep(30) + + while True: + try: + for src in ("listenbrainz", "lastfm"): + try: + logger.info("Warming discover cache (source=%s)...", src) + await discover_service.warm_cache(source=src) + logger.info("Discover cache warming complete (source=%s)", src) + except Exception as e: + logger.error( + "Discover cache warming failed (source=%s): %s", + src, + e, + exc_info=True, + ) + + if queue_manager and preferences_service: + try: + adv = preferences_service.get_advanced_settings() + if adv.discover_queue_auto_generate and adv.discover_queue_warm_cycle_build: + resolved = discover_service.resolve_source(None) + logger.info("Pre-building discover queue (source=%s)...", resolved) + await queue_manager.start_build(resolved) + except Exception as e: + logger.error("Discover queue pre-build failed: %s", e, exc_info=True) + + except asyncio.CancelledError: + logger.info("Discover cache warming task cancelled") + break + + await asyncio.sleep(interval) + + +def start_discover_cache_warming_task( + discover_service: 'DiscoverService', + queue_manager: 'DiscoverQueueManager | None' = None, + preferences_service: 'PreferencesService | None' = None, +) -> asyncio.Task: + task = asyncio.create_task( + warm_discover_cache_periodically( + discover_service, + queue_manager=queue_manager, + preferences_service=preferences_service, + ) + ) + TaskRegistry.get_instance().register("discover-cache-warming", task) + return task + + +async def warm_jellyfin_mbid_index(jellyfin_repo: 'JellyfinRepository') -> None: + from repositories.jellyfin_repository import JellyfinRepository as _JR + + await asyncio.sleep(8) + try: + index = await jellyfin_repo.build_mbid_index() + logger.info("Jellyfin MBID index warmed with %d entries", len(index)) + except Exception as e: + logger.error("Jellyfin MBID index warming failed: %s", e, exc_info=True) + + +async def warm_navidrome_mbid_cache() -> None: + from core.dependencies import get_navidrome_library_service + + await asyncio.sleep(12) + while True: + try: + service = get_navidrome_library_service() + await service.warm_mbid_cache() + except Exception as e: + logger.error("Navidrome MBID cache warming failed: %s", e, exc_info=True) + await asyncio.sleep(14400) # Re-warm every 4 hours + + +async def warm_artist_discovery_cache_periodically( + artist_discovery_service: 'ArtistDiscoveryService', + library_db: 'LibraryDB', + interval: int = 14400, + delay: float = 0.5, +) -> None: + await asyncio.sleep(60) + + while True: + try: + artists = await library_db.get_artists() + if not artists: + logger.debug("No library artists for discovery cache warming") + await asyncio.sleep(interval) + continue + + mbids = [ + a['mbid'] for a in artists + if a.get('mbid') and not is_unknown_mbid(a['mbid']) + ] + if not mbids: + await asyncio.sleep(interval) + continue + + logger.info( + "Warming artist discovery cache for %d library artists...", len(mbids) + ) + cached = await artist_discovery_service.precache_artist_discovery( + mbids, delay=delay + ) + logger.info( + "Artist discovery cache warming complete: %d/%d artists refreshed", + cached, len(mbids), + ) + except asyncio.CancelledError: + logger.info("Artist discovery cache warming task cancelled") + break + except Exception as e: + logger.error("Artist discovery cache warming failed: %s", e, exc_info=True) + + await asyncio.sleep(interval) + + +def start_artist_discovery_cache_warming_task( + artist_discovery_service: 'ArtistDiscoveryService', + library_db: 'LibraryDB', + interval: int = 14400, + delay: float = 0.5, +) -> asyncio.Task: + task = asyncio.create_task( + warm_artist_discovery_cache_periodically( + artist_discovery_service, + library_db, + interval=interval, + delay=delay, + ) + ) + TaskRegistry.get_instance().register("artist-discovery-warming", task) + return task + + +_AUDIODB_SWEEP_INTERVAL = 86400 +_AUDIODB_SWEEP_INITIAL_DELAY = 120 +_AUDIODB_SWEEP_MAX_ITEMS = 5000 +_AUDIODB_SWEEP_INTER_ITEM_DELAY = 2.0 +_AUDIODB_SWEEP_CURSOR_PERSIST_INTERVAL = 50 +_AUDIODB_SWEEP_LOG_INTERVAL = 100 + + +async def warm_audiodb_cache_periodically( + audiodb_image_service: 'AudioDBImageService', + library_db: 'LibraryDB', + preferences_service: 'PreferencesService', + precache_service: 'LibraryPrecacheService | None' = None, +) -> None: + if precache_service is None: + logger.warning("AudioDB sweep: precache_service not available, byte downloads disabled") + await asyncio.sleep(_AUDIODB_SWEEP_INITIAL_DELAY) + + while True: + try: + await asyncio.sleep(_AUDIODB_SWEEP_INTERVAL) + + settings = preferences_service.get_advanced_settings() + if not settings.audiodb_enabled: + logger.debug("AudioDB sweep skipped (audiodb_enabled=false)") + continue + + artists = await library_db.get_artists() + albums = await library_db.get_albums() + if not artists and not albums: + logger.debug("AudioDB sweep: no library items") + continue + + cursor = preferences_service.get_setting('audiodb_sweep_cursor') + all_items: list[tuple[str, str, dict]] = [] + + for a in (artists or []): + mbid = a.get('mbid') + if mbid and not is_unknown_mbid(mbid): + all_items.append(("artist", mbid, a)) + for a in (albums or []): + mbid = a.get('mbid') if isinstance(a, dict) else getattr(a, 'musicbrainz_id', None) + if mbid and not is_unknown_mbid(mbid): + all_items.append(("album", mbid, a)) + + all_items.sort(key=lambda x: x[1]) + + if cursor: + start_idx = 0 + for i, (_, mbid, _) in enumerate(all_items): + if mbid > cursor: + start_idx = i + break + else: + start_idx = 0 + cursor = None + all_items = all_items[start_idx:] + + items_needing_refresh: list[tuple[str, str, dict]] = [] + for entity_type, mbid, data in all_items: + if len(items_needing_refresh) >= _AUDIODB_SWEEP_MAX_ITEMS: + break + if entity_type == "artist": + cached = await audiodb_image_service.get_cached_artist_images(mbid) + else: + cached = await audiodb_image_service.get_cached_album_images(mbid) + if cached is None: + items_needing_refresh.append((entity_type, mbid, data)) + + if not items_needing_refresh: + preferences_service.save_setting('audiodb_sweep_cursor', None) + preferences_service.save_setting('audiodb_sweep_last_completed', time()) + logger.info("AudioDB sweep complete: all items up to date") + continue + + logger.info( + "audiodb.sweep action=start items=%d cursor=%s", + len(items_needing_refresh), cursor[:8] if cursor else 'start', + ) + + processed = 0 + bytes_ok = 0 + bytes_fail = 0 + for entity_type, mbid, data in items_needing_refresh: + if not preferences_service.get_advanced_settings().audiodb_enabled: + logger.info("AudioDB disabled during sweep, stopping") + break + + try: + if entity_type == "artist": + name = data.get('name') if isinstance(data, dict) else None + result = await audiodb_image_service.fetch_and_cache_artist_images( + mbid, name, is_monitored=True, + ) + if result and not result.is_negative and result.thumb_url and precache_service: + if await precache_service._download_audiodb_bytes(result.thumb_url, "artist", mbid): + bytes_ok += 1 + else: + bytes_fail += 1 + else: + artist_name = data.get('artist_name') if isinstance(data, dict) else getattr(data, 'artist_name', None) + album_name = data.get('title') if isinstance(data, dict) else getattr(data, 'title', None) + result = await audiodb_image_service.fetch_and_cache_album_images( + mbid, artist_name=artist_name, + album_name=album_name, is_monitored=True, + ) + if result and not result.is_negative and result.album_thumb_url and precache_service: + if await precache_service._download_audiodb_bytes(result.album_thumb_url, "album", mbid): + bytes_ok += 1 + else: + bytes_fail += 1 + except Exception as e: + logger.error( + "audiodb.sweep action=item_error entity_type=%s mbid=%s error=%s", + entity_type, + mbid[:8], + e, + exc_info=True, + ) + + processed += 1 + if processed % _AUDIODB_SWEEP_CURSOR_PERSIST_INTERVAL == 0: + preferences_service.save_setting('audiodb_sweep_cursor', mbid) + + if processed % _AUDIODB_SWEEP_LOG_INTERVAL == 0: + logger.info( + "audiodb.sweep processed=%d total=%d cursor=%s bytes_ok=%d bytes_fail=%d remaining=%d", + processed, len(items_needing_refresh), mbid[:8], + bytes_ok, bytes_fail, len(items_needing_refresh) - processed, + ) + + await asyncio.sleep(_AUDIODB_SWEEP_INTER_ITEM_DELAY) + + if processed >= len(items_needing_refresh): + preferences_service.save_setting('audiodb_sweep_cursor', None) + preferences_service.save_setting('audiodb_sweep_last_completed', time()) + logger.info( + "audiodb.sweep action=complete refreshed=%d bytes_ok=%d bytes_fail=%d", + processed, bytes_ok, bytes_fail, + ) + else: + preferences_service.save_setting('audiodb_sweep_cursor', mbid) + logger.info( + "audiodb.sweep action=interrupted processed=%d total=%d bytes_ok=%d bytes_fail=%d", + processed, len(items_needing_refresh), bytes_ok, bytes_fail, + ) + + except asyncio.CancelledError: + logger.info("AudioDB sweep task cancelled") + break + except Exception as e: + logger.error("AudioDB sweep cycle failed: %s", e, exc_info=True) + + +def start_audiodb_sweep_task( + audiodb_image_service: 'AudioDBImageService', + library_db: 'LibraryDB', + preferences_service: 'PreferencesService', + precache_service: 'LibraryPrecacheService | None' = None, +) -> asyncio.Task: + task = asyncio.create_task( + warm_audiodb_cache_periodically( + audiodb_image_service, library_db, preferences_service, + precache_service=precache_service, + ) + ) + TaskRegistry.get_instance().register("audiodb-sweep", task) + return task + + +_REQUEST_SYNC_INTERVAL = 60 +_REQUEST_SYNC_INITIAL_DELAY = 15 + + +async def sync_request_statuses_periodically( + requests_page_service: 'RequestsPageService', + interval: int = _REQUEST_SYNC_INTERVAL, +) -> None: + await asyncio.sleep(_REQUEST_SYNC_INITIAL_DELAY) + + while True: + try: + await requests_page_service.sync_request_statuses() + except asyncio.CancelledError: + logger.info("Request status sync task cancelled") + break + except Exception as e: + logger.error("Periodic request status sync failed: %s", e, exc_info=True) + + await asyncio.sleep(interval) + + +def start_request_status_sync_task( + requests_page_service: 'RequestsPageService', +) -> asyncio.Task: + task = asyncio.create_task( + sync_request_statuses_periodically(requests_page_service) + ) + TaskRegistry.get_instance().register("request-status-sync", task) + return task + + +# --- Orphan cover demotion --- + +async def demote_orphaned_covers_periodically( + cover_disk_cache: 'CoverDiskCache', + library_db: 'LibraryDB', + interval: int = 86400, +) -> None: + from repositories.coverart_disk_cache import get_cache_filename + + await asyncio.sleep(300) + while True: + try: + album_mbids = await library_db.get_all_album_mbids() + artist_mbids = await library_db.get_all_artist_mbids() + + valid_hashes: set[str] = set() + for mbid in album_mbids: + for suffix in ("500", "250", "1200", "orig"): + valid_hashes.add(get_cache_filename(f"rg_{mbid}", suffix)) + for mbid in artist_mbids: + for size in ("250", "500"): + valid_hashes.add(get_cache_filename(f"artist_{mbid}_{size}", "img")) + valid_hashes.add(get_cache_filename(f"artist_{mbid}", "img")) + + demoted = await asyncio.to_thread(cover_disk_cache.demote_orphaned, valid_hashes) + if demoted: + logger.info("Orphan cover demotion: %d covers demoted to expiring", demoted) + except asyncio.CancelledError: + logger.info("Orphan cover demotion task cancelled") + break + except Exception as e: + logger.error("Orphan cover demotion failed: %s", e, exc_info=True) + + await asyncio.sleep(interval) + + +def start_orphan_cover_demotion_task( + cover_disk_cache: 'CoverDiskCache', + library_db: 'LibraryDB', + interval: int = 86400, +) -> asyncio.Task: + task = asyncio.create_task( + demote_orphaned_covers_periodically(cover_disk_cache, library_db, interval=interval) + ) + TaskRegistry.get_instance().register("orphan-cover-demotion", task) + return task + + +# --- Store pruning (request history + ignored releases + youtube orphans) --- + +async def prune_stores_periodically( + request_history: 'RequestHistoryStore', + mbid_store: 'MBIDStore', + youtube_store: 'YouTubeStore', + request_retention_days: int = 180, + ignored_retention_days: int = 365, + interval: int = 21600, +) -> None: + await asyncio.sleep(600) + while True: + try: + pruned_requests = await request_history.prune_old_terminal_requests(request_retention_days) + pruned_ignored = await mbid_store.prune_old_ignored_releases(ignored_retention_days) + orphaned_yt = await youtube_store.delete_orphaned_track_links() + if pruned_requests or pruned_ignored or orphaned_yt: + logger.info( + "Store prune: requests=%d ignored_releases=%d youtube_orphans=%d", + pruned_requests, pruned_ignored, orphaned_yt, + ) + except asyncio.CancelledError: + logger.info("Store prune task cancelled") + break + except Exception as e: + logger.error("Store prune task failed: %s", e, exc_info=True) + + await asyncio.sleep(interval) + + +def start_store_prune_task( + request_history: 'RequestHistoryStore', + mbid_store: 'MBIDStore', + youtube_store: 'YouTubeStore', + request_retention_days: int = 180, + ignored_retention_days: int = 365, + interval: int = 21600, +) -> asyncio.Task: + task = asyncio.create_task( + prune_stores_periodically( + request_history, mbid_store, youtube_store, + request_retention_days=request_retention_days, + ignored_retention_days=ignored_retention_days, + interval=interval, + ) + ) + TaskRegistry.get_instance().register("store-prune", task) + return task diff --git a/backend/infrastructure/__init__.py b/backend/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infrastructure/cache/__init__.py b/backend/infrastructure/cache/__init__.py new file mode 100644 index 0000000..0102417 --- /dev/null +++ b/backend/infrastructure/cache/__init__.py @@ -0,0 +1,15 @@ +"""Ephemeral caching infrastructure — all data here can be cleared without data loss. + +For durable storage, see ``infrastructure.persistence``. +""" + +from infrastructure.cache.memory_cache import CacheInterface, InMemoryCache +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.cache.protocol import CacheProtocol + +__all__ = [ + "CacheInterface", + "CacheProtocol", + "InMemoryCache", + "DiskMetadataCache", +] \ No newline at end of file diff --git a/backend/infrastructure/cache/cache_keys.py b/backend/infrastructure/cache/cache_keys.py new file mode 100644 index 0000000..385c74a --- /dev/null +++ b/backend/infrastructure/cache/cache_keys.py @@ -0,0 +1,194 @@ +"""Centralized cache key generation for consistent, sorted, testable cache keys.""" +from typing import Optional + + + +MB_ARTIST_SEARCH_PREFIX = "mb:artist:search:" +MB_ARTIST_DETAIL_PREFIX = "mb:artist:detail:" +MB_ALBUM_SEARCH_PREFIX = "mb:album:search:" +MB_RG_DETAIL_PREFIX = "mb:rg:detail:" +MB_RELEASE_DETAIL_PREFIX = "mb:release:detail:" +MB_RELEASE_TO_RG_PREFIX = "mb:release_to_rg:" +MB_RELEASE_REC_PREFIX = "mb:release_rec_positions:" +MB_RECORDING_PREFIX = "mb:recording:" +MB_ARTIST_RELS_PREFIX = "mb:artist_rels:" +MB_ARTISTS_BY_TAG_PREFIX = "mb_artists_by_tag:" +MB_RG_BY_TAG_PREFIX = "mb_rg_by_tag:" + +LB_PREFIX = "lb_" + +LFM_PREFIX = "lfm_" + +JELLYFIN_PREFIX = "jellyfin_" + +NAVIDROME_PREFIX = "navidrome:" + +LIDARR_PREFIX = "lidarr:" +LIDARR_REQUESTED_PREFIX = "lidarr_requested" +LIDARR_ARTIST_IMAGE_PREFIX = "lidarr_artist_image:" +LIDARR_ARTIST_DETAILS_PREFIX = "lidarr_artist_details:" +LIDARR_ARTIST_ALBUMS_PREFIX = "lidarr_artist_albums:" +LIDARR_ALBUM_IMAGE_PREFIX = "lidarr_album_image:" +LIDARR_ALBUM_DETAILS_PREFIX = "lidarr_album_details:" +LIDARR_ALBUM_TRACKS_PREFIX = "lidarr_album_tracks:" +LIDARR_TRACKFILE_PREFIX = "lidarr_trackfile:" +LIDARR_ALBUM_TRACKFILES_PREFIX = "lidarr_album_trackfiles_raw:" + +LOCAL_FILES_PREFIX = "local_files_" + +HOME_RESPONSE_PREFIX = "home_response:" +DISCOVER_RESPONSE_PREFIX = "discover_response:" +GENRE_ARTIST_PREFIX = "genre_artist:" +GENRE_SECTION_PREFIX = "genre_section:" + +SOURCE_RESOLUTION_PREFIX = "source_resolution" + +ARTIST_INFO_PREFIX = "artist_info:" +ALBUM_INFO_PREFIX = "album_info:" + +ARTIST_DISCOVERY_PREFIX = "artist_discovery:" +DISCOVER_QUEUE_ENRICH_PREFIX = "discover_queue_enrich:" + +ARTIST_WIKIDATA_PREFIX = "artist_wikidata:" +WIKIDATA_IMAGE_PREFIX = "wikidata:image:" +WIKIDATA_URL_PREFIX = "wikidata:url:" +WIKIPEDIA_PREFIX = "wikipedia:extract:" + +PREFERENCES_PREFIX = "preferences:" + +AUDIODB_PREFIX = "audiodb_" + + + +def musicbrainz_prefixes() -> list[str]: + """All MusicBrainz cache key prefixes — for bulk invalidation.""" + return [ + MB_ARTIST_SEARCH_PREFIX, + MB_ARTIST_DETAIL_PREFIX, + MB_ALBUM_SEARCH_PREFIX, + MB_RG_DETAIL_PREFIX, + MB_RELEASE_DETAIL_PREFIX, + MB_RELEASE_TO_RG_PREFIX, + MB_RELEASE_REC_PREFIX, + MB_RECORDING_PREFIX, + MB_ARTIST_RELS_PREFIX, + MB_ARTISTS_BY_TAG_PREFIX, + MB_RG_BY_TAG_PREFIX, + ] + + +def listenbrainz_prefixes() -> list[str]: + """All ListenBrainz cache key prefixes.""" + return [LB_PREFIX] + + +def lastfm_prefixes() -> list[str]: + """All Last.fm cache key prefixes.""" + return [LFM_PREFIX] + + +def home_prefixes() -> list[str]: + """Cache prefixes cleared on home/discover invalidation.""" + return [HOME_RESPONSE_PREFIX, DISCOVER_RESPONSE_PREFIX, GENRE_ARTIST_PREFIX, GENRE_SECTION_PREFIX] + + + +def _sort_params(**kwargs) -> str: + """Sort parameters for consistent key generation.""" + return ":".join(f"{k}={v}" for k, v in sorted(kwargs.items()) if v is not None) + + +def mb_artist_search_key(query: str, limit: int, offset: int) -> str: + """Generate cache key for MusicBrainz artist search.""" + return f"{MB_ARTIST_SEARCH_PREFIX}{query}:{limit}:{offset}" + + +def mb_album_search_key( + query: str, + limit: int, + offset: int, + included_secondary_types: Optional[set[str]] = None +) -> str: + """Generate cache key for MusicBrainz album search.""" + types_str = ",".join(sorted(included_secondary_types)) if included_secondary_types else "none" + return f"{MB_ALBUM_SEARCH_PREFIX}{query}:{limit}:{offset}:{types_str}" + + +def mb_artist_detail_key(mbid: str) -> str: + """Generate cache key for MusicBrainz artist details.""" + return f"{MB_ARTIST_DETAIL_PREFIX}{mbid}" + + +def mb_release_group_key(mbid: str, includes: Optional[list[str]] = None) -> str: + """Generate cache key for MusicBrainz release group.""" + includes_str = ",".join(sorted(includes)) if includes else "default" + return f"{MB_RG_DETAIL_PREFIX}{mbid}:{includes_str}" + + +def mb_release_key(release_id: str, includes: Optional[list[str]] = None) -> str: + """Generate cache key for MusicBrainz release.""" + includes_str = ",".join(sorted(includes)) if includes else "default" + return f"{MB_RELEASE_DETAIL_PREFIX}{release_id}:{includes_str}" + + +def lidarr_library_albums_key(include_unmonitored: bool = False) -> str: + """Generate cache key for full Lidarr library album list.""" + suffix = "all" if include_unmonitored else "monitored" + return f"{LIDARR_PREFIX}library:albums:{suffix}" + + +def lidarr_library_artists_key(include_unmonitored: bool = False) -> str: + """Generate cache key for Lidarr library artist list.""" + suffix = "all" if include_unmonitored else "monitored" + return f"{LIDARR_PREFIX}library:artists:{suffix}" + + +def lidarr_library_mbids_key(include_release_ids: bool = False) -> str: + """Generate cache key for Lidarr library MBIDs.""" + suffix = "with_releases" if include_release_ids else "albums_only" + return f"{LIDARR_PREFIX}library:mbids:{suffix}" + + +def lidarr_artist_mbids_key() -> str: + """Generate cache key for Lidarr artist MBIDs.""" + return f"{LIDARR_PREFIX}artists:mbids" + + +def lidarr_raw_albums_key() -> str: + """Generate cache key for raw Lidarr album payload.""" + return f"{LIDARR_PREFIX}raw:albums" + + +def lidarr_library_grouped_key() -> str: + """Generate cache key for grouped Lidarr library albums.""" + return f"{LIDARR_PREFIX}library:grouped" + + +def lidarr_requested_mbids_key() -> str: + """Generate cache key for Lidarr requested (pending download) MBIDs.""" + return f"{LIDARR_REQUESTED_PREFIX}_mbids" + + +def lidarr_status_key() -> str: + """Generate cache key for Lidarr status.""" + return f"{LIDARR_PREFIX}status" + + +def wikidata_artist_image_key(wikidata_id: str) -> str: + """Generate cache key for Wikidata artist image.""" + return f"{WIKIDATA_IMAGE_PREFIX}{wikidata_id}" + + +def wikidata_url_key(artist_id: str) -> str: + """Generate cache key for artist Wikidata URL.""" + return f"{WIKIDATA_URL_PREFIX}{artist_id}" + + +def wikipedia_extract_key(url: str) -> str: + """Generate cache key for Wikipedia extract.""" + return f"{WIKIPEDIA_PREFIX}{url}" + + +def preferences_key() -> str: + """Generate cache key for preferences.""" + return f"{PREFERENCES_PREFIX}current" diff --git a/backend/infrastructure/cache/disk_cache.py b/backend/infrastructure/cache/disk_cache.py new file mode 100644 index 0000000..6d5224d --- /dev/null +++ b/backend/infrastructure/cache/disk_cache.py @@ -0,0 +1,475 @@ +import asyncio +import hashlib +import json +import logging +import shutil +import time +from pathlib import Path +from typing import Any + +from infrastructure.serialization import to_jsonable + +logger = logging.getLogger(__name__) + + +def _encode_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + + +def _decode_json(text: str) -> Any: + return json.loads(text) + + +class DiskMetadataCache: + def __init__( + self, + base_path: Path, + recent_metadata_max_size_mb: int = 128, + recent_covers_max_size_mb: int = 0, + persistent_metadata_ttl_hours: int = 24, + ): + self.base_path = Path(base_path) + self.recent_metadata_max_size_bytes = max(recent_metadata_max_size_mb, 0) * 1024 * 1024 + self.recent_covers_max_size_bytes = max(recent_covers_max_size_mb, 0) * 1024 * 1024 + self.default_ttl_seconds = max(persistent_metadata_ttl_hours, 1) * 3600 + + self._recent_albums_dir = self.base_path / "recent" / "albums" + self._recent_artists_dir = self.base_path / "recent" / "artists" + self._recent_covers_dir = self.base_path / "recent" / "covers" + self._persistent_albums_dir = self.base_path / "persistent" / "albums" + self._persistent_artists_dir = self.base_path / "persistent" / "artists" + self._recent_audiodb_artists_dir = self.base_path / "recent" / "audiodb_artists" + self._recent_audiodb_albums_dir = self.base_path / "recent" / "audiodb_albums" + self._persistent_audiodb_artists_dir = self.base_path / "persistent" / "audiodb_artists" + self._persistent_audiodb_albums_dir = self.base_path / "persistent" / "audiodb_albums" + self._ensure_dirs() + + def _ensure_dirs(self) -> None: + for path in ( + self._recent_albums_dir, + self._recent_artists_dir, + self._recent_covers_dir, + self._persistent_albums_dir, + self._persistent_artists_dir, + self._recent_audiodb_artists_dir, + self._recent_audiodb_albums_dir, + self._persistent_audiodb_artists_dir, + self._persistent_audiodb_albums_dir, + ): + path.mkdir(parents=True, exist_ok=True) + + @staticmethod + def _cache_hash(identifier: str) -> str: + return hashlib.sha1(identifier.encode()).hexdigest() + + @staticmethod + def _meta_path(file_path: Path) -> Path: + return file_path.with_suffix(".meta.json") + + def _entity_paths(self, entity_type: str, identifier: str) -> tuple[Path, Path]: + cache_hash = self._cache_hash(identifier) + if entity_type == "album": + return ( + self._recent_albums_dir / f"{cache_hash}.json", + self._persistent_albums_dir / f"{cache_hash}.json", + ) + if entity_type == "artist": + return ( + self._recent_artists_dir / f"{cache_hash}.json", + self._persistent_artists_dir / f"{cache_hash}.json", + ) + if entity_type == "audiodb_artist": + return ( + self._recent_audiodb_artists_dir / f"{cache_hash}.json", + self._persistent_audiodb_artists_dir / f"{cache_hash}.json", + ) + if entity_type == "audiodb_album": + return ( + self._recent_audiodb_albums_dir / f"{cache_hash}.json", + self._persistent_audiodb_albums_dir / f"{cache_hash}.json", + ) + raise ValueError(f"Unsupported entity type: {entity_type}") + + def _delete_file_pair(self, file_path: Path) -> None: + file_path.unlink(missing_ok=True) + self._meta_path(file_path).unlink(missing_ok=True) + + def _load_meta(self, meta_path: Path) -> dict[str, Any]: + if not meta_path.exists(): + return {} + try: + payload = _decode_json(meta_path.read_text()) + except (json.JSONDecodeError, OSError, TypeError): + return {} + return payload if isinstance(payload, dict) else {} + + @staticmethod + def _is_expired(meta: dict[str, Any]) -> bool: + expires_at = meta.get("expires_at") + return isinstance(expires_at, (int, float)) and time.time() > float(expires_at) + + def _cleanup_expired_directory(self, directory: Path) -> int: + removed = 0 + handled_meta_paths: set[Path] = set() + + for data_path in directory.iterdir(): + if not data_path.is_file() or data_path.name.endswith(".meta.json"): + continue + meta_path = self._meta_path(data_path) + handled_meta_paths.add(meta_path) + if self._is_expired(self._load_meta(meta_path)): + self._delete_file_pair(data_path) + removed += 1 + + for meta_path in directory.glob("*.meta.json"): + if meta_path in handled_meta_paths: + continue + if self._is_expired(self._load_meta(meta_path)): + meta_path.unlink(missing_ok=True) + removed += 1 + + return removed + + def _enforce_size_limit_for_directory(self, directory: Path, max_size_bytes: int) -> int: + if max_size_bytes <= 0: + return 0 + + candidates: list[tuple[float, Path, int]] = [] + total_size = 0 + for data_path in directory.iterdir(): + if not data_path.is_file() or data_path.name.endswith(".meta.json"): + continue + try: + size_bytes = data_path.stat().st_size + except FileNotFoundError: + continue + meta = self._load_meta(self._meta_path(data_path)) + last_accessed = float(meta.get("last_accessed", meta.get("created_at", 0.0)) or 0.0) + total_size += size_bytes + candidates.append((last_accessed, data_path, size_bytes)) + + if total_size <= max_size_bytes: + return 0 + + bytes_to_free = total_size - max_size_bytes + freed = 0 + for _, data_path, size_bytes in sorted(candidates, key=lambda item: item[0]): + self._delete_file_pair(data_path) + freed += size_bytes + if freed >= bytes_to_free: + break + return freed + + def _write_json_entry(self, file_path: Path, payload: dict[str, Any], expires_at: float | None) -> None: + file_path.parent.mkdir(parents=True, exist_ok=True) + now = time.time() + file_path.write_text(_encode_json(payload)) + meta = { + "created_at": now, + "last_accessed": now, + } + if expires_at is not None: + meta["expires_at"] = expires_at + self._meta_path(file_path).write_text(_encode_json(meta)) + + def _read_json_entry(self, file_path: Path, honor_expiry: bool) -> dict[str, Any] | None: + if not file_path.exists(): + return None + + meta_path = self._meta_path(file_path) + meta: dict[str, Any] = {} + if meta_path.exists(): + try: + meta = _decode_json(meta_path.read_text()) + except (json.JSONDecodeError, OSError, TypeError): + meta = {} + + if honor_expiry: + expires_at = meta.get("expires_at") + if isinstance(expires_at, (int, float)) and time.time() > float(expires_at): + self._delete_file_pair(file_path) + return None + + try: + payload = _decode_json(file_path.read_text()) + except (json.JSONDecodeError, OSError, TypeError): + self._delete_file_pair(file_path) + return None + + if not isinstance(payload, dict): + self._delete_file_pair(file_path) + return None + + if meta_path.exists(): + meta["last_accessed"] = time.time() + try: + meta_path.write_text(_encode_json(meta)) + except OSError as exc: + logger.debug("Failed to update disk cache access time for %s: %s", meta_path, exc) + + return payload + + async def _set_entity( + self, + entity_type: str, + identifier: str, + payload: Any, + is_monitored: bool, + ttl_seconds: int | None, + ) -> None: + builtins = to_jsonable(payload) + if not isinstance(builtins, dict): + raise TypeError(f"Expected mapping payload for {entity_type} cache, got {type(builtins)!r}") + + recent_path, persistent_path = self._entity_paths(entity_type, identifier) + + def operation() -> None: + target_path = persistent_path if is_monitored else recent_path + other_path = recent_path if is_monitored else persistent_path + self._delete_file_pair(other_path) + expires_at = None + if ttl_seconds is not None: + expires_at = time.time() + max(ttl_seconds, 1) + elif not is_monitored: + expires_at = time.time() + max(self.default_ttl_seconds, 1) + self._write_json_entry(target_path, builtins, expires_at) + + await asyncio.to_thread(operation) + + async def _get_entity(self, entity_type: str, identifier: str) -> dict[str, Any] | None: + recent_path, persistent_path = self._entity_paths(entity_type, identifier) + + def operation() -> dict[str, Any] | None: + persistent_payload = self._read_json_entry(persistent_path, honor_expiry=True) + if persistent_payload is not None: + return persistent_payload + return self._read_json_entry(recent_path, honor_expiry=True) + + return await asyncio.to_thread(operation) + + async def set_album( + self, + musicbrainz_id: str, + album_info: Any, + is_monitored: bool = False, + ttl_seconds: int | None = None, + ) -> None: + await self._set_entity("album", musicbrainz_id, album_info, is_monitored, ttl_seconds) + + async def get_album(self, musicbrainz_id: str) -> dict[str, Any] | None: + return await self._get_entity("album", musicbrainz_id) + + async def set_artist( + self, + musicbrainz_id: str, + artist_info: Any, + is_monitored: bool = False, + ttl_seconds: int | None = None, + ) -> None: + await self._set_entity("artist", musicbrainz_id, artist_info, is_monitored, ttl_seconds) + + async def get_artist(self, musicbrainz_id: str) -> dict[str, Any] | None: + return await self._get_entity("artist", musicbrainz_id) + + async def set_audiodb_artist( + self, + identifier: str, + payload: Any, + is_monitored: bool = False, + ttl_seconds: int | None = None, + ) -> None: + await self._set_entity("audiodb_artist", identifier, payload, is_monitored, ttl_seconds) + + async def get_audiodb_artist(self, identifier: str) -> dict[str, Any] | None: + return await self._get_entity("audiodb_artist", identifier) + + async def set_audiodb_album( + self, + identifier: str, + payload: Any, + is_monitored: bool = False, + ttl_seconds: int | None = None, + ) -> None: + await self._set_entity("audiodb_album", identifier, payload, is_monitored, ttl_seconds) + + async def get_audiodb_album(self, identifier: str) -> dict[str, Any] | None: + return await self._get_entity("audiodb_album", identifier) + + async def delete_album(self, musicbrainz_id: str) -> None: + recent_path, persistent_path = self._entity_paths("album", musicbrainz_id) + await asyncio.to_thread(self._delete_file_pair, recent_path) + await asyncio.to_thread(self._delete_file_pair, persistent_path) + + async def delete_artist(self, musicbrainz_id: str) -> None: + recent_path, persistent_path = self._entity_paths("artist", musicbrainz_id) + await asyncio.to_thread(self._delete_file_pair, recent_path) + await asyncio.to_thread(self._delete_file_pair, persistent_path) + + async def delete_entity(self, entity_type: str, identifier: str) -> None: + recent_path, persistent_path = self._entity_paths(entity_type, identifier) + await asyncio.to_thread(self._delete_file_pair, recent_path) + await asyncio.to_thread(self._delete_file_pair, persistent_path) + + async def promote_to_persistent(self, identifier: str, identifier_type: str) -> bool: + entity_type = "artist" if identifier_type == "artist" else "album" + recent_path, persistent_path = self._entity_paths(entity_type, identifier) + + def operation() -> bool: + if persistent_path.exists(): + return True + if not recent_path.exists(): + return False + persistent_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(recent_path), str(persistent_path)) + recent_meta = self._meta_path(recent_path) + persistent_meta = self._meta_path(persistent_path) + if recent_meta.exists(): + meta = _decode_json(recent_meta.read_text()) + meta.pop("expires_at", None) + meta["last_accessed"] = time.time() + persistent_meta.write_text(_encode_json(meta)) + recent_meta.unlink(missing_ok=True) + return True + + return await asyncio.to_thread(operation) + + async def promote_album_to_persistent(self, musicbrainz_id: str) -> bool: + return await self.promote_to_persistent(musicbrainz_id, "album") + + async def promote_artist_to_persistent(self, musicbrainz_id: str) -> bool: + return await self.promote_to_persistent(musicbrainz_id, "artist") + + async def cleanup_expired_recent(self) -> int: + def operation() -> int: + removed = 0 + for base_dir in ( + self._recent_albums_dir, + self._recent_artists_dir, + self._recent_audiodb_artists_dir, + self._recent_audiodb_albums_dir, + ): + removed += self._cleanup_expired_directory(base_dir) + return removed + + return await asyncio.to_thread(operation) + + async def enforce_recent_size_limits(self) -> int: + if self.recent_metadata_max_size_bytes <= 0: + return 0 + + def operation() -> int: + candidates: list[tuple[float, Path, int]] = [] + total_size = 0 + for base_dir in ( + self._recent_albums_dir, + self._recent_artists_dir, + self._recent_audiodb_artists_dir, + self._recent_audiodb_albums_dir, + ): + for data_path in base_dir.glob("*.json"): + if data_path.name.endswith(".meta.json"): + continue + try: + size_bytes = data_path.stat().st_size + except FileNotFoundError: + continue + meta_path = self._meta_path(data_path) + meta: dict[str, Any] = {} + if meta_path.exists(): + try: + meta = _decode_json(meta_path.read_text()) + except Exception: # noqa: BLE001 + meta = {} + last_accessed = float(meta.get("last_accessed", meta.get("created_at", 0.0)) or 0.0) + total_size += size_bytes + candidates.append((last_accessed, data_path, size_bytes)) + + if total_size <= self.recent_metadata_max_size_bytes: + return 0 + + bytes_to_free = total_size - self.recent_metadata_max_size_bytes + freed = 0 + for _, data_path, size_bytes in sorted(candidates, key=lambda item: item[0]): + self._delete_file_pair(data_path) + freed += size_bytes + if freed >= bytes_to_free: + break + return freed + + return await asyncio.to_thread(operation) + + async def cleanup_expired_covers(self) -> int: + return await asyncio.to_thread(self._cleanup_expired_directory, self._recent_covers_dir) + + async def enforce_cover_size_limits(self) -> int: + return await asyncio.to_thread( + self._enforce_size_limit_for_directory, + self._recent_covers_dir, + self.recent_covers_max_size_bytes, + ) + + def get_stats(self) -> dict[str, Any]: + total_count = 0 + album_count = 0 + artist_count = 0 + audiodb_artist_count = 0 + audiodb_album_count = 0 + total_size_bytes = 0 + + for base_dir, counter_name in ( + (self._recent_albums_dir, "album"), + (self._persistent_albums_dir, "album"), + (self._recent_artists_dir, "artist"), + (self._persistent_artists_dir, "artist"), + (self._recent_audiodb_artists_dir, "audiodb_artist"), + (self._persistent_audiodb_artists_dir, "audiodb_artist"), + (self._recent_audiodb_albums_dir, "audiodb_album"), + (self._persistent_audiodb_albums_dir, "audiodb_album"), + ): + for data_path in base_dir.glob("*.json"): + if data_path.name.endswith(".meta.json"): + continue + total_count += 1 + if counter_name == "album": + album_count += 1 + elif counter_name == "artist": + artist_count += 1 + elif counter_name == "audiodb_artist": + audiodb_artist_count += 1 + elif counter_name == "audiodb_album": + audiodb_album_count += 1 + try: + total_size_bytes += data_path.stat().st_size + except FileNotFoundError: + pass + + return { + "total_count": total_count, + "album_count": album_count, + "artist_count": artist_count, + "audiodb_artist_count": audiodb_artist_count, + "audiodb_album_count": audiodb_album_count, + "total_size_bytes": total_size_bytes, + } + + async def clear_all(self) -> None: + def operation() -> None: + if self.base_path.exists(): + shutil.rmtree(self.base_path) + self._ensure_dirs() + + await asyncio.to_thread(operation) + + async def clear_audiodb(self) -> None: + def operation() -> None: + for d in ( + self._recent_audiodb_artists_dir, + self._recent_audiodb_albums_dir, + self._persistent_audiodb_artists_dir, + self._persistent_audiodb_albums_dir, + ): + if d.exists(): + shutil.rmtree(d) + self._ensure_dirs() + + await asyncio.to_thread(operation) diff --git a/backend/infrastructure/cache/memory_cache.py b/backend/infrastructure/cache/memory_cache.py new file mode 100644 index 0000000..513499b --- /dev/null +++ b/backend/infrastructure/cache/memory_cache.py @@ -0,0 +1,154 @@ +import asyncio +import logging +import sys +import time +from typing import Any, Optional +from abc import ABC, abstractmethod +from collections import OrderedDict + +logger = logging.getLogger(__name__) + + +class CacheInterface(ABC): + @abstractmethod + async def get(self, key: str) -> Optional[Any]: + pass + + @abstractmethod + async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: + pass + + @abstractmethod + async def delete(self, key: str) -> None: + pass + + @abstractmethod + async def clear(self) -> None: + pass + + @abstractmethod + async def clear_prefix(self, prefix: str) -> int: + pass + + @abstractmethod + async def cleanup_expired(self) -> int: + pass + + @abstractmethod + def size(self) -> int: + pass + + @abstractmethod + def estimate_memory_bytes(self) -> int: + pass + + +class CacheEntry: + __slots__ = ('value', 'expires_at') + + def __init__(self, value: Any, ttl_seconds: int): + self.value = value + self.expires_at = time.time() + ttl_seconds + + def is_expired(self) -> bool: + return time.time() > self.expires_at + + +class InMemoryCache(CacheInterface): + def __init__(self, max_entries: int = 10000): + self._cache: OrderedDict[str, CacheEntry] = OrderedDict() + self._lock = asyncio.Lock() + self._max_entries = max_entries + self._evictions = 0 + self._hits = 0 + self._misses = 0 + + async def get(self, key: str) -> Optional[Any]: + async with self._lock: + entry = self._cache.get(key) + if entry is None: + self._misses += 1 + return None + + if entry.is_expired(): + self._cache.pop(key, None) + self._misses += 1 + return None + + self._cache.move_to_end(key) + self._hits += 1 + return entry.value + + async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: + async with self._lock: + if key not in self._cache and len(self._cache) >= self._max_entries: + oldest_key, _ = self._cache.popitem(last=False) + self._evictions += 1 + if self._evictions % 100 == 0: + logger.info(f"Cache LRU evictions: {self._evictions}, current size: {len(self._cache)}") + + self._cache[key] = CacheEntry(value, ttl_seconds) + self._cache.move_to_end(key) + + async def delete(self, key: str) -> None: + async with self._lock: + self._cache.pop(key, None) + + async def clear(self) -> None: + async with self._lock: + self._cache.clear() + + async def clear_prefix(self, prefix: str) -> int: + async with self._lock: + keys_to_remove = [k for k in self._cache.keys() if k.startswith(prefix)] + for key in keys_to_remove: + self._cache.pop(key, None) + + if keys_to_remove: + logger.info(f"Cleared {len(keys_to_remove)} cache entries with prefix '{prefix}'") + + return len(keys_to_remove) + + async def cleanup_expired(self) -> int: + now = time.time() + + async with self._lock: + expired_keys = [ + key for key, entry in self._cache.items() + if now > entry.expires_at + ] + for key in expired_keys: + self._cache.pop(key, None) + + if expired_keys: + logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries") + + return len(expired_keys) + + def size(self) -> int: + return len(self._cache) + + def estimate_memory_bytes(self) -> int: + total_size = 0 + + total_size += sys.getsizeof(self._cache) + + for key, entry in self._cache.items(): + total_size += sys.getsizeof(key) + total_size += sys.getsizeof(entry) + total_size += sys.getsizeof(entry.value) + + return total_size + + def get_stats(self) -> dict[str, Any]: + total = self._hits + self._misses + hit_rate = (self._hits / total * 100) if total > 0 else 0.0 + return { + "size": len(self._cache), + "max_entries": self._max_entries, + "hits": self._hits, + "misses": self._misses, + "hit_rate_percent": round(hit_rate, 2), + "evictions": self._evictions, + "memory_bytes": self.estimate_memory_bytes() + } diff --git a/backend/infrastructure/cache/protocol.py b/backend/infrastructure/cache/protocol.py new file mode 100644 index 0000000..c0ffd90 --- /dev/null +++ b/backend/infrastructure/cache/protocol.py @@ -0,0 +1,15 @@ +"""Cache protocol — structural subtyping for ephemeral caches.""" + +from typing import Any, Optional, Protocol, runtime_checkable + + +@runtime_checkable +class CacheProtocol(Protocol): + async def get(self, key: str) -> Optional[Any]: ... + async def set(self, key: str, value: Any, ttl_seconds: int = 60) -> None: ... + async def delete(self, key: str) -> None: ... + async def clear(self) -> None: ... + async def clear_prefix(self, prefix: str) -> int: ... + async def cleanup_expired(self) -> int: ... + def size(self) -> int: ... + def estimate_memory_bytes(self) -> int: ... diff --git a/backend/infrastructure/cache/request_history.py b/backend/infrastructure/cache/request_history.py new file mode 100644 index 0000000..024eae2 --- /dev/null +++ b/backend/infrastructure/cache/request_history.py @@ -0,0 +1,4 @@ +"""Backward-compat shim — re-exports from infrastructure.persistence.""" +from infrastructure.persistence.request_history import RequestHistoryRecord, RequestHistoryStore + +__all__ = ["RequestHistoryRecord", "RequestHistoryStore"] diff --git a/backend/infrastructure/constants.py b/backend/infrastructure/constants.py new file mode 100644 index 0000000..e161054 --- /dev/null +++ b/backend/infrastructure/constants.py @@ -0,0 +1,31 @@ +STREAM_CHUNK_SIZE = 64 * 1024 + +JELLYFIN_TICKS_PER_SECOND = 10_000_000 + +BROWSER_AUDIO_DEVICE_PROFILE: dict[str, object] = { + "MaxStreamingBitrate": 8000000, + "MaxStaticBitrate": 8000000, + "MusicStreamingTranscodingBitrate": 128000, + "MaxStaticMusicBitrate": 8000000, + "DirectPlayProfiles": [ + {"Container": "opus", "Type": "Audio"}, + {"Container": "webm", "AudioCodec": "opus", "Type": "Audio"}, + {"Container": "mp3", "Type": "Audio"}, + {"Container": "aac", "Type": "Audio"}, + {"Container": "m4a", "AudioCodec": "aac", "Type": "Audio"}, + {"Container": "m4b", "AudioCodec": "aac", "Type": "Audio"}, + {"Container": "flac", "Type": "Audio"}, + {"Container": "wav", "Type": "Audio"}, + {"Container": "ts", "AudioCodec": "mp3", "Type": "Audio"}, + ], + "TranscodingProfiles": [ + { + "Container": "opus", + "Type": "Audio", + "AudioCodec": "opus", + "Context": "Streaming", + "Protocol": "http", + "MaxAudioChannels": "2", + } + ], +} diff --git a/backend/infrastructure/cover_urls.py b/backend/infrastructure/cover_urls.py new file mode 100644 index 0000000..5655a31 --- /dev/null +++ b/backend/infrastructure/cover_urls.py @@ -0,0 +1,37 @@ +from typing import Optional + +from infrastructure.validators import is_valid_mbid + + +def release_group_cover_url(release_group_mbid: Optional[str], size: int = 500) -> Optional[str]: + if not is_valid_mbid(release_group_mbid): + return None + return f"/api/v1/covers/release-group/{release_group_mbid}?size={size}" + + +def release_cover_url(release_mbid: Optional[str], size: int = 500) -> Optional[str]: + if not is_valid_mbid(release_mbid): + return None + return f"/api/v1/covers/release/{release_mbid}?size={size}" + + +def artist_cover_url(artist_mbid: Optional[str], size: int = 500) -> Optional[str]: + if not is_valid_mbid(artist_mbid): + return None + return f"/api/v1/covers/artist/{artist_mbid}?size={size}" + + +def prefer_release_group_cover_url( + release_group_mbid: Optional[str], + fallback_url: Optional[str] = None, + size: int = 500, +) -> Optional[str]: + return release_group_cover_url(release_group_mbid, size=size) or fallback_url + + +def prefer_artist_cover_url( + artist_mbid: Optional[str], + fallback_url: Optional[str] = None, + size: int = 500, +) -> Optional[str]: + return artist_cover_url(artist_mbid, size=size) or fallback_url diff --git a/backend/infrastructure/degradation.py b/backend/infrastructure/degradation.py new file mode 100644 index 0000000..a313a83 --- /dev/null +++ b/backend/infrastructure/degradation.py @@ -0,0 +1,98 @@ +"""Request-scoped degradation context via ``contextvars``. + +A :class:`DegradationContext` is created per HTTP request by the +:class:`DegradationMiddleware` and can be accessed from *any* layer +(repository, service, route) without threading extra arguments through +call signatures. +""" + +from __future__ import annotations + +import contextvars +import logging +from typing import Literal + +from infrastructure.integration_result import IntegrationResult, IntegrationStatus + +logger = logging.getLogger(__name__) + +_degradation_ctx_var: contextvars.ContextVar[DegradationContext | None] = ( + contextvars.ContextVar("degradation_ctx", default=None) +) + + +class DegradationContext: + """Accumulates per-source integration status within a single request.""" + + __slots__ = ("_services",) + + def __init__(self) -> None: + self._services: dict[str, IntegrationStatus] = {} + + def record(self, result: IntegrationResult) -> None: # type: ignore[type-arg] + """Record an integration result, keeping the worst status per source.""" + prev = self._services.get(result.source) + if prev is None or _severity(result.status) > _severity(prev): + self._services[result.source] = result.status + if result.status != "ok": + logger.debug( + "Degradation recorded: source=%s status=%s msg=%s", + result.source, + result.status, + result.error_message, + ) + + def summary(self) -> dict[str, str]: + """Return ``{source: status}`` for all recorded integrations.""" + return dict(self._services) + + def degraded_summary(self) -> dict[str, str]: + """Return only sources that are *not* ``ok``.""" + return {k: v for k, v in self._services.items() if v != "ok"} + + def has_degradation(self) -> bool: + return any(v != "ok" for v in self._services.values()) + + + + +def init_degradation_context() -> DegradationContext: + """Create a fresh context and install it in the current ``ContextVar``.""" + ctx = DegradationContext() + _degradation_ctx_var.set(ctx) + return ctx + + +def get_degradation_context() -> DegradationContext: + """Return the current request's context. + + Raises :class:`RuntimeError` when called outside a request scope. + """ + ctx = _degradation_ctx_var.get() + if ctx is None: + raise RuntimeError( + "get_degradation_context() called outside a request scope" + ) + return ctx + + +def try_get_degradation_context() -> DegradationContext | None: + """Return the current context, or ``None`` if none is active. + + Safe for background tasks / startup code where no request is in + flight. + """ + return _degradation_ctx_var.get() + + +def clear_degradation_context() -> None: + """Remove the current context (end-of-request cleanup).""" + _degradation_ctx_var.set(None) + + + +_SEVERITY: dict[IntegrationStatus, int] = {"ok": 0, "degraded": 1, "error": 2} + + +def _severity(status: IntegrationStatus) -> int: + return _SEVERITY[status] diff --git a/backend/infrastructure/external/__init__.py b/backend/infrastructure/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infrastructure/file_utils.py b/backend/infrastructure/file_utils.py new file mode 100644 index 0000000..46f758f --- /dev/null +++ b/backend/infrastructure/file_utils.py @@ -0,0 +1,105 @@ +import asyncio +import logging +import threading +from pathlib import Path +from typing import Any + +import aiofiles +import aiofiles.os +import msgspec + +logger = logging.getLogger(__name__) + +_file_locks: dict[str, threading.Lock] = {} +_locks_lock = threading.Lock() +_async_locks: dict[str, asyncio.Lock] = {} + + +def _get_file_lock(file_path: Path) -> threading.Lock: + path_str = str(file_path.resolve()) + with _locks_lock: + if path_str not in _file_locks: + _file_locks[path_str] = threading.Lock() + return _file_locks[path_str] + + +def _get_async_lock(file_path: Path) -> asyncio.Lock: + path_str = str(file_path.resolve()) + if path_str not in _async_locks: + _async_locks[path_str] = asyncio.Lock() + return _async_locks[path_str] + + +def atomic_write_json(file_path: Path, data: Any, indent: int = 2) -> None: + lock = _get_file_lock(file_path) + + with lock: + file_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = file_path.with_suffix(file_path.suffix + '.tmp') + + try: + _ = indent + with open(tmp_path, 'wb') as f: + f.write(msgspec.json.encode(data)) + f.flush() + + tmp_path.replace(file_path) + logger.debug(f"Atomically wrote JSON to {file_path}") + + except Exception as e: + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError as cleanup_error: + logger.warning("Couldn't remove temp file %s: %s", tmp_path, cleanup_error) + logger.error(f"Failed to write JSON to {file_path}: {e}") + raise + + +async def atomic_write_json_async(file_path: Path, data: Any, indent: int = 2) -> None: + lock = _get_async_lock(file_path) + + async with lock: + await aiofiles.os.makedirs(file_path.parent, exist_ok=True) + tmp_path = file_path.with_suffix(file_path.suffix + '.tmp') + + try: + _ = indent + content = msgspec.json.encode(data) + async with aiofiles.open(tmp_path, 'wb') as f: + await f.write(content) + + await asyncio.to_thread(tmp_path.replace, file_path) + logger.debug(f"Atomically wrote JSON to {file_path}") + + except Exception as e: + try: + if tmp_path.exists(): + await aiofiles.os.remove(tmp_path) + except OSError as cleanup_error: + logger.warning("Couldn't remove temp file %s: %s", tmp_path, cleanup_error) + logger.error(f"Failed to write JSON to {file_path}: {e}") + raise + + +def read_json(file_path: Path, default: Any = None) -> Any: + lock = _get_file_lock(file_path) + + with lock: + if not file_path.exists(): + return default + + with open(file_path, 'rb') as f: + return msgspec.json.decode(f.read()) + + +async def read_json_async(file_path: Path, default: Any = None) -> Any: + lock = _get_async_lock(file_path) + + async with lock: + if not file_path.exists(): + return default + + async with aiofiles.open(file_path, 'rb') as f: + content = await f.read() + return msgspec.json.decode(content) diff --git a/backend/infrastructure/http/__init__.py b/backend/infrastructure/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infrastructure/http/client.py b/backend/infrastructure/http/client.py new file mode 100644 index 0000000..cf91ed2 --- /dev/null +++ b/backend/infrastructure/http/client.py @@ -0,0 +1,90 @@ +import httpx +import logging +from typing import Optional +from core.config import Settings, get_settings + +logger = logging.getLogger(__name__) + + +def _get_user_agent(settings: Optional[Settings] = None) -> str: + if settings: + return settings.get_user_agent() + return get_settings().get_user_agent() + + +class HttpClientFactory: + _clients: dict[str, httpx.AsyncClient] = {} + + @classmethod + def get_client( + cls, + name: str = "default", + timeout: float = 10.0, + connect_timeout: float = 5.0, + max_connections: int = 200, + max_keepalive: int = 200, + settings: Optional[Settings] = None, + http2: bool = True, + **kwargs + ) -> httpx.AsyncClient: + if name not in cls._clients: + cls._clients[name] = httpx.AsyncClient( + http2=http2, + timeout=httpx.Timeout(timeout, connect=connect_timeout), + limits=httpx.Limits( + max_connections=max_connections, + max_keepalive_connections=max_keepalive, + keepalive_expiry=60.0, + ), + follow_redirects=True, + transport=httpx.AsyncHTTPTransport(http2=http2, retries=0), + headers={"User-Agent": _get_user_agent(settings)}, + **kwargs + ) + return cls._clients[name] + + @classmethod + async def close_all(cls) -> None: + for client in cls._clients.values(): + await client.aclose() + cls._clients.clear() + + +def get_http_client( + settings: Optional[Settings] = None, + timeout: Optional[float] = None, + connect_timeout: Optional[float] = None, + max_connections: Optional[int] = None, +) -> httpx.AsyncClient: + if settings is None: + settings = get_settings() + return HttpClientFactory.get_client( + name="default", + timeout=timeout or settings.http_timeout, + connect_timeout=connect_timeout or settings.http_connect_timeout, + max_connections=max_connections or settings.http_max_connections, + max_keepalive=settings.http_max_keepalive, + settings=settings, + ) + + +async def close_http_clients() -> None: + await HttpClientFactory.close_all() + + +def get_listenbrainz_http_client( + settings: Optional[Settings] = None, + timeout: Optional[float] = None, + connect_timeout: Optional[float] = None, +) -> httpx.AsyncClient: + if settings is None: + settings = get_settings() + return HttpClientFactory.get_client( + name="listenbrainz", + timeout=timeout or settings.http_timeout, + connect_timeout=connect_timeout or settings.http_connect_timeout, + max_connections=20, + max_keepalive=20, + settings=settings, + http2=False, + ) diff --git a/backend/infrastructure/http/deduplication.py b/backend/infrastructure/http/deduplication.py new file mode 100644 index 0000000..5fabeed --- /dev/null +++ b/backend/infrastructure/http/deduplication.py @@ -0,0 +1,86 @@ +import asyncio +import logging +from typing import TypeVar, Awaitable, Callable, Any +from functools import wraps + +from core.exceptions import ClientDisconnectedError + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class RequestDeduplicator: + """ + Prevents duplicate concurrent requests by coalescing identical requests. + + If request A is in-flight and request B arrives with the same key, + request B will wait for A's result instead of making a duplicate call. + """ + + def __init__(self): + self._pending: dict[str, asyncio.Future[Any]] = {} + self._lock = asyncio.Lock() + + async def dedupe( + self, + key: str, + coro_factory: Callable[[], Awaitable[T]] + ) -> T: + while True: + async with self._lock: + if key in self._pending: + logger.debug(f"Deduplicating request: {key}") + future = self._pending[key] + should_execute = False + else: + future = asyncio.get_running_loop().create_future() + self._pending[key] = future + should_execute = True + + if should_execute: + try: + result = await coro_factory() + future.set_result(result) + except ClientDisconnectedError: + future.cancel() + raise + except Exception as e: # noqa: BLE001 + future.set_exception(e) + finally: + if not future.done(): + future.cancel() + async with self._lock: + self._pending.pop(key, None) + + try: + return await future + except asyncio.CancelledError: + continue + + +_global_deduplicator = RequestDeduplicator() + + +def get_deduplicator() -> RequestDeduplicator: + return _global_deduplicator + + +def deduplicate(key_func: Callable[..., str]): + """ + Decorator that deduplicates concurrent calls to the same function + with the same key. + + Usage: + @deduplicate(lambda self, artist_id: f"artist:{artist_id}") + async def get_artist(self, artist_id: str) -> Artist: + ... + """ + def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]: + @wraps(func) + async def wrapper(*args, **kwargs) -> T: + key = key_func(*args, **kwargs) + dedup = get_deduplicator() + return await dedup.dedupe(key, lambda: func(*args, **kwargs)) + return wrapper + return decorator diff --git a/backend/infrastructure/http/disconnect.py b/backend/infrastructure/http/disconnect.py new file mode 100644 index 0000000..0ad44e4 --- /dev/null +++ b/backend/infrastructure/http/disconnect.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable + +from core.exceptions import ClientDisconnectedError + +logger = logging.getLogger(__name__) + +DisconnectCallable = Callable[[], Awaitable[bool]] + + +async def check_disconnected( + is_disconnected: DisconnectCallable | None, +) -> None: + if is_disconnected is not None and await is_disconnected(): + logger.debug("Client disconnected — aborting cover fetch") + raise ClientDisconnectedError("Client disconnected") diff --git a/backend/infrastructure/integration_result.py b/backend/infrastructure/integration_result.py new file mode 100644 index 0000000..f76c085 --- /dev/null +++ b/backend/infrastructure/integration_result.py @@ -0,0 +1,82 @@ +"""Typed result wrapper for external integration calls. + +Replaces silent ``return [] / return None`` degradation patterns with +an explicit result that carries upstream status metadata. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, Literal, TypeVar + +T = TypeVar("T") + +IntegrationStatus = Literal["ok", "degraded", "error"] + + +@dataclass(frozen=True, slots=True) +class IntegrationResult(Generic[T]): + """Outcome of an external-service call. + + ``data`` is ``None`` only when ``status == "error"`` (upstream failed + completely). For ``"degraded"`` the caller received *some* data — + possibly stale or partial. + """ + + data: T | None + source: str + status: IntegrationStatus + error_message: str | None = None + + + @property + def is_ok(self) -> bool: + return self.status == "ok" + + @property + def is_degraded(self) -> bool: + return self.status == "degraded" + + @property + def is_error(self) -> bool: + return self.status == "error" + + + @staticmethod + def ok(data: T, source: str) -> IntegrationResult[T]: + return IntegrationResult(data=data, source=source, status="ok") + + @staticmethod + def degraded( + data: T, source: str, msg: str + ) -> IntegrationResult[T]: + return IntegrationResult( + data=data, source=source, status="degraded", error_message=msg + ) + + @staticmethod + def error(source: str, msg: str) -> IntegrationResult[None]: + return IntegrationResult( + data=None, source=source, status="error", error_message=msg + ) + + + def data_or(self, default: T) -> T: + """Return ``self.data`` if present, else *default*.""" + return self.data if self.data is not None else default + + +def aggregate_status( + *results: IntegrationResult, # type: ignore[type-arg] +) -> IntegrationStatus: + """Compute the worst status across multiple results. + + error > degraded > ok + """ + dominated: IntegrationStatus = "ok" + for r in results: + if r.status == "error": + return "error" + if r.status == "degraded": + dominated = "degraded" + return dominated diff --git a/backend/infrastructure/logging_helper.py b/backend/infrastructure/logging_helper.py new file mode 100644 index 0000000..5fed9fa --- /dev/null +++ b/backend/infrastructure/logging_helper.py @@ -0,0 +1,43 @@ +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def format_mbid(mbid: str) -> str: + return f"{mbid[:8]}..." if len(mbid) >= 8 else mbid + + +def log_cache_hit(entity_type: str, mbid: str, source: Optional[str] = None) -> None: + source_info = f" from {source}" if source else "" + logger.info(f"Cache hit: {entity_type} {format_mbid(mbid)}{source_info}") + + +def log_cache_miss(entity_type: str, mbid: str, source: Optional[str] = None) -> None: + source_info = f" in {source}" if source else "" + logger.debug(f"Cache miss: {entity_type} {format_mbid(mbid)}{source_info}") + + +def log_fetch_start(entity_type: str, mbid: str, source: str) -> None: + logger.info(f"Fetching {entity_type} {format_mbid(mbid)} from {source}") + + +def log_fetch_success(entity_type: str, mbid: str, source: str) -> None: + logger.info(f"Fetch success: {entity_type} {format_mbid(mbid)} from {source}") + + +def log_fetch_failed(entity_type: str, mbid: str, source: str, reason: Optional[str] = None) -> None: + reason_info = f": {reason}" if reason else "" + logger.warning(f"Fetch failed: {entity_type} {format_mbid(mbid)} from {source}{reason_info}") + + +def log_image_fetch(action: str, entity_type: str, mbid: str, source: str) -> None: + logger.info(f"Image {action}: {entity_type} {format_mbid(mbid)} from {source}") + + +def log_http_error(entity_type: str, mbid: str, source: str, status_code: int) -> None: + logger.warning(f"HTTP {status_code}: {entity_type} {format_mbid(mbid)} from {source}") + + +def log_exception(entity_type: str, mbid: str, operation: str, error: Exception) -> None: + logger.error(f"Exception in {operation} for {entity_type} {format_mbid(mbid)}: {error}") diff --git a/backend/infrastructure/msgspec_fastapi.py b/backend/infrastructure/msgspec_fastapi.py new file mode 100644 index 0000000..3066547 --- /dev/null +++ b/backend/infrastructure/msgspec_fastapi.py @@ -0,0 +1,170 @@ +import json +import logging +from collections.abc import Mapping +from typing import Any, Callable, TypeVar, get_args, get_origin + +import msgspec +from fastapi import Body, Depends, HTTPException +from pydantic_core import core_schema +from fastapi.routing import APIRoute +from fastapi.responses import JSONResponse +from starlette.requests import Request +from starlette.responses import Response + +T = TypeVar("T") +logger = logging.getLogger(__name__) + + +class AppStruct(msgspec.Struct, kw_only=True): + def __iter__(self): + return iter(msgspec.to_builtins(self).items()) + + @classmethod + def __get_pydantic_core_schema__( + cls, + source_type: Any, + _handler: Any, + ) -> core_schema.CoreSchema: + def validate(value: Any) -> Any: + if isinstance(value, cls): + return value + + try: + return msgspec.convert(value, type=source_type, strict=False) + except (msgspec.ValidationError, TypeError, ValueError) as exc: + raise ValueError(str(exc)) from exc + + return core_schema.no_info_plain_validator_function( + validate, + serialization=core_schema.plain_serializer_function_ser_schema( + lambda value: msgspec.to_builtins(value) + ), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, + _core_schema: core_schema.CoreSchema, + _handler: Any, + ) -> dict[str, Any]: + try: + schema = dict(msgspec.json.schema(cls)) + except TypeError as exc: + logger.warning("Falling back to generic OpenAPI schema for %s: %s", cls.__name__, exc) + return {"type": "object", "title": cls.__name__} + + if "$ref" in schema or "$defs" in schema: + logger.warning( + "Falling back to generic OpenAPI schema for %s due to unsupported refs/defs in msgspec schema", + cls.__name__, + ) + return {"type": "object", "title": cls.__name__} + + return schema + + +class MsgSpecJSONResponse(JSONResponse): + def render(self, content: Any) -> bytes: + try: + return msgspec.json.encode(content) + except TypeError: + return super().render(content) + + +class MsgSpecJSONRequest(Request): + async def json(self) -> Any: + if not hasattr(self, "_msgspec_json"): + body = await self.body() + try: + self._msgspec_json = msgspec.json.decode(body) + except msgspec.DecodeError as exc: + body_text = body.decode("utf-8", errors="replace") + raise json.JSONDecodeError(str(exc), body_text, 0) from exc + return self._msgspec_json + + +class MsgSpecRoute(APIRoute): + def __init__( + self, + *args: Any, + response_model: Any = None, + openapi_extra: Any = None, + **kwargs: Any, + ) -> None: + route_openapi_extra = openapi_extra + resolved_response_model = response_model + + if _contains_msgspec_struct(response_model): + try: + schema = msgspec.json.schema(response_model) + except TypeError: + schema = None + + if schema is not None: + route_openapi_extra = _merge_response_schema(route_openapi_extra, schema) + resolved_response_model = None + + super().__init__(*args, response_model=resolved_response_model, openapi_extra=route_openapi_extra, **kwargs) + + def get_route_handler(self) -> Callable[[Request], Response]: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = MsgSpecJSONRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +def MsgSpecBody(model: type[T]) -> Any: + async def dependency(payload: Any = Body(...)) -> T: + try: + return msgspec.convert(payload, type=model, strict=False) + except (msgspec.ValidationError, ValueError) as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + dependency.__annotations__["payload"] = model + + return Depends(dependency) + + +def _contains_msgspec_struct(value: Any) -> bool: + if value is None: + return False + + if isinstance(value, type) and issubclass(value, msgspec.Struct): + return True + + origin = get_origin(value) + if origin is None: + return False + + args = get_args(value) + return any(_contains_msgspec_struct(argument) for argument in args) + + +def _merge_response_schema(openapi_extra: Any, schema: Mapping[str, Any]) -> dict[str, Any]: + merged = dict(openapi_extra) if isinstance(openapi_extra, Mapping) else {} + + responses = merged.setdefault("responses", {}) + if not isinstance(responses, dict): + responses = {} + merged["responses"] = responses + + response_200 = responses.setdefault("200", {}) + if not isinstance(response_200, dict): + response_200 = {} + responses["200"] = response_200 + + content = response_200.setdefault("content", {}) + if not isinstance(content, dict): + content = {} + response_200["content"] = content + + app_json = content.setdefault("application/json", {}) + if not isinstance(app_json, dict): + app_json = {} + content["application/json"] = app_json + + app_json["schema"] = dict(schema) + return merged diff --git a/backend/infrastructure/persistence/__init__.py b/backend/infrastructure/persistence/__init__.py new file mode 100644 index 0000000..571fcaf --- /dev/null +++ b/backend/infrastructure/persistence/__init__.py @@ -0,0 +1,22 @@ +"""Durable persistence layer — data that survives cache clears. + +All stores share a single SQLite database via :class:`PersistenceBase`. +""" + +from infrastructure.persistence._database import PersistenceBase +from infrastructure.persistence.genre_index import GenreIndex +from infrastructure.persistence.library_db import LibraryDB +from infrastructure.persistence.mbid_store import MBIDStore +from infrastructure.persistence.request_history import RequestHistoryStore +from infrastructure.persistence.sync_state_store import SyncStateStore +from infrastructure.persistence.youtube_store import YouTubeStore + +__all__ = [ + "PersistenceBase", + "LibraryDB", + "GenreIndex", + "YouTubeStore", + "MBIDStore", + "SyncStateStore", + "RequestHistoryStore", +] diff --git a/backend/infrastructure/persistence/_database.py b/backend/infrastructure/persistence/_database.py new file mode 100644 index 0000000..5f01617 --- /dev/null +++ b/backend/infrastructure/persistence/_database.py @@ -0,0 +1,85 @@ +"""Shared SQLite infrastructure for all persistence stores.""" + +import asyncio +import json +import logging +import sqlite3 +import threading +from pathlib import Path +from typing import Any, TypeVar + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +def _encode_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, separators=(",", ":")) + + +def _decode_json(text: str) -> Any: + return json.loads(text) + + +def _normalize(value: str | None) -> str: + return value.lower() if isinstance(value, str) else "" + + +def _decode_rows(rows: list[sqlite3.Row]) -> list[dict[str, Any]]: + decoded: list[dict[str, Any]] = [] + for row in rows: + try: + payload = _decode_json(row["raw_json"]) + except Exception: # noqa: BLE001 + continue + if isinstance(payload, dict): + decoded.append(payload) + return decoded + + +class PersistenceBase: + """Shared base for all domain-specific SQLite stores. + + All stores receive the *same* ``db_path`` and ``write_lock`` so they + operate on a single database file with serialised writes. + """ + + def __init__(self, db_path: Path, write_lock: threading.Lock) -> None: + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock = write_lock + with self._write_lock: + self._ensure_tables() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + def _execute(self, operation: Any, write: bool) -> Any: + if write: + with self._write_lock: + conn = self._connect() + try: + result = operation(conn) + conn.commit() + return result + finally: + conn.close() + + conn = self._connect() + try: + return operation(conn) + finally: + conn.close() + + async def _read(self, operation: Any) -> Any: + return await asyncio.to_thread(self._execute, operation, False) + + async def _write(self, operation: Any) -> Any: + return await asyncio.to_thread(self._execute, operation, True) + + def _ensure_tables(self) -> None: + raise NotImplementedError diff --git a/backend/infrastructure/persistence/genre_index.py b/backend/infrastructure/persistence/genre_index.py new file mode 100644 index 0000000..2abf6a3 --- /dev/null +++ b/backend/infrastructure/persistence/genre_index.py @@ -0,0 +1,177 @@ +"""Domain 2 — Genre indexing persistence.""" + +import logging +import sqlite3 +from typing import Any + +from infrastructure.persistence._database import ( + PersistenceBase, + _decode_json, + _decode_rows, + _encode_json, + _normalize, +) + +logger = logging.getLogger(__name__) + +LIBRARY_ARTISTS_TABLE = "library_artists" +LIBRARY_ALBUMS_TABLE = "library_albums" + + +def _normalize_genre(value: str | None) -> str: + return value.strip().lower() if isinstance(value, str) else "" + + +def _clean_genres(values: list[Any]) -> list[str]: + cleaned: list[str] = [] + seen: set[str] = set() + for value in values: + if not isinstance(value, str): + continue + genre = value.strip() + if not genre: + continue + normalized = _normalize_genre(genre) + if normalized in seen: + continue + seen.add(normalized) + cleaned.append(genre) + return cleaned + + +class GenreIndex(PersistenceBase): + """Owns tables: ``artist_genres``, ``artist_genre_lookup``. + + Genre queries JOIN against ``library_artists`` / ``library_albums`` + which live in the same SQLite database (owned by :class:`LibraryDB`). + """ + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS artist_genres ( + artist_mbid_lower TEXT PRIMARY KEY, + artist_mbid TEXT NOT NULL, + genres_json TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS artist_genre_lookup ( + artist_mbid_lower TEXT NOT NULL, + genre_lower TEXT NOT NULL, + PRIMARY KEY (artist_mbid_lower, genre_lower) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_artist_genre_lookup_genre ON artist_genre_lookup(genre_lower, artist_mbid_lower)" + ) + self._backfill_artist_genre_lookup(conn) + conn.commit() + finally: + conn.close() + + def _replace_artist_genre_lookup( + self, + conn: sqlite3.Connection, + artist_mbid: str, + genres: list[str], + ) -> None: + artist_mbid_lower = _normalize(artist_mbid) + conn.execute( + "DELETE FROM artist_genre_lookup WHERE artist_mbid_lower = ?", + (artist_mbid_lower,), + ) + for genre in genres: + conn.execute( + "INSERT OR REPLACE INTO artist_genre_lookup (artist_mbid_lower, genre_lower) VALUES (?, ?)", + (artist_mbid_lower, _normalize_genre(genre)), + ) + + def _backfill_artist_genre_lookup(self, conn: sqlite3.Connection) -> None: + lookup_count_row = conn.execute( + "SELECT COUNT(*) AS count FROM artist_genre_lookup" + ).fetchone() + if lookup_count_row is not None and int(lookup_count_row["count"] or 0) > 0: + return + + rows = conn.execute( + "SELECT artist_mbid, genres_json FROM artist_genres" + ).fetchall() + for row in rows: + try: + genres = _decode_json(row["genres_json"]) + except Exception: # noqa: BLE001 + continue + if not isinstance(genres, list): + continue + self._replace_artist_genre_lookup(conn, str(row["artist_mbid"]), _clean_genres(genres)) + + async def save_artist_genres(self, artist_genres: dict[str, list[str]]) -> None: + normalized = { + mbid: _clean_genres(genres) + for mbid, genres in artist_genres.items() + if isinstance(mbid, str) and mbid + } + + def operation(conn: sqlite3.Connection) -> None: + for artist_mbid, genres in normalized.items(): + conn.execute( + """ + INSERT INTO artist_genres (artist_mbid_lower, artist_mbid, genres_json) + VALUES (?, ?, ?) + ON CONFLICT(artist_mbid_lower) DO UPDATE SET + artist_mbid = excluded.artist_mbid, + genres_json = excluded.genres_json + """, + (_normalize(artist_mbid), artist_mbid, _encode_json(genres)), + ) + self._replace_artist_genre_lookup(conn, artist_mbid, genres) + + await self._write(operation) + + async def get_artists_by_genre(self, genre: str, limit: int = 50) -> list[dict[str, Any]]: + needle = _normalize_genre(genre) + if not needle: + return [] + + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + """ + SELECT a.raw_json + FROM library_artists a + JOIN artist_genre_lookup g ON a.mbid_lower = g.artist_mbid_lower + WHERE g.genre_lower = ? + ORDER BY COALESCE(a.date_added, 0) DESC, a.name COLLATE NOCASE ASC + LIMIT ? + """, + (needle, max(limit, 1)), + ).fetchall() + return _decode_rows(rows) + + return await self._read(operation) + + async def get_albums_by_genre(self, genre: str, limit: int = 50) -> list[dict[str, Any]]: + needle = _normalize_genre(genre) + if not needle: + return [] + + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + """ + SELECT a.raw_json + FROM library_albums a + JOIN artist_genre_lookup g ON a.artist_mbid_lower = g.artist_mbid_lower + WHERE g.genre_lower = ? + ORDER BY COALESCE(a.date_added, 0) DESC, a.title COLLATE NOCASE ASC + LIMIT ? + """, + (needle, max(limit, 1)), + ).fetchall() + return _decode_rows(rows) + + return await self._read(operation) diff --git a/backend/infrastructure/persistence/library_db.py b/backend/infrastructure/persistence/library_db.py new file mode 100644 index 0000000..8b1beab --- /dev/null +++ b/backend/infrastructure/persistence/library_db.py @@ -0,0 +1,418 @@ +"""Domain 1 — Library state persistence (artists, albums, metadata).""" + +import json +import logging +import sqlite3 +import time +from typing import Any + +from infrastructure.persistence._database import ( + PersistenceBase, + _decode_json, + _decode_rows, + _encode_json, + _normalize, +) +from infrastructure.serialization import to_jsonable + +logger = logging.getLogger(__name__) + + +def _escape_like(term: str) -> str: + """Escape SQL LIKE metacharacters so they match literally.""" + return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + +# Cross-domain tables cleared during full library resync / clear. +# These belong to other stores but must be reset atomically with library data. +_CROSS_DOMAIN_CLEAR_TABLES = ( + "artist_genres", + "artist_genre_lookup", + "processed_items", +) + +_FULL_CLEAR_EXTRA_TABLES = ( + "sync_state", + "jellyfin_mbid_index", + "navidrome_album_mbid_index", + "navidrome_artist_mbid_index", +) + + +def _safe_delete(conn: sqlite3.Connection, table: str) -> None: + """DELETE FROM a table that may not exist yet (cross-domain dependency).""" + try: + conn.execute(f'DELETE FROM "{table}"') + except sqlite3.OperationalError as exc: + if "no such table" in str(exc): + logger.debug("Cross-domain table %s not yet created, skipping clear", table) + else: + logger.warning("Unexpected error clearing cross-domain table %s: %s", table, exc) + + +class LibraryDB(PersistenceBase): + """Owns tables: ``cache_meta``, ``library_artists``, ``library_albums``.""" + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS cache_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS library_artists ( + mbid_lower TEXT PRIMARY KEY, + mbid TEXT NOT NULL, + name TEXT NOT NULL, + album_count INTEGER DEFAULT 0, + date_added INTEGER, + raw_json TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_artists_date_added ON library_artists(date_added DESC)" + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS library_albums ( + mbid_lower TEXT PRIMARY KEY, + mbid TEXT NOT NULL, + artist_mbid TEXT, + artist_mbid_lower TEXT, + artist_name TEXT, + title TEXT NOT NULL, + year INTEGER, + cover_url TEXT, + monitored INTEGER DEFAULT 0, + date_added INTEGER, + raw_json TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_albums_artist_mbid ON library_albums(artist_mbid_lower)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_albums_date_added ON library_albums(date_added DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_albums_title ON library_albums(title COLLATE NOCASE)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_albums_artist_name ON library_albums(artist_name COLLATE NOCASE)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_albums_year ON library_albums(year)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_library_artists_name ON library_artists(name COLLATE NOCASE)" + ) + conn.commit() + finally: + conn.close() + + async def save_library(self, artists: list[Any], albums: list[Any]) -> None: + builtins_artists = [to_jsonable(artist) for artist in artists] + builtins_albums = [to_jsonable(album) for album in albums] + now = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM library_artists") + conn.execute("DELETE FROM library_albums") + for tbl in _CROSS_DOMAIN_CLEAR_TABLES: + _safe_delete(conn, tbl) + + artist_rows = [] + for artist in builtins_artists: + if not isinstance(artist, dict): + continue + mbid = artist.get("mbid") + if not isinstance(mbid, str) or not mbid: + continue + artist_rows.append(( + _normalize(mbid), + mbid, + str(artist.get("name") or "Unknown"), + int(artist.get("album_count") or 0), + artist.get("date_added"), + _encode_json(artist), + )) + if artist_rows: + conn.executemany( + "INSERT INTO library_artists (mbid_lower, mbid, name, album_count, date_added, raw_json) VALUES (?, ?, ?, ?, ?, ?)", + artist_rows, + ) + + album_rows = [] + for album in builtins_albums: + if not isinstance(album, dict): + continue + mbid = album.get("mbid") + if not isinstance(mbid, str) or not mbid: + continue + artist_mbid = album.get("artist_mbid") + album_rows.append(( + _normalize(mbid), + mbid, + artist_mbid, + _normalize(artist_mbid if isinstance(artist_mbid, str) else None), + album.get("artist_name"), + str(album.get("title") or "Unknown Album"), + album.get("year"), + album.get("cover_url"), + 1 if bool(album.get("monitored", True)) else 0, + album.get("date_added"), + _encode_json(album), + )) + if album_rows: + conn.executemany( + """ + INSERT INTO library_albums ( + mbid_lower, mbid, artist_mbid, artist_mbid_lower, artist_name, + title, year, cover_url, monitored, date_added, raw_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + album_rows, + ) + + conn.execute( + "INSERT INTO cache_meta (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", + ("last_library_sync", str(now), now), + ) + + await self._write(operation) + + async def upsert_album(self, album: dict[str, Any]) -> None: + mbid = album.get("mbid") + if not isinstance(mbid, str) or not mbid: + return + artist_mbid = album.get("artist_mbid") + raw_json = _encode_json(album) + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO library_albums ( + mbid_lower, mbid, artist_mbid, artist_mbid_lower, artist_name, + title, year, cover_url, monitored, date_added, raw_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(mbid_lower) DO UPDATE SET + artist_mbid = excluded.artist_mbid, + artist_mbid_lower = excluded.artist_mbid_lower, + artist_name = excluded.artist_name, + title = excluded.title, + year = excluded.year, + cover_url = excluded.cover_url, + monitored = excluded.monitored, + date_added = excluded.date_added, + raw_json = excluded.raw_json + """, + ( + _normalize(mbid), + mbid, + artist_mbid, + _normalize(artist_mbid if isinstance(artist_mbid, str) else None), + album.get("artist_name"), + str(album.get("title") or "Unknown Album"), + album.get("year"), + album.get("cover_url"), + 1 if bool(album.get("monitored", True)) else 0, + album.get("date_added"), + raw_json, + ), + ) + + await self._write(operation) + + async def get_artists(self, limit: int | None = None) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + query = "SELECT raw_json FROM library_artists ORDER BY COALESCE(date_added, 0) DESC, name COLLATE NOCASE ASC" + params: tuple[object, ...] = () + if limit is not None: + query += " LIMIT ?" + params = (limit,) + rows = conn.execute(query, params).fetchall() + return _decode_rows(rows) + + return await self._read(operation) + + async def get_albums(self) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT raw_json FROM library_albums ORDER BY COALESCE(date_added, 0) DESC, title COLLATE NOCASE ASC" + ).fetchall() + return _decode_rows(rows) + + return await self._read(operation) + + _ALBUM_SORT_COLUMNS = { + "date_added": "COALESCE(date_added, 0)", + "title": "title COLLATE NOCASE", + "artist": "artist_name COLLATE NOCASE", + "year": "COALESCE(year, 0)", + } + + _ARTIST_SORT_COLUMNS = { + "name": "name COLLATE NOCASE", + "album_count": "album_count", + "date_added": "COALESCE(date_added, 0)", + } + + async def get_albums_paginated( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "date_added", + sort_order: str = "desc", + search: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: + sort_col = self._ALBUM_SORT_COLUMNS.get(sort_by, "COALESCE(date_added, 0)") + direction = "ASC" if sort_order.lower() == "asc" else "DESC" + + def operation(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], int]: + where = "" + params: list[object] = [] + if search: + term = f"%{_escape_like(search)}%" + where = "WHERE (artist_name LIKE ? ESCAPE '\\' COLLATE NOCASE OR title LIKE ? ESCAPE '\\' COLLATE NOCASE)" + params = [term, term] + + count_row = conn.execute( + f"SELECT COUNT(*) AS cnt FROM library_albums {where}", params + ).fetchone() + total = int(count_row["cnt"]) if count_row else 0 + + rows = conn.execute( + f"SELECT raw_json FROM library_albums {where} ORDER BY {sort_col} {direction}, title COLLATE NOCASE ASC, mbid_lower ASC LIMIT ? OFFSET ?", + [*params, max(limit, 1), max(offset, 0)], + ).fetchall() + return _decode_rows(rows), total + + return await self._read(operation) + + async def get_artists_paginated( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "name", + sort_order: str = "asc", + search: str | None = None, + ) -> tuple[list[dict[str, Any]], int]: + sort_col = self._ARTIST_SORT_COLUMNS.get(sort_by, "name COLLATE NOCASE") + direction = "ASC" if sort_order.lower() == "asc" else "DESC" + + def operation(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], int]: + where = "" + params: list[object] = [] + if search: + term = f"%{_escape_like(search)}%" + where = "WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE" + params = [term] + + count_row = conn.execute( + f"SELECT COUNT(*) AS cnt FROM library_artists {where}", params + ).fetchone() + total = int(count_row["cnt"]) if count_row else 0 + + rows = conn.execute( + f"SELECT raw_json FROM library_artists {where} ORDER BY {sort_col} {direction}, name COLLATE NOCASE ASC, mbid_lower ASC LIMIT ? OFFSET ?", + [*params, max(limit, 1), max(offset, 0)], + ).fetchall() + return _decode_rows(rows), total + + return await self._read(operation) + + async def get_recently_added(self, limit: int = 20) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT raw_json FROM library_albums ORDER BY COALESCE(date_added, 0) DESC, title COLLATE NOCASE ASC LIMIT ?", + (max(limit, 1),), + ).fetchall() + return _decode_rows(rows) + + return await self._read(operation) + + async def get_album_by_mbid(self, musicbrainz_id: str) -> dict[str, Any] | None: + normalized_mbid = _normalize(musicbrainz_id) + + def operation(conn: sqlite3.Connection) -> dict[str, Any] | None: + row = conn.execute( + "SELECT raw_json FROM library_albums WHERE mbid_lower = ?", + (normalized_mbid,), + ).fetchone() + if row is None: + return None + try: + payload = _decode_json(row["raw_json"]) + except (json.JSONDecodeError, TypeError): + return None + return payload if isinstance(payload, dict) else None + + return await self._read(operation) + + async def get_all_album_mbids(self) -> set[str]: + def operation(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT mbid FROM library_albums").fetchall() + return {str(row["mbid"]) for row in rows if row["mbid"]} + + return await self._read(operation) + + async def get_all_artist_mbids(self) -> set[str]: + def operation(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT mbid FROM library_artists").fetchall() + return {str(row["mbid"]) for row in rows if row["mbid"]} + + return await self._read(operation) + + async def get_all_albums_for_matching(self) -> list[tuple[str, str, str, str]]: + """Return (title, artist_name, album_mbid, artist_mbid) for all library albums.""" + + def operation(conn: sqlite3.Connection) -> list[tuple[str, str, str, str]]: + rows = conn.execute( + "SELECT title, artist_name, mbid, COALESCE(artist_mbid, '') AS artist_mbid FROM library_albums" + ).fetchall() + return [ + (str(row["title"]), str(row["artist_name"] or ""), str(row["mbid"]), str(row["artist_mbid"])) + for row in rows + if row["title"] and row["mbid"] + ] + + return await self._read(operation) + + async def get_stats(self) -> dict[str, Any]: + def operation(conn: sqlite3.Connection) -> dict[str, Any]: + artist_row = conn.execute("SELECT COUNT(*) AS count FROM library_artists").fetchone() + album_row = conn.execute("SELECT COUNT(*) AS count FROM library_albums").fetchone() + sync_row = conn.execute("SELECT value FROM cache_meta WHERE key = 'last_library_sync'").fetchone() + last_sync = None + if sync_row is not None: + try: + last_sync = float(sync_row["value"]) + except (TypeError, ValueError): + last_sync = None + db_size_bytes = self.db_path.stat().st_size if self.db_path.exists() else 0 + return { + "artist_count": int(artist_row["count"] if artist_row is not None else 0), + "album_count": int(album_row["count"] if album_row is not None else 0), + "db_size_bytes": db_size_bytes, + "last_sync": last_sync, + } + + return await self._read(operation) + + async def clear(self) -> None: + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM library_artists") + conn.execute("DELETE FROM library_albums") + for tbl in _CROSS_DOMAIN_CLEAR_TABLES + _FULL_CLEAR_EXTRA_TABLES: + _safe_delete(conn, tbl) + conn.execute("DELETE FROM cache_meta WHERE key = 'last_library_sync'") + + await self._write(operation) diff --git a/backend/infrastructure/persistence/mbid_store.py b/backend/infrastructure/persistence/mbid_store.py new file mode 100644 index 0000000..60d5a90 --- /dev/null +++ b/backend/infrastructure/persistence/mbid_store.py @@ -0,0 +1,275 @@ +"""Domain 4 — MBID resolution and external-service index persistence.""" + +import logging +import sqlite3 +import time +from typing import Any + +from infrastructure.persistence._database import PersistenceBase, _normalize + +logger = logging.getLogger(__name__) + + +class MBIDStore(PersistenceBase): + """Owns tables: ``mbid_resolution_map``, ``ignored_releases``, + ``jellyfin_mbid_index``, ``navidrome_album_mbid_index``, + ``navidrome_artist_mbid_index``. + """ + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS mbid_resolution_map ( + source_mbid_lower TEXT PRIMARY KEY, + source_mbid TEXT NOT NULL, + release_group_mbid TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS ignored_releases ( + release_group_mbid_lower TEXT PRIMARY KEY, + release_group_mbid TEXT NOT NULL, + artist_mbid TEXT NOT NULL, + release_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + ignored_at REAL NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS jellyfin_mbid_index ( + mbid_lower TEXT PRIMARY KEY, + mbid TEXT NOT NULL, + item_id TEXT NOT NULL, + saved_at REAL NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS navidrome_album_mbid_index ( + cache_key TEXT PRIMARY KEY, + mbid TEXT, + saved_at REAL NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS navidrome_artist_mbid_index ( + cache_key TEXT PRIMARY KEY, + mbid TEXT, + saved_at REAL NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + async def save_mbid_resolution_map(self, mapping: dict[str, str | None]) -> None: + normalized = { + source_mbid: value + for source_mbid, value in mapping.items() + if isinstance(source_mbid, str) and source_mbid + } + + def operation(conn: sqlite3.Connection) -> None: + for source_mbid, resolved_mbid in normalized.items(): + conn.execute( + """ + INSERT INTO mbid_resolution_map (source_mbid_lower, source_mbid, release_group_mbid) + VALUES (?, ?, ?) + ON CONFLICT(source_mbid_lower) DO UPDATE SET + source_mbid = excluded.source_mbid, + release_group_mbid = excluded.release_group_mbid + """, + (_normalize(source_mbid), source_mbid, resolved_mbid), + ) + + await self._write(operation) + + async def get_mbid_resolution_map(self, source_mbids: list[str]) -> dict[str, str | None]: + normalized_mbids = [_normalize(mbid) for mbid in source_mbids if isinstance(mbid, str) and mbid] + if not normalized_mbids: + return {} + + def operation(conn: sqlite3.Connection) -> dict[str, str | None]: + placeholders = ",".join("?" for _ in normalized_mbids) + rows = conn.execute( + f"SELECT source_mbid_lower, release_group_mbid FROM mbid_resolution_map WHERE source_mbid_lower IN ({placeholders})", + tuple(normalized_mbids), + ).fetchall() + return {str(row["source_mbid_lower"]): row["release_group_mbid"] for row in rows} + + return await self._read(operation) + + async def add_ignored_release( + self, + release_group_mbid: str, + artist_mbid: str, + release_name: str, + artist_name: str, + ) -> None: + ignored_at = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO ignored_releases ( + release_group_mbid_lower, release_group_mbid, artist_mbid, + release_name, artist_name, ignored_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(release_group_mbid_lower) DO UPDATE SET + release_group_mbid = excluded.release_group_mbid, + artist_mbid = excluded.artist_mbid, + release_name = excluded.release_name, + artist_name = excluded.artist_name, + ignored_at = excluded.ignored_at + """, + ( + _normalize(release_group_mbid), + release_group_mbid, + artist_mbid, + release_name, + artist_name, + ignored_at, + ), + ) + + await self._write(operation) + + async def get_ignored_release_mbids(self) -> set[str]: + def operation(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute("SELECT release_group_mbid_lower FROM ignored_releases").fetchall() + return {str(row["release_group_mbid_lower"]) for row in rows if row["release_group_mbid_lower"]} + + return await self._read(operation) + + async def get_ignored_releases(self) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT release_group_mbid, artist_mbid, release_name, artist_name, ignored_at FROM ignored_releases ORDER BY ignored_at DESC" + ).fetchall() + return [ + { + "release_group_mbid": row["release_group_mbid"], + "artist_mbid": row["artist_mbid"], + "release_name": row["release_name"], + "artist_name": row["artist_name"], + "ignored_at": row["ignored_at"], + } + for row in rows + ] + + return await self._read(operation) + + async def save_jellyfin_mbid_index(self, index: dict[str, str]) -> None: + saved_at = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM jellyfin_mbid_index") + for mbid, item_id in index.items(): + if not isinstance(mbid, str) or not mbid or not isinstance(item_id, str) or not item_id: + continue + conn.execute( + "INSERT INTO jellyfin_mbid_index (mbid_lower, mbid, item_id, saved_at) VALUES (?, ?, ?, ?)", + (_normalize(mbid), mbid, item_id, saved_at), + ) + + await self._write(operation) + + async def load_jellyfin_mbid_index(self, max_age_seconds: int = 3600) -> dict[str, str]: + def operation(conn: sqlite3.Connection) -> dict[str, str]: + row = conn.execute("SELECT MAX(saved_at) AS saved_at FROM jellyfin_mbid_index").fetchone() + if row is None or row["saved_at"] is None: + return {} + if time.time() - float(row["saved_at"]) > max(max_age_seconds, 1): + return {} + rows = conn.execute("SELECT mbid, item_id FROM jellyfin_mbid_index").fetchall() + return {str(r["mbid"]): str(r["item_id"]) for r in rows if r["mbid"] and r["item_id"]} + + return await self._read(operation) + + async def clear_jellyfin_mbid_index(self) -> None: + await self._write(lambda conn: conn.execute("DELETE FROM jellyfin_mbid_index")) + + async def save_navidrome_album_mbid_index(self, index: dict[str, str | None]) -> None: + saved_at = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM navidrome_album_mbid_index") + for cache_key, mbid in index.items(): + if not isinstance(cache_key, str) or not cache_key: + continue + conn.execute( + "INSERT OR REPLACE INTO navidrome_album_mbid_index (cache_key, mbid, saved_at) VALUES (?, ?, ?)", + (cache_key, mbid, saved_at), + ) + + await self._write(operation) + + async def load_navidrome_album_mbid_index(self, max_age_seconds: int = 86400) -> dict[str, str | None]: + def operation(conn: sqlite3.Connection) -> dict[str, str | None]: + row = conn.execute("SELECT MAX(saved_at) AS saved_at FROM navidrome_album_mbid_index").fetchone() + if row is None or row["saved_at"] is None: + return {} + if time.time() - float(row["saved_at"]) > max(max_age_seconds, 1): + return {} + rows = conn.execute("SELECT cache_key, mbid FROM navidrome_album_mbid_index").fetchall() + return {str(r["cache_key"]): (str(r["mbid"]) if r["mbid"] else None) for r in rows if r["cache_key"]} + + return await self._read(operation) + + async def save_navidrome_artist_mbid_index(self, index: dict[str, str | None]) -> None: + saved_at = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM navidrome_artist_mbid_index") + for cache_key, mbid in index.items(): + if not isinstance(cache_key, str) or not cache_key: + continue + conn.execute( + "INSERT OR REPLACE INTO navidrome_artist_mbid_index (cache_key, mbid, saved_at) VALUES (?, ?, ?)", + (cache_key, mbid, saved_at), + ) + + await self._write(operation) + + async def load_navidrome_artist_mbid_index(self, max_age_seconds: int = 86400) -> dict[str, str | None]: + def operation(conn: sqlite3.Connection) -> dict[str, str | None]: + row = conn.execute("SELECT MAX(saved_at) AS saved_at FROM navidrome_artist_mbid_index").fetchone() + if row is None or row["saved_at"] is None: + return {} + if time.time() - float(row["saved_at"]) > max(max_age_seconds, 1): + return {} + rows = conn.execute("SELECT cache_key, mbid FROM navidrome_artist_mbid_index").fetchall() + return {str(r["cache_key"]): (str(r["mbid"]) if r["mbid"] else None) for r in rows if r["cache_key"]} + + return await self._read(operation) + + async def clear_navidrome_mbid_indexes(self) -> None: + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM navidrome_album_mbid_index") + conn.execute("DELETE FROM navidrome_artist_mbid_index") + + await self._write(operation) + + async def prune_old_ignored_releases(self, days: int) -> int: + """Delete ignored releases older than `days` days.""" + import time as _time + cutoff = _time.time() - days * 86400 + + def operation(conn: sqlite3.Connection) -> int: + cursor = conn.execute( + "DELETE FROM ignored_releases WHERE ignored_at < ?", + (cutoff,), + ) + return cursor.rowcount + + return await self._write(operation) diff --git a/backend/infrastructure/persistence/request_history.py b/backend/infrastructure/persistence/request_history.py new file mode 100644 index 0000000..71d8831 --- /dev/null +++ b/backend/infrastructure/persistence/request_history.py @@ -0,0 +1,302 @@ +import asyncio +import logging +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path + +import msgspec + +logger = logging.getLogger(__name__) + + +class RequestHistoryRecord(msgspec.Struct): + musicbrainz_id: str + artist_name: str + album_title: str + requested_at: str + status: str + artist_mbid: str | None = None + year: int | None = None + cover_url: str | None = None + completed_at: str | None = None + lidarr_album_id: int | None = None + + +class RequestHistoryStore: + _ACTIVE_STATUSES = ("pending", "downloading") + + def __init__(self, db_path: Path, write_lock: threading.Lock | None = None): + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock = write_lock or threading.Lock() + with self._write_lock: + self._ensure_tables() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS request_history ( + musicbrainz_id_lower TEXT PRIMARY KEY, + musicbrainz_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_title TEXT NOT NULL, + artist_mbid TEXT, + year INTEGER, + cover_url TEXT, + requested_at TEXT NOT NULL, + completed_at TEXT, + status TEXT NOT NULL, + lidarr_album_id INTEGER + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_request_history_status_requested_at ON request_history(status, requested_at DESC)" + ) + conn.commit() + finally: + conn.close() + + def _execute(self, operation, write: bool): + if write: + with self._write_lock: + conn = self._connect() + try: + result = operation(conn) + conn.commit() + return result + finally: + conn.close() + + conn = self._connect() + try: + return operation(conn) + finally: + conn.close() + + async def _read(self, operation): + return await asyncio.to_thread(self._execute, operation, False) + + async def _write(self, operation): + return await asyncio.to_thread(self._execute, operation, True) + + @staticmethod + def _row_to_record(row: sqlite3.Row | None) -> RequestHistoryRecord | None: + if row is None: + return None + return RequestHistoryRecord( + musicbrainz_id=row["musicbrainz_id"], + artist_name=row["artist_name"], + album_title=row["album_title"], + artist_mbid=row["artist_mbid"], + year=row["year"], + cover_url=row["cover_url"], + requested_at=row["requested_at"], + completed_at=row["completed_at"], + status=row["status"], + lidarr_album_id=row["lidarr_album_id"], + ) + + async def async_record_request( + self, + musicbrainz_id: str, + artist_name: str, + album_title: str, + year: int | None = None, + cover_url: str | None = None, + artist_mbid: str | None = None, + lidarr_album_id: int | None = None, + ) -> None: + requested_at = datetime.now(timezone.utc).isoformat() + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO request_history ( + musicbrainz_id_lower, musicbrainz_id, artist_name, album_title, + artist_mbid, year, cover_url, requested_at, completed_at, status, lidarr_album_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'pending', ?) + ON CONFLICT(musicbrainz_id_lower) DO UPDATE SET + musicbrainz_id = excluded.musicbrainz_id, + artist_name = excluded.artist_name, + album_title = excluded.album_title, + artist_mbid = excluded.artist_mbid, + year = excluded.year, + cover_url = COALESCE(excluded.cover_url, request_history.cover_url), + requested_at = excluded.requested_at, + completed_at = NULL, + status = 'pending', + lidarr_album_id = COALESCE(excluded.lidarr_album_id, request_history.lidarr_album_id) + """, + ( + normalized_mbid, + musicbrainz_id, + artist_name, + album_title, + artist_mbid, + year, + cover_url, + requested_at, + lidarr_album_id, + ), + ) + + await self._write(operation) + + async def async_get_record(self, musicbrainz_id: str) -> RequestHistoryRecord | None: + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> RequestHistoryRecord | None: + row = conn.execute( + "SELECT * FROM request_history WHERE musicbrainz_id_lower = ?", + (normalized_mbid,), + ).fetchone() + return self._row_to_record(row) + + return await self._read(operation) + + async def async_get_active_requests(self) -> list[RequestHistoryRecord]: + def operation(conn: sqlite3.Connection) -> list[RequestHistoryRecord]: + rows = conn.execute( + "SELECT * FROM request_history WHERE status IN (?, ?) ORDER BY requested_at DESC", + self._ACTIVE_STATUSES, + ).fetchall() + return [record for row in rows if (record := self._row_to_record(row)) is not None] + + return await self._read(operation) + + async def async_get_active_count(self) -> int: + def operation(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT COUNT(*) AS count FROM request_history WHERE status IN (?, ?)", + self._ACTIVE_STATUSES, + ).fetchone() + return int(row["count"] if row is not None else 0) + + return await self._read(operation) + + async def async_get_history( + self, + page: int = 1, + page_size: int = 20, + status_filter: str | None = None, + sort: str | None = None, + ) -> tuple[list[RequestHistoryRecord], int]: + safe_page = max(page, 1) + safe_page_size = max(page_size, 1) + offset = (safe_page - 1) * safe_page_size + + _SORT_MAP = { + "newest": "requested_at DESC", + "oldest": "requested_at ASC", + "status": "status ASC, requested_at DESC", + } + order_clause = _SORT_MAP.get(sort or "", "requested_at DESC") + + def operation(conn: sqlite3.Connection) -> tuple[list[RequestHistoryRecord], int]: + params: tuple[object, ...] + where_clause = "" + if status_filter: + where_clause = "WHERE status = ?" + params = (status_filter,) + else: + params = () + + total_row = conn.execute( + f"SELECT COUNT(*) AS count FROM request_history {where_clause}", + params, + ).fetchone() + rows = conn.execute( + f"SELECT * FROM request_history {where_clause} ORDER BY {order_clause} LIMIT ? OFFSET ?", + params + (safe_page_size, offset), + ).fetchall() + records = [record for row in rows if (record := self._row_to_record(row)) is not None] + total = int(total_row["count"] if total_row is not None else 0) + return records, total + + return await self._read(operation) + + async def async_update_status( + self, + musicbrainz_id: str, + status: str, + completed_at: str | None = None, + ) -> None: + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> None: + if status in self._ACTIVE_STATUSES and completed_at is None: + conn.execute( + "UPDATE request_history SET status = ?, completed_at = NULL WHERE musicbrainz_id_lower = ?", + (status, normalized_mbid), + ) + return + + conn.execute( + "UPDATE request_history SET status = ?, completed_at = COALESCE(?, completed_at) WHERE musicbrainz_id_lower = ?", + (status, completed_at, normalized_mbid), + ) + + await self._write(operation) + + async def async_update_cover_url(self, musicbrainz_id: str, cover_url: str) -> None: + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + "UPDATE request_history SET cover_url = ? WHERE musicbrainz_id_lower = ?", + (cover_url, normalized_mbid), + ) + + await self._write(operation) + + async def async_update_lidarr_album_id(self, musicbrainz_id: str, lidarr_album_id: int) -> None: + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + "UPDATE request_history SET lidarr_album_id = ? WHERE musicbrainz_id_lower = ?", + (lidarr_album_id, normalized_mbid), + ) + + await self._write(operation) + + async def async_delete_record(self, musicbrainz_id: str) -> bool: + normalized_mbid = musicbrainz_id.lower() + + def operation(conn: sqlite3.Connection) -> bool: + cursor = conn.execute( + "DELETE FROM request_history WHERE musicbrainz_id_lower = ?", + (normalized_mbid,), + ) + return cursor.rowcount > 0 + + return await self._write(operation) + + async def prune_old_terminal_requests(self, days: int) -> int: + """Delete terminal requests older than `days` days. Active requests are never touched.""" + import time as _time + from datetime import timezone + cutoff_iso = datetime.fromtimestamp(_time.time() - days * 86400, tz=timezone.utc).isoformat() + terminal_statuses = ("imported", "failed", "cancelled", "incomplete") + + def operation(conn: sqlite3.Connection) -> int: + cursor = conn.execute( + f"DELETE FROM request_history WHERE status IN ({','.join('?' for _ in terminal_statuses)}) " + "AND COALESCE(completed_at, requested_at) < ?", + (*terminal_statuses, cutoff_iso), + ) + return cursor.rowcount + + return await self._write(operation) \ No newline at end of file diff --git a/backend/infrastructure/persistence/sync_state_store.py b/backend/infrastructure/persistence/sync_state_store.py new file mode 100644 index 0000000..54c65ae --- /dev/null +++ b/backend/infrastructure/persistence/sync_state_store.py @@ -0,0 +1,99 @@ +"""Domain 5 — Sync lifecycle persistence.""" + +import logging +import sqlite3 +import time +from typing import Any + +from infrastructure.persistence._database import ( + PersistenceBase, + _decode_json, + _encode_json, + _normalize, +) +from infrastructure.serialization import to_jsonable + +logger = logging.getLogger(__name__) + + +class SyncStateStore(PersistenceBase): + """Owns tables: ``sync_state``, ``processed_items``.""" + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS processed_items ( + item_type TEXT NOT NULL, + mbid_lower TEXT NOT NULL, + mbid TEXT NOT NULL, + PRIMARY KEY (item_type, mbid_lower) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sync_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + state_json TEXT NOT NULL, + updated_at REAL NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() + + async def save_sync_state(self, **state: Any) -> None: + payload = to_jsonable(state) + now = time.time() + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO sync_state (singleton, state_json, updated_at) + VALUES (1, ?, ?) + ON CONFLICT(singleton) DO UPDATE SET + state_json = excluded.state_json, + updated_at = excluded.updated_at + """, + (_encode_json(payload), now), + ) + + await self._write(operation) + + async def get_sync_state(self) -> dict[str, Any] | None: + def operation(conn: sqlite3.Connection) -> dict[str, Any] | None: + row = conn.execute("SELECT state_json FROM sync_state WHERE singleton = 1").fetchone() + if row is None: + return None + payload = _decode_json(row["state_json"]) + return payload if isinstance(payload, dict) else None + + return await self._read(operation) + + async def clear_sync_state(self) -> None: + await self._write(lambda conn: conn.execute("DELETE FROM sync_state WHERE singleton = 1")) + + async def get_processed_items(self, item_type: str) -> set[str]: + def operation(conn: sqlite3.Connection) -> set[str]: + rows = conn.execute( + "SELECT mbid FROM processed_items WHERE item_type = ?", + (item_type,), + ).fetchall() + return {str(row["mbid"]) for row in rows if row["mbid"]} + + return await self._read(operation) + + async def mark_items_processed_batch(self, item_type: str, mbids: list[str]) -> None: + normalized = [mbid for mbid in mbids if isinstance(mbid, str) and mbid] + + def operation(conn: sqlite3.Connection) -> None: + for mbid in normalized: + conn.execute( + "INSERT OR REPLACE INTO processed_items (item_type, mbid_lower, mbid) VALUES (?, ?, ?)", + (item_type, _normalize(mbid), mbid), + ) + + await self._write(operation) diff --git a/backend/infrastructure/persistence/youtube_store.py b/backend/infrastructure/persistence/youtube_store.py new file mode 100644 index 0000000..0faa5a0 --- /dev/null +++ b/backend/infrastructure/persistence/youtube_store.py @@ -0,0 +1,408 @@ +"""Domain 3 — YouTube link persistence.""" + +import logging +import sqlite3 +from typing import Any + +from infrastructure.persistence._database import PersistenceBase +from infrastructure.serialization import to_jsonable + +logger = logging.getLogger(__name__) + + +class YouTubeStore(PersistenceBase): + """Owns tables: ``youtube_links``, ``youtube_track_links``.""" + + def _ensure_tables(self) -> None: + conn = self._connect() + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS youtube_links ( + album_id TEXT PRIMARY KEY, + video_id TEXT, + album_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + embed_url TEXT, + cover_url TEXT, + created_at TEXT NOT NULL, + is_manual INTEGER DEFAULT 0, + track_count INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS youtube_track_links ( + album_id TEXT NOT NULL, + track_number INTEGER NOT NULL, + disc_number INTEGER NOT NULL DEFAULT 1, + album_name TEXT NOT NULL, + track_name TEXT NOT NULL, + video_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + embed_url TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (album_id, disc_number, track_number) + ) + """ + ) + self._migrate_youtube_links(conn) + self._migrate_youtube_track_links(conn) + conn.commit() + finally: + conn.close() + + def _migrate_youtube_links(self, conn: sqlite3.Connection) -> None: + rows = conn.execute("PRAGMA table_info(youtube_links)").fetchall() + + if not rows: + # Orphan recovery: main table missing but _old exists from interrupted migration + old_exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='youtube_links_old'" + ).fetchone() + if old_exists: + conn.execute("ALTER TABLE youtube_links_old RENAME TO youtube_links") + rows = conn.execute("PRAGMA table_info(youtube_links)").fetchall() + else: + return + + col_names = {row["name"] for row in rows} + video_notnull = any(row["name"] == "video_id" and row["notnull"] for row in rows) + needs_track_count = "track_count" not in col_names + + if not video_notnull and not needs_track_count: + return + + if video_notnull: + conn.execute("ALTER TABLE youtube_links RENAME TO youtube_links_old") + conn.execute( + """ + CREATE TABLE youtube_links ( + album_id TEXT PRIMARY KEY, + video_id TEXT, + album_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + embed_url TEXT, + cover_url TEXT, + created_at TEXT NOT NULL, + is_manual INTEGER DEFAULT 0, + track_count INTEGER DEFAULT 0 + ) + """ + ) + conn.execute( + """ + INSERT INTO youtube_links + (album_id, video_id, album_name, artist_name, embed_url, cover_url, created_at, is_manual, track_count) + SELECT album_id, video_id, album_name, artist_name, embed_url, cover_url, created_at, is_manual, 0 + FROM youtube_links_old + """ + ) + conn.execute("DROP TABLE youtube_links_old") + elif needs_track_count: + conn.execute("ALTER TABLE youtube_links ADD COLUMN track_count INTEGER DEFAULT 0") + + conn.execute( + """ + UPDATE youtube_links + SET track_count = ( + SELECT COUNT(*) FROM youtube_track_links + WHERE youtube_track_links.album_id = youtube_links.album_id + ) + WHERE EXISTS ( + SELECT 1 FROM youtube_track_links + WHERE youtube_track_links.album_id = youtube_links.album_id + ) + """ + ) + + def _migrate_youtube_track_links(self, conn: sqlite3.Connection) -> None: + rows = conn.execute("PRAGMA table_info(youtube_track_links)").fetchall() + if not rows: + # Orphan recovery: main table missing but _old exists from interrupted migration + old_exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='youtube_track_links_old'" + ).fetchone() + if old_exists: + conn.execute("ALTER TABLE youtube_track_links_old RENAME TO youtube_track_links") + rows = conn.execute("PRAGMA table_info(youtube_track_links)").fetchall() + else: + return + + col_names = {row["name"] for row in rows} + if "disc_number" in col_names: + return + + conn.execute("ALTER TABLE youtube_track_links RENAME TO youtube_track_links_old") + conn.execute( + """ + CREATE TABLE youtube_track_links ( + album_id TEXT NOT NULL, + track_number INTEGER NOT NULL, + disc_number INTEGER NOT NULL DEFAULT 1, + album_name TEXT NOT NULL, + track_name TEXT NOT NULL, + video_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + embed_url TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (album_id, disc_number, track_number) + ) + """ + ) + conn.execute( + """ + INSERT INTO youtube_track_links ( + album_id, track_number, disc_number, album_name, track_name, + video_id, artist_name, embed_url, created_at + ) + SELECT album_id, track_number, 1, album_name, track_name, + video_id, artist_name, embed_url, created_at + FROM youtube_track_links_old + """ + ) + conn.execute("DROP TABLE youtube_track_links_old") + + async def save_youtube_link(self, **payload: Any) -> None: + builtins = to_jsonable(payload) + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO youtube_links ( + album_id, video_id, album_name, artist_name, + embed_url, cover_url, created_at, is_manual + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(album_id) DO UPDATE SET + video_id = excluded.video_id, + album_name = excluded.album_name, + artist_name = excluded.artist_name, + embed_url = excluded.embed_url, + cover_url = excluded.cover_url, + created_at = excluded.created_at, + is_manual = excluded.is_manual + """, + ( + builtins["album_id"], + builtins["video_id"], + builtins["album_name"], + builtins["artist_name"], + builtins["embed_url"], + builtins.get("cover_url"), + builtins["created_at"], + 1 if bool(builtins.get("is_manual")) else 0, + ), + ) + + await self._write(operation) + + async def ensure_youtube_album_entry( + self, + album_id: str, + album_name: str, + artist_name: str, + cover_url: str | None, + created_at: str, + ) -> None: + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO youtube_links ( + album_id, video_id, album_name, artist_name, + embed_url, cover_url, created_at, is_manual, track_count + ) VALUES ( + ?, NULL, ?, ?, NULL, ?, ?, 0, + (SELECT COUNT(*) FROM youtube_track_links WHERE album_id = ?) + ) + ON CONFLICT(album_id) DO UPDATE SET + track_count = (SELECT COUNT(*) FROM youtube_track_links WHERE album_id = excluded.album_id), + cover_url = COALESCE(excluded.cover_url, youtube_links.cover_url) + """, + (album_id, album_name, artist_name, cover_url, created_at, album_id), + ) + + await self._write(operation) + + async def get_youtube_link(self, album_id: str) -> dict[str, Any] | None: + def operation(conn: sqlite3.Connection) -> dict[str, Any] | None: + row = conn.execute("SELECT * FROM youtube_links WHERE album_id = ?", (album_id,)).fetchone() + if row is None: + return None + return { + "album_id": row["album_id"], + "video_id": row["video_id"], + "album_name": row["album_name"], + "artist_name": row["artist_name"], + "embed_url": row["embed_url"], + "cover_url": row["cover_url"], + "created_at": row["created_at"], + "is_manual": bool(row["is_manual"]), + "track_count": row["track_count"] or 0, + } + + return await self._read(operation) + + async def get_all_youtube_links(self) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + """ + SELECT yl.*, + (SELECT COUNT(*) FROM youtube_track_links WHERE album_id = yl.album_id) AS live_track_count + FROM youtube_links yl + ORDER BY yl.created_at DESC + """ + ).fetchall() + return [ + { + "album_id": row["album_id"], + "video_id": row["video_id"], + "album_name": row["album_name"], + "artist_name": row["artist_name"], + "embed_url": row["embed_url"], + "cover_url": row["cover_url"], + "created_at": row["created_at"], + "is_manual": bool(row["is_manual"]), + "track_count": row["live_track_count"], + } + for row in rows + ] + + return await self._read(operation) + + async def delete_youtube_link(self, album_id: str) -> None: + def operation(conn: sqlite3.Connection) -> None: + conn.execute("DELETE FROM youtube_track_links WHERE album_id = ?", (album_id,)) + conn.execute("DELETE FROM youtube_links WHERE album_id = ?", (album_id,)) + await self._write(operation) + + async def delete_orphaned_track_links(self) -> int: + def operation(conn: sqlite3.Connection) -> int: + cursor = conn.execute( + "DELETE FROM youtube_track_links " + "WHERE album_id NOT IN (SELECT album_id FROM youtube_links) " + "AND datetime(created_at) < datetime('now', '-1 hour')" + ) + return cursor.rowcount + return await self._write(operation) + + async def save_youtube_track_link(self, **payload: Any) -> None: + builtins = to_jsonable(payload) + + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + INSERT INTO youtube_track_links ( + album_id, track_number, disc_number, album_name, track_name, + video_id, artist_name, embed_url, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(album_id, disc_number, track_number) DO UPDATE SET + album_name = excluded.album_name, + track_name = excluded.track_name, + video_id = excluded.video_id, + artist_name = excluded.artist_name, + embed_url = excluded.embed_url, + created_at = excluded.created_at + """, + ( + builtins["album_id"], + int(builtins["track_number"]), + int(builtins.get("disc_number", 1)), + builtins["album_name"], + builtins["track_name"], + builtins["video_id"], + builtins["artist_name"], + builtins["embed_url"], + builtins["created_at"], + ), + ) + + await self._write(operation) + + async def save_youtube_track_links_batch(self, album_id: str, payloads: list[dict[str, Any]]) -> None: + normalized_payloads = [payload for payload in payloads if isinstance(payload, dict)] + + def operation(conn: sqlite3.Connection) -> None: + for payload in normalized_payloads: + conn.execute( + """ + INSERT INTO youtube_track_links ( + album_id, track_number, disc_number, album_name, track_name, + video_id, artist_name, embed_url, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(album_id, disc_number, track_number) DO UPDATE SET + album_name = excluded.album_name, + track_name = excluded.track_name, + video_id = excluded.video_id, + artist_name = excluded.artist_name, + embed_url = excluded.embed_url, + created_at = excluded.created_at + """, + ( + album_id, + int(payload["track_number"]), + int(payload.get("disc_number", 1)), + payload["album_name"], + payload["track_name"], + payload["video_id"], + payload["artist_name"], + payload["embed_url"], + payload["created_at"], + ), + ) + + await self._write(operation) + + async def get_youtube_track_links(self, album_id: str) -> list[dict[str, Any]]: + def operation(conn: sqlite3.Connection) -> list[dict[str, Any]]: + rows = conn.execute( + "SELECT * FROM youtube_track_links WHERE album_id = ? ORDER BY disc_number ASC, track_number ASC", + (album_id,), + ).fetchall() + return [ + { + "album_id": row["album_id"], + "track_number": row["track_number"], + "disc_number": row["disc_number"], + "album_name": row["album_name"], + "track_name": row["track_name"], + "video_id": row["video_id"], + "artist_name": row["artist_name"], + "embed_url": row["embed_url"], + "created_at": row["created_at"], + } + for row in rows + ] + + return await self._read(operation) + + async def count_youtube_track_links(self, album_id: str) -> int: + def operation(conn: sqlite3.Connection) -> int: + row = conn.execute( + "SELECT COUNT(*) AS cnt FROM youtube_track_links WHERE album_id = ?", + (album_id,), + ).fetchone() + return int(row["cnt"]) if row else 0 + + return await self._read(operation) + + async def update_youtube_link_track_count(self, album_id: str) -> None: + def operation(conn: sqlite3.Connection) -> None: + conn.execute( + """ + UPDATE youtube_links + SET track_count = (SELECT COUNT(*) FROM youtube_track_links WHERE album_id = ?) + WHERE album_id = ? + """, + (album_id, album_id), + ) + + await self._write(operation) + + async def delete_youtube_track_link(self, album_id: str, disc_number: int, track_number: int) -> None: + await self._write( + lambda conn: conn.execute( + "DELETE FROM youtube_track_links WHERE album_id = ? AND disc_number = ? AND track_number = ?", + (album_id, disc_number, track_number), + ) + ) diff --git a/backend/infrastructure/queue/__init__.py b/backend/infrastructure/queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/infrastructure/queue/priority_queue.py b/backend/infrastructure/queue/priority_queue.py new file mode 100644 index 0000000..36d157c --- /dev/null +++ b/backend/infrastructure/queue/priority_queue.py @@ -0,0 +1,101 @@ +import asyncio +import logging +from enum import IntEnum +from typing import Optional +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class RequestPriority(IntEnum): + USER_INITIATED = 0 + IMAGE_FETCH = 1 + PREFETCH_VISIBLE = 2 + BACKGROUND_SYNC = 3 + OPPORTUNISTIC = 4 + + +class PriorityQueueManager: + _instance: Optional['PriorityQueueManager'] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._user_semaphore = asyncio.Semaphore(20) + self._image_semaphore = asyncio.Semaphore(10) + self._background_semaphore = asyncio.Semaphore(5) + self._user_activity_flag = False + self._user_activity_timestamp = 0.0 + self._user_activity_timeout = 2.0 + self._user_activity_event = asyncio.Event() + self._background_waiters = 0 + self._initialized = True + + logger.info("PriorityQueueManager initialized: user=20, image=10, background=5") + + async def acquire_slot(self, priority: RequestPriority) -> asyncio.Semaphore: + if priority == RequestPriority.USER_INITIATED: + self._mark_user_activity() + return self._user_semaphore + elif priority == RequestPriority.IMAGE_FETCH: + return self._image_semaphore + else: + await self._wait_for_user_inactivity() + return self._background_semaphore + + def _mark_user_activity(self): + self._user_activity_flag = True + self._user_activity_timestamp = datetime.now().timestamp() + self._user_activity_event.clear() + + async def _wait_for_user_inactivity(self): + self._background_waiters += 1 + try: + while self._user_activity_flag: + current = datetime.now().timestamp() + elapsed = current - self._user_activity_timestamp + + if elapsed >= self._user_activity_timeout: + self._user_activity_flag = False + self._user_activity_event.set() + break + + wait_time = self._user_activity_timeout - elapsed + try: + await asyncio.wait_for( + self._user_activity_event.wait(), + timeout=wait_time + 0.1 + ) + except asyncio.TimeoutError: + pass + finally: + self._background_waiters -= 1 + + def mark_user_activity(self): + self._mark_user_activity() + + def is_user_active(self) -> bool: + current = datetime.now().timestamp() + if current - self._user_activity_timestamp > self._user_activity_timeout: + self._user_activity_flag = False + return self._user_activity_flag + + def get_stats(self) -> dict: + return { + 'user_slots_available': self._user_semaphore._value, + 'image_slots_available': self._image_semaphore._value, + 'background_slots_available': self._background_semaphore._value, + 'user_active': self.is_user_active(), + 'background_waiters': self._background_waiters + } + + +def get_priority_queue() -> PriorityQueueManager: + return PriorityQueueManager() diff --git a/backend/infrastructure/queue/queue_store.py b/backend/infrastructure/queue/queue_store.py new file mode 100644 index 0000000..2d7643a --- /dev/null +++ b/backend/infrastructure/queue/queue_store.py @@ -0,0 +1,194 @@ +import logging +import sqlite3 +import threading +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class QueueStore: + def __init__(self, db_path: Path = Path("/app/cache/queue.db")) -> None: + self.db_path = Path(db_path) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock = threading.Lock() + self._ensure_tables() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + def _ensure_tables(self) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS pending_jobs ( + id TEXT PRIMARY KEY, + album_mbid TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + status TEXT NOT NULL DEFAULT 'pending' + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_mbid_active + ON pending_jobs(album_mbid) WHERE status = 'pending'; + + CREATE TABLE IF NOT EXISTS dead_letters ( + id TEXT PRIMARY KEY, + album_mbid TEXT NOT NULL, + error_message TEXT NOT NULL DEFAULT '', + retry_count INTEGER NOT NULL DEFAULT 0, + max_retries INTEGER NOT NULL DEFAULT 3, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_attempted_at TEXT NOT NULL DEFAULT (datetime('now')), + status TEXT NOT NULL DEFAULT 'pending' + ); + """) + conn.commit() + finally: + conn.close() + + def enqueue(self, job_id: str, album_mbid: str) -> bool: + """Persist a job. Returns True if inserted, False if duplicate MBID already pending.""" + with self._write_lock: + conn = self._connect() + try: + cursor = conn.execute( + "INSERT OR IGNORE INTO pending_jobs (id, album_mbid) VALUES (?, ?)", + (job_id, album_mbid), + ) + conn.commit() + return cursor.rowcount > 0 + finally: + conn.close() + + def dequeue(self, job_id: str) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.execute("DELETE FROM pending_jobs WHERE id = ?", (job_id,)) + conn.commit() + finally: + conn.close() + + def mark_processing(self, job_id: str) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.execute( + "UPDATE pending_jobs SET status = 'processing' WHERE id = ?", + (job_id,), + ) + conn.commit() + finally: + conn.close() + + def has_pending_mbid(self, album_mbid: str) -> bool: + """Check if a pending job already exists for this album MBID.""" + conn = self._connect() + try: + row = conn.execute( + "SELECT 1 FROM pending_jobs WHERE album_mbid = ? AND status = 'pending' LIMIT 1", + (album_mbid,), + ).fetchone() + return row is not None + finally: + conn.close() + + def get_pending(self) -> list[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + "SELECT * FROM pending_jobs WHERE status = 'pending' ORDER BY created_at" + ).fetchall() + finally: + conn.close() + + def get_all(self) -> list[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + "SELECT * FROM pending_jobs ORDER BY created_at" + ).fetchall() + finally: + conn.close() + + def reset_processing(self) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.execute( + "UPDATE pending_jobs SET status = 'pending' WHERE status = 'processing'" + ) + conn.commit() + finally: + conn.close() + + def add_dead_letter( + self, + job_id: str, + album_mbid: str, + error_message: str, + retry_count: int, + max_retries: int, + ) -> None: + status = "exhausted" if retry_count >= max_retries else "pending" + with self._write_lock: + conn = self._connect() + try: + conn.execute( + """INSERT OR REPLACE INTO dead_letters + (id, album_mbid, error_message, retry_count, max_retries, last_attempted_at, status) + VALUES (?, ?, ?, ?, ?, datetime('now'), ?)""", + (job_id, album_mbid, error_message, retry_count, max_retries, status), + ) + conn.commit() + finally: + conn.close() + + def get_retryable_dead_letters(self) -> list[sqlite3.Row]: + conn = self._connect() + try: + return conn.execute( + "SELECT * FROM dead_letters WHERE status = 'pending' ORDER BY last_attempted_at" + ).fetchall() + finally: + conn.close() + + def remove_dead_letter(self, job_id: str) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.execute("DELETE FROM dead_letters WHERE id = ?", (job_id,)) + conn.commit() + finally: + conn.close() + + def update_dead_letter_attempt( + self, job_id: str, error_message: str, retry_count: int + ) -> None: + with self._write_lock: + conn = self._connect() + try: + conn.execute( + """UPDATE dead_letters + SET error_message = ?, + retry_count = ?, + last_attempted_at = datetime('now'), + status = CASE WHEN ? >= max_retries THEN 'exhausted' ELSE 'pending' END + WHERE id = ?""", + (error_message, retry_count, retry_count, job_id), + ) + conn.commit() + finally: + conn.close() + + def get_dead_letter_count(self) -> int: + conn = self._connect() + try: + row = conn.execute("SELECT COUNT(*) FROM dead_letters").fetchone() + return row[0] if row else 0 + finally: + conn.close() diff --git a/backend/infrastructure/queue/request_queue.py b/backend/infrastructure/queue/request_queue.py new file mode 100644 index 0000000..f890a05 --- /dev/null +++ b/backend/infrastructure/queue/request_queue.py @@ -0,0 +1,204 @@ +import asyncio +import logging +import uuid +from typing import Any, Callable, Optional, TYPE_CHECKING +from abc import ABC, abstractmethod + +if TYPE_CHECKING: + from infrastructure.queue.queue_store import QueueStore + +logger = logging.getLogger(__name__) + + +class QueueInterface(ABC): + @abstractmethod + async def add(self, item: Any) -> Any: + pass + + @abstractmethod + async def start(self) -> None: + pass + + @abstractmethod + async def stop(self) -> None: + pass + + @abstractmethod + def get_status(self) -> dict: + pass + + +class QueuedRequest: + __slots__ = ('album_mbid', 'future', 'job_id', 'retry_count', 'recovered') + + def __init__( + self, + album_mbid: str, + future: Optional[asyncio.Future] = None, + job_id: str = "", + recovered: bool = False, + ): + self.album_mbid = album_mbid + self.future: asyncio.Future = future if future is not None else asyncio.get_event_loop().create_future() + self.job_id = job_id or str(uuid.uuid4()) + self.retry_count = 0 + self.recovered = recovered + + +class RequestQueue(QueueInterface): + def __init__( + self, + processor: Callable, + maxsize: int = 200, + store: "QueueStore | None" = None, + max_retries: int = 3, + ): + self._queue: asyncio.Queue = asyncio.Queue(maxsize=maxsize) + self._processor = processor + self._processor_task: Optional[asyncio.Task] = None + self._processing = False + self._maxsize = maxsize + self._store = store + self._max_retries = max_retries + + async def add(self, album_mbid: str) -> dict: + await self.start() + + request = QueuedRequest(album_mbid) + await self._queue.put(request) + if self._store: + self._store.enqueue(request.job_id, album_mbid) + + result = await request.future + return result + + async def start(self) -> None: + if self._processor_task is None or self._processor_task.done(): + self._processor_task = asyncio.create_task(self._process_queue()) + logger.info("Queue processor started") + self._recover_pending() + + async def stop(self) -> None: + if self._processor_task and not self._processor_task.done(): + await self.drain() + + self._processor_task.cancel() + try: + await self._processor_task + except asyncio.CancelledError: + pass + self._processor_task = None + logger.info("Queue processor stopped") + + async def drain(self, timeout: float = 30.0) -> None: + try: + await asyncio.wait_for(self._queue.join(), timeout=timeout) + logger.info("Queue drained successfully") + except asyncio.TimeoutError: + remaining = self._queue.qsize() + logger.warning("Queue drain timeout: %d items remaining", remaining) + + def get_status(self) -> dict: + status = { + "queue_size": self._queue.qsize(), + "max_size": self._maxsize, + "processing": self._processing, + } + if self._store: + status["dead_letter_count"] = self._store.get_dead_letter_count() + status["persisted_pending"] = len(self._store.get_all()) + return status + + def _recover_pending(self) -> None: + if not self._store: + return + self._store.reset_processing() + pending = self._store.get_pending() + recovered = 0 + for row in pending: + request = QueuedRequest( + album_mbid=row["album_mbid"], + job_id=row["id"], + recovered=True, + ) + try: + self._queue.put_nowait(request) + recovered += 1 + except asyncio.QueueFull: + logger.warning("Queue full during recovery, %d items deferred to next restart", + len(pending) - recovered) + break + if recovered: + logger.info("Recovered %d pending jobs from store", recovered) + + self._retry_dead_letters() + + def _retry_dead_letters(self) -> None: + if not self._store: + return + retryable = self._store.get_retryable_dead_letters() + enqueued = 0 + for row in retryable: + if self._store.has_pending_mbid(row["album_mbid"]): + self._store.remove_dead_letter(row["id"]) + continue + self._store.remove_dead_letter(row["id"]) + inserted = self._store.enqueue(row["id"], row["album_mbid"]) + if not inserted: + continue + request = QueuedRequest( + album_mbid=row["album_mbid"], + job_id=row["id"], + recovered=True, + ) + request.retry_count = row["retry_count"] + try: + self._queue.put_nowait(request) + enqueued += 1 + except asyncio.QueueFull: + logger.warning("Queue full during dead-letter retry, remaining deferred") + break + if enqueued: + logger.info("Re-enqueued %d dead-letter jobs for retry", enqueued) + + async def _process_queue(self) -> None: + while True: + try: + request: QueuedRequest = await self._queue.get() + self._processing = True + + if self._store: + self._store.mark_processing(request.job_id) + + try: + if request.recovered: + logger.info("Processing recovered job %s for album %s", request.job_id[:8], request.album_mbid[:8]) + result = await self._processor(request.album_mbid) + if not request.future.done(): + request.future.set_result(result) + if self._store: + self._store.dequeue(request.job_id) + except Exception as e: # noqa: BLE001 + logger.error("Error processing request for %s (attempt %d/%d): %s", + request.album_mbid[:8], request.retry_count + 1, self._max_retries, e) + if not request.future.done(): + request.future.set_exception(e) + if self._store: + self._store.dequeue(request.job_id) + self._store.add_dead_letter( + job_id=request.job_id, + album_mbid=request.album_mbid, + error_message=str(e), + retry_count=request.retry_count + 1, + max_retries=self._max_retries, + ) + finally: + self._queue.task_done() + self._processing = False + + except asyncio.CancelledError: + logger.info("Queue processor cancelled") + break + except Exception as e: # noqa: BLE001 + logger.error("Queue processor error: %s", e) + self._processing = False diff --git a/backend/infrastructure/resilience/__init__.py b/backend/infrastructure/resilience/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/infrastructure/resilience/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/infrastructure/resilience/rate_limiter.py b/backend/infrastructure/resilience/rate_limiter.py new file mode 100644 index 0000000..3ae3484 --- /dev/null +++ b/backend/infrastructure/resilience/rate_limiter.py @@ -0,0 +1,77 @@ +import asyncio +import math +import time +from typing import Optional + +EPSILON = 1e-9 + + +class TokenBucketRateLimiter: + + def __init__(self, rate: float, capacity: Optional[int] = None): + self.rate = rate + self.capacity = capacity or int(rate * 2) + self._tokens = float(self.capacity) + self._last_update = time.monotonic() + self._lock = asyncio.Lock() + + async def acquire(self, tokens: int = 1) -> None: + if tokens > self.capacity: + raise ValueError( + f"Cannot acquire {tokens} tokens (capacity: {self.capacity}). " + f"Request would wait indefinitely." + ) + + while True: + async with self._lock: + now = time.monotonic() + elapsed = now - self._last_update + self._tokens = min(self.capacity, self._tokens + elapsed * self.rate) + self._last_update = now + + if self._tokens >= tokens - EPSILON: + self._tokens -= tokens + return + + tokens_needed = tokens - self._tokens + wait_time = tokens_needed / self.rate + + await asyncio.sleep(wait_time) + + async def try_acquire(self, tokens: int = 1) -> bool: + async with self._lock: + now = time.monotonic() + elapsed = now - self._last_update + self._tokens = min(self.capacity, self._tokens + elapsed * self.rate) + self._last_update = now + + if self._tokens >= tokens - EPSILON: + self._tokens -= tokens + return True + return False + + def _refresh_tokens(self) -> None: + now = time.monotonic() + elapsed = now - self._last_update + self._tokens = min(self.capacity, self._tokens + elapsed * self.rate) + self._last_update = now + + @property + def remaining(self) -> int: + self._refresh_tokens() + return max(0, int(self._tokens)) + + def retry_after(self, tokens: int = 1) -> float: + self._refresh_tokens() + if self._tokens >= tokens - EPSILON: + return 0.0 + deficit = tokens - self._tokens + return math.ceil(deficit / self.rate) + + def reset(self) -> None: + self._tokens = float(self.capacity) + self._last_update = time.monotonic() + + def update_capacity(self, new_capacity: int) -> None: + self.capacity = new_capacity + self._tokens = min(self._tokens, float(new_capacity)) diff --git a/backend/infrastructure/resilience/retry.py b/backend/infrastructure/resilience/retry.py new file mode 100644 index 0000000..a75b3d7 --- /dev/null +++ b/backend/infrastructure/resilience/retry.py @@ -0,0 +1,301 @@ +import asyncio +import logging +import random +import time +from enum import Enum +from functools import wraps +from typing import Awaitable, Callable, TypeVar, ParamSpec, Optional + +logger = logging.getLogger(__name__) + +P = ParamSpec('P') +T = TypeVar('T') + + +class CircuitState(Enum): + CLOSED = "closed" + OPEN = "open" + HALF_OPEN = "half_open" + + +CircuitStateChangeCallback = Callable[["CircuitBreaker", CircuitState, CircuitState, str], None] + + +class CircuitBreaker: + + def __init__( + self, + failure_threshold: int = 5, + success_threshold: int = 2, + timeout: float = 60.0, + name: str = "default", + on_state_change: CircuitStateChangeCallback | None = None, + ): + self.failure_threshold = failure_threshold + self.success_threshold = success_threshold + self.timeout = timeout + self.name = name + self._on_state_change = on_state_change + self._lock = asyncio.Lock() + + self.failure_count = 0 + self.success_count = 0 + self.last_failure_time: float = 0 + self.state = CircuitState.CLOSED + + def _notify_state_change( + self, + previous_state: CircuitState, + new_state: CircuitState, + reason: str, + ) -> None: + if previous_state == new_state or self._on_state_change is None: + return + + try: + self._on_state_change(self, previous_state, new_state, reason) + except Exception: + logger.exception( + "Circuit breaker '%s' state change callback failed", + self.name, + ) + + def is_open(self) -> bool: + if self.state == CircuitState.OPEN: + if time.time() - self.last_failure_time > self.timeout: + logger.info( + "Circuit breaker '%s' transitioning to HALF_OPEN", + self.name, + ) + previous_state = self.state + self.state = CircuitState.HALF_OPEN + self.success_count = 0 + self._notify_state_change(previous_state, self.state, "timeout_elapsed") + return False + return True + return False + + def record_success(self): + if self.state == CircuitState.HALF_OPEN: + self.success_count += 1 + if self.success_count >= self.success_threshold: + logger.info( + "Circuit breaker '%s' closing after %d successes", + self.name, + self.success_count, + ) + previous_state = self.state + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.success_count = 0 + self._notify_state_change(previous_state, self.state, "success_threshold_reached") + elif self.state == CircuitState.CLOSED: + self.failure_count = 0 + + def record_failure(self): + self.last_failure_time = time.time() + + if self.state == CircuitState.HALF_OPEN: + logger.warning( + "Circuit breaker '%s' reopening after failure in HALF_OPEN", + self.name, + ) + previous_state = self.state + self.state = CircuitState.OPEN + self.failure_count = 0 + self.success_count = 0 + self._notify_state_change(previous_state, self.state, "half_open_failure") + elif self.state == CircuitState.CLOSED: + self.failure_count += 1 + if self.failure_count >= self.failure_threshold: + logger.error( + "Circuit breaker '%s' opening after %d failures", + self.name, + self.failure_count, + ) + previous_state = self.state + self.state = CircuitState.OPEN + self._notify_state_change(previous_state, self.state, "failure_threshold_reached") + + def get_state(self) -> dict: + return { + "name": self.name, + "state": self.state.value, + "failure_count": self.failure_count, + "success_count": self.success_count, + "last_failure_time": self.last_failure_time + } + + def reset(self): + logger.info("Circuit breaker '%s' manually reset", self.name) + previous_state = self.state + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.success_count = 0 + self.last_failure_time = 0 + self._notify_state_change(previous_state, self.state, "manual_reset") + + async def arecord_success(self): + async with self._lock: + self.record_success() + + async def arecord_failure(self): + async with self._lock: + self.record_failure() + + async def atry_transition(self): + """Acquire lock and attempt OPEN→HALF_OPEN transition if timeout elapsed.""" + if self.state != CircuitState.OPEN: + return + async with self._lock: + if self.state == CircuitState.OPEN and time.time() - self.last_failure_time > self.timeout: + logger.info( + "Circuit breaker '%s' transitioning to HALF_OPEN (locked)", + self.name, + ) + previous_state = self.state + self.state = CircuitState.HALF_OPEN + self.success_count = 0 + self._notify_state_change(previous_state, self.state, "timeout_elapsed") + + +class CircuitOpenError(Exception): + pass + + +def _get_retry_after_seconds(exception: Exception) -> Optional[float]: + retry_after = getattr(exception, "retry_after_seconds", None) + if retry_after is None: + return None + try: + retry_after_value = float(retry_after) + except (TypeError, ValueError): + return None + if retry_after_value <= 0: + return None + return retry_after_value + + +def with_retry( + max_attempts: int = 3, + base_delay: float = 1.0, + max_delay: float = 10.0, + exponential_base: float = 2.0, + jitter: bool = True, + circuit_breaker: Optional[CircuitBreaker] = None, + retriable_exceptions: tuple = (Exception,), + non_breaking_exceptions: tuple = (), +): + if max_attempts < 1: + raise ValueError("max_attempts must be >= 1") + + def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + service_name = circuit_breaker.name if circuit_breaker else "unknown" + func_name = func.__name__ + start_time = time.time() + + if circuit_breaker: + await circuit_breaker.atry_transition() + if circuit_breaker.is_open(): + error_msg = "Circuit breaker '%s' is OPEN" + logger.warning( + error_msg, + circuit_breaker.name, + extra={"service_name": service_name, "function": func_name} + ) + raise CircuitOpenError( + f"Circuit breaker '{circuit_breaker.name}' is OPEN" + ) + + last_exception = None + + for attempt in range(1, max_attempts + 1): + attempt_start = time.time() + try: + result = await func(*args, **kwargs) + + elapsed_ms = int((time.time() - attempt_start) * 1000) + + if circuit_breaker: + await circuit_breaker.arecord_success() + + if attempt > 1: + total_elapsed_ms = int((time.time() - start_time) * 1000) + logger.info( + "%s succeeded on attempt %d/%d", + func_name, + attempt, + max_attempts, + extra={ + "service_name": service_name, + "function": func_name, + "attempt": attempt, + "max_attempts": max_attempts, + "elapsed_ms": elapsed_ms, + "total_elapsed_ms": total_elapsed_ms, + } + ) + + return result + + except retriable_exceptions as e: + last_exception = e + elapsed_ms = int((time.time() - attempt_start) * 1000) + + is_non_breaking = isinstance(e, non_breaking_exceptions) if non_breaking_exceptions else False + if circuit_breaker and (not is_non_breaking or circuit_breaker.state == CircuitState.HALF_OPEN): + await circuit_breaker.arecord_failure() + + if attempt >= max_attempts: + total_elapsed_ms = int((time.time() - start_time) * 1000) + logger.error( + "%s failed after %d attempts: %s", + func_name, + max_attempts, + e, + extra={ + "service_name": service_name, + "function": func_name, + "attempt": attempt, + "max_attempts": max_attempts, + "elapsed_ms": elapsed_ms, + "total_elapsed_ms": total_elapsed_ms, + "error": str(e), + } + ) + break + + retry_after_override = _get_retry_after_seconds(e) + if retry_after_override is not None: + delay = retry_after_override + else: + delay = min(base_delay * (exponential_base ** (attempt - 1)), max_delay) + if jitter: + delay *= (0.5 + random.random()) + + logger.warning( + "%s attempt %d/%d failed: %s. Retrying in %.2fs...", + func_name, + attempt, + max_attempts, + e, + delay, + extra={ + "service_name": service_name, + "function": func_name, + "attempt": attempt, + "max_attempts": max_attempts, + "elapsed_ms": elapsed_ms, + "retry_delay_s": f"{delay:.2f}", + "error": str(e), + } + ) + + await asyncio.sleep(delay) + + raise last_exception + + return wrapper + return decorator diff --git a/backend/infrastructure/serialization.py b/backend/infrastructure/serialization.py new file mode 100644 index 0000000..213e5fa --- /dev/null +++ b/backend/infrastructure/serialization.py @@ -0,0 +1,19 @@ +from typing import Any + +import msgspec + + +def to_jsonable(value: Any) -> Any: + return msgspec.to_builtins(value) + + +def clone_with_updates(value: Any, updates: dict[str, Any]) -> Any: + if isinstance(value, msgspec.Struct): + return msgspec.structs.replace(value, **updates) + + if isinstance(value, dict): + cloned = dict(value) + cloned.update(updates) + return cloned + + raise TypeError(f"Unsupported model type for clone_with_updates: {type(value)!r}") diff --git a/backend/infrastructure/validators.py b/backend/infrastructure/validators.py new file mode 100644 index 0000000..07380be --- /dev/null +++ b/backend/infrastructure/validators.py @@ -0,0 +1,170 @@ +import ipaddress +import re +from typing import Optional +from urllib.parse import urlparse + +from core.exceptions import ValidationError as AppValidationError + +MBID_PATTERN = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) + +_ALLOWED_SERVICE_SCHEMES = frozenset({"http", "https"}) + + +def validate_service_url(url: str, label: str = "URL") -> str: + """Validate that a user-supplied service URL uses http or https only. + + Raises AppValidationError for empty, malformed, or non-HTTP URLs. + Returns the normalised URL on success. + """ + if not url or not isinstance(url, str): + raise AppValidationError(f"{label} must be a non-empty string") + + url = url.strip() + if not url: + raise AppValidationError(f"{label} must be a non-empty string") + + try: + parsed = urlparse(url) + except Exception: # noqa: BLE001 + raise AppValidationError(f"Invalid {label}: malformed URL") + + scheme = (parsed.scheme or "").lower() + if scheme not in _ALLOWED_SERVICE_SCHEMES: + raise AppValidationError( + f"Invalid {label}: only http:// and https:// schemes are allowed" + ) + + if not parsed.hostname: + raise AppValidationError(f"Invalid {label}: missing hostname") + + return url + +AUDIODB_ALLOWED_HOSTS: frozenset[str] = frozenset({ + "www.theaudiodb.com", + "theaudiodb.com", + "r2.theaudiodb.com", +}) + + +def validate_audiodb_image_url(url: str) -> bool: + """Validate that a URL points to a known AudioDB CDN host over HTTPS. + + Rejects non-HTTPS schemes, unknown hosts, and private/loopback/link-local targets. + """ + if not url or not isinstance(url, str): + return False + try: + parsed = urlparse(url) + except Exception: # noqa: BLE001 + return False + + if parsed.scheme != "https": + return False + + hostname = (parsed.hostname or "").lower().strip() + if not hostname: + return False + + if hostname not in AUDIODB_ALLOWED_HOSTS: + return False + + try: + addr = ipaddress.ip_address(hostname) + if addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved: + return False + except ValueError: + pass + + return True + + +def is_valid_mbid(mbid: Optional[str]) -> bool: + if not mbid or not isinstance(mbid, str): + return False + + mbid = mbid.strip() + + if mbid.startswith('unknown_'): + return False + + return bool(MBID_PATTERN.match(mbid)) + + +def validate_mbid(mbid: Optional[str], entity_type: str = "entity") -> str: + if not mbid or not isinstance(mbid, str): + raise ValueError(f"Invalid {entity_type} MBID: must be a non-empty string") + + mbid = mbid.strip() + + if not mbid: + raise ValueError(f"Invalid {entity_type} MBID: empty string") + + if mbid.startswith('unknown_'): + raise ValueError(f"Cannot process unknown {entity_type} MBID: {mbid}") + + if not MBID_PATTERN.match(mbid): + raise ValueError(f"Invalid {entity_type} MBID format: {mbid}") + + return mbid + + +def is_unknown_mbid(mbid: Optional[str]) -> bool: + return not mbid or not isinstance(mbid, str) or mbid.startswith('unknown_') or not mbid.strip() + + +def sanitize_optional_string(value: Optional[str]) -> Optional[str]: + if not value or not isinstance(value, str): + return None + + value = value.strip() + return value if value else None + + +def strip_html_tags(text: str | None) -> str: + """Strip HTML tags from text, converting
to newlines. + + Uses stdlib html.parser — no external dependencies needed. + Returns plain text suitable for display. + """ + if not text: + return "" + + from html.parser import HTMLParser + from html import unescape + + class _TextExtractor(HTMLParser): + def __init__(self) -> None: + super().__init__() + self._parts: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in ("br", "br/"): + self._parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag == "p": + self._parts.append("\n\n") + + def handle_data(self, data: str) -> None: + self._parts.append(data) + + def get_text(self) -> str: + return "".join(self._parts).strip() + + extractor = _TextExtractor() + extractor.feed(unescape(text)) + return extractor.get_text() + + +_LASTFM_SUFFIX_RE = re.compile( + r"\s*Read more on Last\.fm\.?\s*$", + re.IGNORECASE, +) + + +def clean_lastfm_bio(text: str | None) -> str: + """Strip HTML tags and remove the trailing 'Read more on Last.fm' suffix.""" + cleaned = strip_html_tags(text) + if not cleaned: + return "" + return _LASTFM_SUFFIX_RE.sub("", cleaned).rstrip() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..5c440d7 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,314 @@ +import logging +import asyncio +from contextlib import asynccontextmanager +from fastapi import FastAPI, APIRouter, HTTPException +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from fastapi.middleware.gzip import GZipMiddleware +from core.dependencies import ( + get_request_queue, + get_cache, + get_library_service, + get_preferences_service, + init_app_state, + cleanup_app_state +) +from core.tasks import start_cache_cleanup_task, start_library_sync_task, start_disk_cache_cleanup_task, start_home_cache_warming_task, start_genre_cache_warming_task, start_discover_cache_warming_task, start_artist_discovery_cache_warming_task, start_audiodb_sweep_task, start_request_status_sync_task +from core.task_registry import TaskRegistry +from core.config import get_settings +from core.exceptions import ResourceNotFoundError, ExternalServiceError, SourceResolutionError, ValidationError, ConfigurationError, ClientDisconnectedError +from core.exception_handlers import ( + resource_not_found_handler, + external_service_error_handler, + circuit_open_error_handler, + source_resolution_error_handler, + validation_error_handler, + configuration_error_handler, + general_exception_handler, + http_exception_handler, + starlette_http_exception_handler, + request_validation_error_handler, + client_disconnected_handler, +) +from infrastructure.resilience.retry import CircuitOpenError +from infrastructure.msgspec_fastapi import MsgSpecJSONResponse +from middleware import DegradationMiddleware, PerformanceMiddleware, RateLimitMiddleware +from static_server import mount_frontend +from api.v1.routes import ( + search, requests, library, status, queue, covers, artists, albums, settings, home, discover, profile, playlists +) +from api.v1.routes import cache as cache_routes +from api.v1.routes import cache_status as cache_status_routes +from api.v1.routes import youtube as youtube_routes +from api.v1.routes import requests_page as requests_page_routes +from api.v1.routes import stream as stream_routes +from api.v1.routes import jellyfin_library as jellyfin_library_routes +from api.v1.routes import navidrome_library as navidrome_library_routes +from api.v1.routes import local_library as local_library_routes +from api.v1.routes import lastfm as lastfm_routes +from api.v1.routes import scrobble as scrobble_routes + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting Musicseerr...") + + settings = get_settings() + configured_level = getattr(logging, settings.log_level, logging.INFO) + logging.getLogger().setLevel(configured_level) + logger.info("Log level set to %s", settings.log_level) + + await init_app_state(app) + + preferences_service = get_preferences_service() + advanced_settings = preferences_service.get_advanced_settings() + + cache = get_cache() + start_cache_cleanup_task(cache, interval=advanced_settings.memory_cache_cleanup_interval) + + from core.dependencies import get_disk_cache + disk_cache = get_disk_cache() + from core.dependencies import get_coverart_repository + cover_disk_cache = get_coverart_repository().disk_cache + start_disk_cache_cleanup_task( + disk_cache, + interval=advanced_settings.disk_cache_cleanup_interval, + cover_disk_cache=cover_disk_cache, + ) + + library_service = get_library_service() + start_library_sync_task(library_service, preferences_service) + + request_queue = get_request_queue() + await request_queue.start() + + from core.tasks import warm_library_cache + from core.dependencies import get_album_service, get_library_db, get_sync_state_store + + def handle_cache_warming_error(task: asyncio.Task): + try: + if task.cancelled(): + logger.info("Cache warming was cancelled") + return + + exc = task.exception() + if exc: + logger.error("Cache warming failed: %s", exc, exc_info=exc) + except asyncio.CancelledError: + logger.info("Cache warming was cancelled") + except Exception as e: # noqa: BLE001 + logger.error("Error checking cache warming task: %s", e) + + cache_task = asyncio.create_task( + warm_library_cache(library_service, get_album_service(), get_library_db()) + ) + cache_task.add_done_callback(handle_cache_warming_error) + TaskRegistry.get_instance().register("library-cache-warmup", cache_task) + + from services.cache_status_service import CacheStatusService + sync_state_store = get_sync_state_store() + library_db = get_library_db() + status_service = CacheStatusService(sync_state_store) + + interrupted_state = await status_service.restore_from_persistence() + if interrupted_state: + logger.info("Found interrupted library sync, scheduling resume...") + + async def resume_sync(): + try: + await asyncio.sleep(5) + artists = await library_db.get_artists() + albums = await library_db.get_albums() + if artists or albums: + artists_dicts = [{'mbid': a['mbid'], 'name': a['name']} for a in artists] + await library_service._precache_service.precache_library_resources( + artists_dicts, albums, resume=True + ) + else: + logger.warning("No cached artists/albums to resume sync with, clearing state") + await sync_state_store.clear_sync_state() + except Exception as e: # noqa: BLE001 + logger.error("Failed to resume interrupted sync: %s", e) + await status_service.complete_sync(str(e)) + + resume_task = asyncio.create_task(resume_sync()) + resume_task.add_done_callback(lambda t: logger.info("Resume sync task completed") if not t.exception() else logger.error("Resume sync failed: %s", t.exception())) + TaskRegistry.get_instance().register("library-sync-resume", resume_task) + + from core.dependencies import get_home_service + start_home_cache_warming_task(get_home_service()) + start_genre_cache_warming_task(get_home_service()) + + from core.dependencies import get_discover_service, get_discover_queue_manager + start_discover_cache_warming_task( + get_discover_service(), + queue_manager=get_discover_queue_manager(), + preferences_service=get_preferences_service(), + ) + + from core.dependencies import get_artist_discovery_service + start_artist_discovery_cache_warming_task( + get_artist_discovery_service(), + get_library_db(), + interval=advanced_settings.artist_discovery_warm_interval, + delay=advanced_settings.artist_discovery_warm_delay, + ) + + from core.dependencies import get_audiodb_image_service + start_audiodb_sweep_task( + get_audiodb_image_service(), + get_library_db(), + get_preferences_service(), + precache_service=library_service._precache_service, + ) + + from core.dependencies import get_audiodb_browse_queue + browse_queue = get_audiodb_browse_queue() + browse_queue.start_consumer( + get_audiodb_image_service(), + get_preferences_service(), + ) + + from core.tasks import warm_jellyfin_mbid_index + from core.dependencies import get_jellyfin_repository + jellyfin_settings = preferences_service.get_jellyfin_connection() + if jellyfin_settings.enabled: + mbid_task = asyncio.create_task(warm_jellyfin_mbid_index(get_jellyfin_repository())) + mbid_task.add_done_callback( + lambda t: None if t.cancelled() else ( + logger.error("Jellyfin MBID index warming failed: %s", t.exception()) if t.exception() else None + ) + ) + TaskRegistry.get_instance().register("jellyfin-mbid-warmup", mbid_task) + + navidrome_settings = preferences_service.get_navidrome_connection() + if navidrome_settings.enabled: + from core.tasks import warm_navidrome_mbid_cache + nav_mbid_task = asyncio.create_task(warm_navidrome_mbid_cache()) + nav_mbid_task.add_done_callback( + lambda t: None if t.cancelled() else ( + logger.error("Navidrome MBID cache warming failed: %s", t.exception()) if t.exception() else None + ) + ) + TaskRegistry.get_instance().register("navidrome-mbid-warmup", nav_mbid_task) + + from core.dependencies import get_requests_page_service + requests_page_service = get_requests_page_service() + + start_request_status_sync_task(requests_page_service) + + from core.tasks import start_orphan_cover_demotion_task, start_store_prune_task + from core.dependencies import get_request_history_store, get_mbid_store, get_youtube_store + + start_orphan_cover_demotion_task( + cover_disk_cache, + library_db, + interval=advanced_settings.orphan_cover_demote_interval_hours * 3600, + ) + + start_store_prune_task( + get_request_history_store(), + get_mbid_store(), + get_youtube_store(), + request_retention_days=advanced_settings.request_history_retention_days, + ignored_retention_days=advanced_settings.ignored_releases_retention_days, + interval=advanced_settings.store_prune_interval_hours * 3600, + ) + + logger.info("Musicseerr started successfully") + + try: + yield + finally: + logger.info("Shutting down Musicseerr...") + + try: + await request_queue.stop() + except Exception as e: # noqa: BLE001 + logger.error("Error stopping request queue: %s", e) + + registry = TaskRegistry.get_instance() + settings = get_settings() + await registry.cancel_all(grace_period=settings.shutdown_grace_period) + + try: + await cleanup_app_state() + except Exception as e: # noqa: BLE001 + logger.error("Error during cleanup: %s", e) + + logger.info("Musicseerr shut down successfully") + + +app = FastAPI( + title="Musicseerr", + description="Music request and management system", + version="1.0.0", + docs_url="/api/v1/docs", + redoc_url="/api/v1/redoc", + openapi_url="/api/v1/openapi.json", + lifespan=lifespan, + default_response_class=MsgSpecJSONResponse, +) + +app.add_exception_handler(ClientDisconnectedError, client_disconnected_handler) +app.add_exception_handler(ResourceNotFoundError, resource_not_found_handler) +app.add_exception_handler(ExternalServiceError, external_service_error_handler) +app.add_exception_handler(SourceResolutionError, source_resolution_error_handler) +app.add_exception_handler(ValidationError, validation_error_handler) +app.add_exception_handler(ConfigurationError, configuration_error_handler) +app.add_exception_handler(CircuitOpenError, circuit_open_error_handler) +app.add_exception_handler(HTTPException, http_exception_handler) +app.add_exception_handler(StarletteHTTPException, starlette_http_exception_handler) +app.add_exception_handler(RequestValidationError, request_validation_error_handler) +app.add_exception_handler(Exception, general_exception_handler) + +app.add_middleware(DegradationMiddleware) +app.add_middleware(PerformanceMiddleware) +app.add_middleware( + RateLimitMiddleware, + default_rate=30.0, + default_capacity=60, + overrides={ + "/api/v1/search": (10.0, 20), + "/api/v1/discover": (10.0, 20), + "/api/v1/covers": (15.0, 30), + }, +) +app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=6) + + +@app.get("/health") +def health_check(): + return {"status": "ok", "message": "Musicseerr backend running"} + + +v1_router = APIRouter(prefix="/api/v1") +v1_router.include_router(search.router) +v1_router.include_router(requests.router) +v1_router.include_router(library.router) +v1_router.include_router(queue.router) +v1_router.include_router(status.router) +v1_router.include_router(covers.router) +v1_router.include_router(artists.router) +v1_router.include_router(albums.router) +v1_router.include_router(settings.router) +v1_router.include_router(home.router) +v1_router.include_router(discover.router) +v1_router.include_router(youtube_routes.router) +v1_router.include_router(cache_routes.router) +v1_router.include_router(cache_status_routes.router) +v1_router.include_router(requests_page_routes.router) +v1_router.include_router(stream_routes.router) +v1_router.include_router(jellyfin_library_routes.router) +v1_router.include_router(navidrome_library_routes.router) +v1_router.include_router(local_library_routes.router) +v1_router.include_router(lastfm_routes.router) +v1_router.include_router(scrobble_routes.router) +v1_router.include_router(profile.router) +v1_router.include_router(playlists.router) +app.include_router(v1_router) + +mount_frontend(app) diff --git a/backend/middleware.py b/backend/middleware.py new file mode 100644 index 0000000..6b6b8e3 --- /dev/null +++ b/backend/middleware.py @@ -0,0 +1,111 @@ +import logging +import time +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from infrastructure.degradation import ( + init_degradation_context, + try_get_degradation_context, + clear_degradation_context, +) +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from infrastructure.msgspec_fastapi import MsgSpecJSONResponse + +logger = logging.getLogger(__name__) + +SLOW_REQUEST_THRESHOLD = 1.0 + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Per-process token-bucket rate limiter with per-path overrides.""" + + def __init__( + self, + app: ASGIApp, + default_rate: float = 30.0, + default_capacity: int = 60, + overrides: dict[str, tuple[float, int]] | None = None, + ): + super().__init__(app) + self._default = TokenBucketRateLimiter(rate=default_rate, capacity=default_capacity) + self._overrides: list[tuple[str, TokenBucketRateLimiter]] = [] + for prefix, (rate, capacity) in (overrides or {}).items(): + self._overrides.append((prefix, TokenBucketRateLimiter(rate=rate, capacity=capacity))) + + def _get_limiter(self, path: str) -> TokenBucketRateLimiter: + for prefix, limiter in self._overrides: + if path.startswith(prefix): + return limiter + return self._default + + async def dispatch(self, request: Request, call_next): + path = request.url.path + if not path.startswith("/api/"): + return await call_next(request) + + limiter = self._get_limiter(path) + acquired = await limiter.try_acquire() + + if acquired: + response = await call_next(request) + response.headers["X-RateLimit-Limit"] = str(limiter.capacity) + response.headers["X-RateLimit-Remaining"] = str(limiter.remaining) + return response + + retry_after = limiter.retry_after() + return MsgSpecJSONResponse( + status_code=429, + content={ + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests", + "details": None, + } + }, + headers={ + "Retry-After": str(int(retry_after)), + "X-RateLimit-Limit": str(limiter.capacity), + "X-RateLimit-Remaining": "0", + }, + ) + + +class DegradationMiddleware(BaseHTTPMiddleware): + """Initialise a per-request DegradationContext and surface results in a header.""" + + async def dispatch(self, request: Request, call_next): + init_degradation_context() + try: + response = await call_next(request) + ctx = try_get_degradation_context() + if ctx and ctx.has_degradation(): + sources = ",".join( + name for name, status in ctx.summary().items() if status != "ok" + ) + if sources: + response.headers["X-Degraded-Services"] = sources + return response + finally: + clear_degradation_context() + + +class PerformanceMiddleware(BaseHTTPMiddleware): + + def __init__(self, app: ASGIApp): + super().__init__(app) + + async def dispatch(self, request: Request, call_next): + start_time = time.perf_counter() + response = await call_next(request) + process_time = time.perf_counter() - start_time + + response.headers["X-Response-Time"] = f"{process_time:.3f}s" + + if process_time > SLOW_REQUEST_THRESHOLD: + logger.warning( + f"Slow request: {request.method} {request.url.path} " + f"took {process_time:.2f}s" + ) + + return response diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/album.py b/backend/models/album.py new file mode 100644 index 0000000..7f89442 --- /dev/null +++ b/backend/models/album.py @@ -0,0 +1,38 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class Track(AppStruct): + position: int + title: str + disc_number: int = 1 + length: int | None = None + recording_id: str | None = None + + +class AlbumInfo(AppStruct): + title: str + musicbrainz_id: str + artist_name: str + artist_id: str + release_date: str | None = None + year: int | None = None + type: str | None = None + label: str | None = None + barcode: str | None = None + country: str | None = None + disambiguation: str | None = None + tracks: list[Track] = [] + total_tracks: int = 0 + total_length: int | None = None + in_library: bool = False + requested: bool = False + cover_url: str | None = None + album_thumb_url: str | None = None + album_back_url: str | None = None + album_cdart_url: str | None = None + album_spine_url: str | None = None + album_3d_case_url: str | None = None + album_3d_flat_url: str | None = None + album_3d_face_url: str | None = None + album_3d_thumb_url: str | None = None + service_status: dict[str, str] | None = None diff --git a/backend/models/artist.py b/backend/models/artist.py new file mode 100644 index 0000000..fbeb9ed --- /dev/null +++ b/backend/models/artist.py @@ -0,0 +1,58 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class ExternalLink(AppStruct): + type: str + url: str + label: str | list[str] + category: str = "other" + + def __post_init__(self) -> None: + if isinstance(self.label, list): + object.__setattr__(self, "label", self.label[0] if self.label else self.type) + + +class LifeSpan(AppStruct): + begin: str | None = None + end: str | None = None + ended: str | None = None + + +class ReleaseItem(AppStruct): + id: str | None = None + title: str | None = None + type: str | None = None + first_release_date: str | None = None + year: int | None = None + in_library: bool = False + requested: bool = False + + +class ArtistInfo(AppStruct): + name: str + musicbrainz_id: str + disambiguation: str | None = None + type: str | None = None + country: str | None = None + life_span: LifeSpan | None = None + description: str | None = None + image: str | None = None + fanart_url: str | None = None + banner_url: str | None = None + thumb_url: str | None = None + fanart_url_2: str | None = None + fanart_url_3: str | None = None + fanart_url_4: str | None = None + wide_thumb_url: str | None = None + logo_url: str | None = None + clearart_url: str | None = None + cutout_url: str | None = None + tags: list[str] = [] + aliases: list[str] = [] + external_links: list[ExternalLink] = [] + in_library: bool = False + albums: list[ReleaseItem] = [] + singles: list[ReleaseItem] = [] + eps: list[ReleaseItem] = [] + release_group_count: int = 0 + service_status: dict[str, str] | None = None diff --git a/backend/models/common.py b/backend/models/common.py new file mode 100644 index 0000000..3dd70a7 --- /dev/null +++ b/backend/models/common.py @@ -0,0 +1,9 @@ +from typing import Literal + +from infrastructure.msgspec_fastapi import AppStruct + + +class ServiceStatus(AppStruct): + status: Literal["ok", "error"] + version: str | None = None + message: str | None = None diff --git a/backend/models/error.py b/backend/models/error.py new file mode 100644 index 0000000..f1e2e74 --- /dev/null +++ b/backend/models/error.py @@ -0,0 +1,54 @@ +from typing import Any + +from infrastructure.msgspec_fastapi import AppStruct, MsgSpecJSONResponse + +VALIDATION_ERROR = "VALIDATION_ERROR" +NOT_FOUND = "NOT_FOUND" +EXTERNAL_SERVICE_UNAVAILABLE = "EXTERNAL_SERVICE_UNAVAILABLE" +SERVICE_UNAVAILABLE = "SERVICE_UNAVAILABLE" +CONFIGURATION_ERROR = "CONFIGURATION_ERROR" +SOURCE_RESOLUTION_ERROR = "SOURCE_RESOLUTION_ERROR" +INTERNAL_ERROR = "INTERNAL_ERROR" +RATE_LIMITED = "RATE_LIMITED" +CLIENT_DISCONNECTED = "CLIENT_DISCONNECTED" + +FORBIDDEN = "FORBIDDEN" +RANGE_NOT_SATISFIABLE = "RANGE_NOT_SATISFIABLE" +METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED" +CONFLICT = "CONFLICT" + +STATUS_TO_CODE: dict[int, str] = { + 400: VALIDATION_ERROR, + 403: FORBIDDEN, + 404: NOT_FOUND, + 405: METHOD_NOT_ALLOWED, + 409: CONFLICT, + 416: RANGE_NOT_SATISFIABLE, + 422: VALIDATION_ERROR, + 429: RATE_LIMITED, + 500: INTERNAL_ERROR, + 502: EXTERNAL_SERVICE_UNAVAILABLE, + 503: SERVICE_UNAVAILABLE, +} + + +class ErrorDetail(AppStruct): + code: str + message: str + details: Any | None = None + + +class ErrorResponse(AppStruct): + error: ErrorDetail + + +def error_response( + status_code: int, + code: str, + message: str, + details: Any | None = None, +) -> MsgSpecJSONResponse: + return MsgSpecJSONResponse( + status_code=status_code, + content={"error": {"code": code, "message": message, "details": details}}, + ) diff --git a/backend/models/library.py b/backend/models/library.py new file mode 100644 index 0000000..3554f69 --- /dev/null +++ b/backend/models/library.py @@ -0,0 +1,32 @@ +from infrastructure.msgspec_fastapi import AppStruct +from infrastructure.validators import sanitize_optional_string + + +class LibraryAlbum(AppStruct, rename={"musicbrainz_id": "foreignAlbumId"}): + artist: str + album: str + monitored: bool + year: int | None = None + quality: str | None = None + cover_url: str | None = None + musicbrainz_id: str | None = None + artist_mbid: str | None = None + date_added: int | None = None + + def __post_init__(self) -> None: + self.cover_url = sanitize_optional_string(self.cover_url) + self.quality = sanitize_optional_string(self.quality) + self.musicbrainz_id = sanitize_optional_string(self.musicbrainz_id) + self.artist_mbid = sanitize_optional_string(self.artist_mbid) + + +class LibraryGroupedAlbum(AppStruct): + title: str | None = None + year: int | None = None + monitored: bool = False + cover_url: str | None = None + + +class LibraryGroupedArtist(AppStruct): + artist: str + albums: list[LibraryGroupedAlbum] = [] diff --git a/backend/models/pagination.py b/backend/models/pagination.py new file mode 100644 index 0000000..5c2c17c --- /dev/null +++ b/backend/models/pagination.py @@ -0,0 +1,10 @@ +from typing import Any + + +def paginated_response( + items: list[Any], + total: int, + offset: int, + limit: int, +) -> dict[str, Any]: + return {"items": items, "total": total, "offset": offset, "limit": limit} diff --git a/backend/models/request.py b/backend/models/request.py new file mode 100644 index 0000000..7871c6c --- /dev/null +++ b/backend/models/request.py @@ -0,0 +1,15 @@ +from datetime import datetime +from typing import Annotated + +import msgspec + +from infrastructure.msgspec_fastapi import AppStruct + + +class QueueItem(AppStruct): + artist: str + album: str + status: str + progress: Annotated[int, msgspec.Meta(ge=0, le=100)] | None = None + eta: datetime | None = None + musicbrainz_id: str | None = None diff --git a/backend/models/search.py b/backend/models/search.py new file mode 100644 index 0000000..187047c --- /dev/null +++ b/backend/models/search.py @@ -0,0 +1,19 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class SearchResult(AppStruct): + type: str + title: str + musicbrainz_id: str + artist: str | None = None + year: int | None = None + in_library: bool = False + requested: bool = False + cover_url: str | None = None + album_thumb_url: str | None = None + thumb_url: str | None = None + fanart_url: str | None = None + banner_url: str | None = None + disambiguation: str | None = None + type_info: str | None = None + score: int = 0 diff --git a/backend/models/youtube.py b/backend/models/youtube.py new file mode 100644 index 0000000..f7c9e66 --- /dev/null +++ b/backend/models/youtube.py @@ -0,0 +1,8 @@ +from infrastructure.msgspec_fastapi import AppStruct + + +class YouTubeQuotaResponse(AppStruct): + used: int + limit: int + remaining: int + date: str diff --git a/backend/repositories/__init__.py b/backend/repositories/__init__.py new file mode 100644 index 0000000..8954e54 --- /dev/null +++ b/backend/repositories/__init__.py @@ -0,0 +1,15 @@ + + +from repositories.protocols import ( + MusicBrainzRepositoryProtocol, + LidarrRepositoryProtocol, + WikidataRepositoryProtocol, + CoverArtRepositoryProtocol, +) + +__all__ = [ + "MusicBrainzRepositoryProtocol", + "LidarrRepositoryProtocol", + "WikidataRepositoryProtocol", + "CoverArtRepositoryProtocol", +] diff --git a/backend/repositories/async_playlist_repository.py b/backend/repositories/async_playlist_repository.py new file mode 100644 index 0000000..ebd1128 --- /dev/null +++ b/backend/repositories/async_playlist_repository.py @@ -0,0 +1,96 @@ +import asyncio +from typing import Optional + +from repositories.playlist_repository import ( + PlaylistRecord, + PlaylistRepository, + PlaylistSummaryRecord, + PlaylistTrackRecord, + _UNSET, +) + + +class AsyncPlaylistRepository: + """Async wrapper around PlaylistRepository. + + Delegates all calls to asyncio.to_thread to avoid blocking the event loop. + """ + + def __init__(self, repo: PlaylistRepository): + self._repo = repo + + async def create_playlist(self, name: str) -> PlaylistRecord: + return await asyncio.to_thread(self._repo.create_playlist, name) + + async def get_playlist(self, playlist_id: str) -> Optional[PlaylistRecord]: + return await asyncio.to_thread(self._repo.get_playlist, playlist_id) + + async def get_all_playlists(self) -> list[PlaylistSummaryRecord]: + return await asyncio.to_thread(self._repo.get_all_playlists) + + async def update_playlist( + self, + playlist_id: str, + name: Optional[str] = None, + cover_image_path: Optional[str] = _UNSET, + ) -> Optional[PlaylistRecord]: + return await asyncio.to_thread( + self._repo.update_playlist, playlist_id, name, cover_image_path, + ) + + async def delete_playlist(self, playlist_id: str) -> bool: + return await asyncio.to_thread(self._repo.delete_playlist, playlist_id) + + async def add_tracks( + self, + playlist_id: str, + tracks: list[dict], + position: Optional[int] = None, + ) -> list[PlaylistTrackRecord]: + return await asyncio.to_thread(self._repo.add_tracks, playlist_id, tracks, position) + + async def remove_track(self, playlist_id: str, track_id: str) -> bool: + return await asyncio.to_thread(self._repo.remove_track, playlist_id, track_id) + + async def remove_tracks(self, playlist_id: str, track_ids: list[str]) -> int: + return await asyncio.to_thread(self._repo.remove_tracks, playlist_id, track_ids) + + async def reorder_track( + self, playlist_id: str, track_id: str, new_position: int, + ) -> Optional[int]: + return await asyncio.to_thread( + self._repo.reorder_track, playlist_id, track_id, new_position, + ) + + async def update_track_source( + self, + playlist_id: str, + track_id: str, + source_type: Optional[str] = None, + available_sources: Optional[list[str]] = None, + track_source_id: Optional[str] = None, + ) -> Optional[PlaylistTrackRecord]: + return await asyncio.to_thread( + self._repo.update_track_source, playlist_id, track_id, + source_type, available_sources, track_source_id, + ) + + async def batch_update_available_sources( + self, + playlist_id: str, + updates: dict[str, list[str]], + ) -> int: + return await asyncio.to_thread( + self._repo.batch_update_available_sources, playlist_id, updates, + ) + + async def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]: + return await asyncio.to_thread(self._repo.get_tracks, playlist_id) + + async def get_track(self, playlist_id: str, track_id: str) -> Optional[PlaylistTrackRecord]: + return await asyncio.to_thread(self._repo.get_track, playlist_id, track_id) + + async def check_track_membership( + self, tracks: list[tuple[str, str, str]], + ) -> dict[str, list[int]]: + return await asyncio.to_thread(self._repo.check_track_membership, tracks) diff --git a/backend/repositories/audiodb_models.py b/backend/repositories/audiodb_models.py new file mode 100644 index 0000000..2e20d24 --- /dev/null +++ b/backend/repositories/audiodb_models.py @@ -0,0 +1,118 @@ +import time +from typing import Literal + +from infrastructure.msgspec_fastapi import AppStruct + + +class AudioDBArtistResponse(AppStruct): + idArtist: str + strArtist: str + strMusicBrainzID: str | None = None + strArtistThumb: str | None = None + strArtistFanart: str | None = None + strArtistFanart2: str | None = None + strArtistFanart3: str | None = None + strArtistFanart4: str | None = None + strArtistWideThumb: str | None = None + strArtistBanner: str | None = None + strArtistLogo: str | None = None + strArtistCutout: str | None = None + strArtistClearart: str | None = None + + +class AudioDBAlbumResponse(AppStruct): + idAlbum: str + strAlbum: str + strMusicBrainzID: str | None = None + strAlbumThumb: str | None = None + strAlbumBack: str | None = None + strAlbumCDart: str | None = None + strAlbumSpine: str | None = None + strAlbum3DCase: str | None = None + strAlbum3DFlat: str | None = None + strAlbum3DFace: str | None = None + strAlbum3DThumb: str | None = None + + +class AudioDBArtistImages(AppStruct): + thumb_url: str | None = None + fanart_url: str | None = None + fanart_url_2: str | None = None + fanart_url_3: str | None = None + fanart_url_4: str | None = None + wide_thumb_url: str | None = None + banner_url: str | None = None + logo_url: str | None = None + cutout_url: str | None = None + clearart_url: str | None = None + lookup_source: Literal["mbid", "name"] = "mbid" + matched_mbid: str | None = None + is_negative: bool = False + cached_at: float = 0.0 + + @staticmethod + def from_response(resp: AudioDBArtistResponse, lookup_source: Literal["mbid", "name"] = "mbid") -> "AudioDBArtistImages": + return AudioDBArtistImages( + thumb_url=resp.strArtistThumb, + fanart_url=resp.strArtistFanart, + fanart_url_2=resp.strArtistFanart2, + fanart_url_3=resp.strArtistFanart3, + fanart_url_4=resp.strArtistFanart4, + wide_thumb_url=resp.strArtistWideThumb, + banner_url=resp.strArtistBanner, + logo_url=resp.strArtistLogo, + cutout_url=resp.strArtistCutout, + clearart_url=resp.strArtistClearart, + lookup_source=lookup_source, + matched_mbid=resp.strMusicBrainzID, + is_negative=False, + cached_at=time.time(), + ) + + @classmethod + def negative(cls, lookup_source: Literal["mbid", "name"] = "mbid") -> "AudioDBArtistImages": + return cls( + is_negative=True, + lookup_source=lookup_source, + cached_at=time.time(), + ) + + +class AudioDBAlbumImages(AppStruct): + album_thumb_url: str | None = None + album_back_url: str | None = None + album_cdart_url: str | None = None + album_spine_url: str | None = None + album_3d_case_url: str | None = None + album_3d_flat_url: str | None = None + album_3d_face_url: str | None = None + album_3d_thumb_url: str | None = None + lookup_source: Literal["mbid", "name"] = "mbid" + matched_mbid: str | None = None + is_negative: bool = False + cached_at: float = 0.0 + + @staticmethod + def from_response(resp: AudioDBAlbumResponse, lookup_source: Literal["mbid", "name"] = "mbid") -> "AudioDBAlbumImages": + return AudioDBAlbumImages( + album_thumb_url=resp.strAlbumThumb, + album_back_url=resp.strAlbumBack, + album_cdart_url=resp.strAlbumCDart, + album_spine_url=resp.strAlbumSpine, + album_3d_case_url=resp.strAlbum3DCase, + album_3d_flat_url=resp.strAlbum3DFlat, + album_3d_face_url=resp.strAlbum3DFace, + album_3d_thumb_url=resp.strAlbum3DThumb, + lookup_source=lookup_source, + matched_mbid=resp.strMusicBrainzID, + is_negative=False, + cached_at=time.time(), + ) + + @classmethod + def negative(cls, lookup_source: Literal["mbid", "name"] = "mbid") -> "AudioDBAlbumImages": + return cls( + is_negative=True, + lookup_source=lookup_source, + cached_at=time.time(), + ) diff --git a/backend/repositories/audiodb_repository.py b/backend/repositories/audiodb_repository.py new file mode 100644 index 0000000..a28e7f9 --- /dev/null +++ b/backend/repositories/audiodb_repository.py @@ -0,0 +1,298 @@ +import logging +import time +from typing import Any + +import httpx +import msgspec + +from core.exceptions import ExternalServiceError, RateLimitedError +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from infrastructure.resilience.retry import CircuitBreaker, CircuitOpenError, with_retry +from repositories.audiodb_models import ( + AudioDBAlbumResponse, + AudioDBArtistResponse, +) +from services.preferences_service import PreferencesService +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "audiodb" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +AUDIODB_API_URL = "https://www.theaudiodb.com/api/v1/json" + + +def _log_circuit_state_change( + breaker: CircuitBreaker, + previous_state, + new_state, + reason: str, +) -> None: + level = logging.INFO if new_state.value == "closed" else logging.WARNING + logger.log( + level, + "audiodb.circuit_state_change service=%s previous_state=%s state=%s reason=%s", + breaker.name, + previous_state.value, + new_state.value, + reason, + ) + +_audiodb_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="audiodb", + on_state_change=_log_circuit_state_change, +) + +AUDIODB_FREE_KEY = "123" + +AudioDBJson = dict[str, Any] + + +def _make_rate_limiter(premium: bool = False) -> TokenBucketRateLimiter: + if premium: + return TokenBucketRateLimiter(rate=5.0, capacity=10) + return TokenBucketRateLimiter(rate=0.5, capacity=2) + + +def _decode_json_response(response: httpx.Response) -> AudioDBJson: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=AudioDBJson) + return response.json() + + +def _extract_first(data: dict[str, Any], key: str) -> dict[str, Any] | None: + items = data.get(key) + if not items or not isinstance(items, list) or len(items) == 0: + return None + return items[0] + + +class AudioDBRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + preferences_service: PreferencesService, + api_key: str = "123", + premium: bool = False, + ): + self._client = http_client + self._preferences_service = preferences_service + self._api_key = api_key + self._rate_limiter = _make_rate_limiter(premium) + + def _is_enabled(self) -> bool: + return self._preferences_service.get_advanced_settings().audiodb_enabled + + def _effective_api_key(self) -> str: + settings_key = self._preferences_service.get_advanced_settings().audiodb_api_key + if settings_key and settings_key.strip(): + return settings_key + return self._api_key + + @staticmethod + def reset_circuit_breaker() -> None: + _audiodb_circuit_breaker.reset() + + @with_retry( + max_attempts=3, + base_delay=2.0, + max_delay=10.0, + circuit_breaker=_audiodb_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError, RateLimitedError), + ) + async def _request(self, endpoint: str, params: dict[str, str] | None = None) -> dict[str, Any] | None: + await self._rate_limiter.acquire() + + url = f"{AUDIODB_API_URL}/{self._effective_api_key()}/{endpoint}" + + try: + t0 = time.monotonic() + response = await self._client.get(url, params=params, timeout=15.0) + elapsed_ms = (time.monotonic() - t0) * 1000 + + if response.status_code == 429: + logger.warning("audiodb.ratelimit status=429 elapsed_ms=%.1f retry_after_s=60", elapsed_ms) + raise RateLimitedError("AudioDB rate limit exceeded", retry_after_seconds=60) + + if response.status_code == 404: + logger.debug("audiodb.request endpoint=%s status=404 elapsed_ms=%.1f", endpoint, elapsed_ms) + return None + + if response.status_code != 200: + raise ExternalServiceError( + f"AudioDB request failed ({response.status_code})" + ) + + try: + data = _decode_json_response(response) + except (msgspec.DecodeError, ValueError, TypeError): + raise ExternalServiceError("AudioDB returned invalid JSON") + + logger.debug("audiodb.request endpoint=%s status=200 elapsed_ms=%.1f", endpoint, elapsed_ms) + return data + + except (ExternalServiceError, RateLimitedError): + raise + except httpx.HTTPError as e: + raise ExternalServiceError(f"AudioDB request failed: {e}") + + async def get_artist_by_mbid(self, mbid: str) -> AudioDBArtistResponse | None: + if not self._is_enabled() or not mbid: + return None + + try: + return await self._get_artist_by_mbid(mbid) + except CircuitOpenError: + logger.warning("audiodb.circuit_open entity=artist lookup_type=mbid mbid=%s", mbid) + _record_degradation(f"Circuit open: artist lookup by mbid {mbid}") + return None + + async def _get_artist_by_mbid(self, mbid: str) -> AudioDBArtistResponse | None: + t0 = time.monotonic() + data = await self._request("artist-mb.php", params={"i": mbid}) + elapsed_ms = (time.monotonic() - t0) * 1000 + + if data is None: + logger.debug("audiodb.lookup entity=artist lookup_type=mbid mbid=%s found=false elapsed_ms=%.1f", mbid, elapsed_ms) + return None + + item = _extract_first(data, "artists") + if item is None: + logger.debug("audiodb.lookup entity=artist lookup_type=mbid mbid=%s found=false elapsed_ms=%.1f", mbid, elapsed_ms) + return None + + try: + result = msgspec.convert(item, type=AudioDBArtistResponse) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc: + logger.warning("audiodb.schema_error entity=artist lookup_type=mbid mbid=%s error=%s", mbid, exc) + _record_degradation(f"Schema error for artist mbid {mbid}: {exc}") + return None + logger.debug("audiodb.lookup entity=artist lookup_type=mbid mbid=%s found=true elapsed_ms=%.1f", mbid, elapsed_ms) + return result + + async def get_album_by_mbid(self, mbid: str) -> AudioDBAlbumResponse | None: + if not self._is_enabled() or not mbid: + return None + + try: + return await self._get_album_by_mbid(mbid) + except CircuitOpenError: + logger.warning("audiodb.circuit_open entity=album lookup_type=mbid mbid=%s", mbid) + _record_degradation(f"Circuit open: album lookup by mbid {mbid}") + return None + + async def _get_album_by_mbid(self, mbid: str) -> AudioDBAlbumResponse | None: + t0 = time.monotonic() + data = await self._request("album-mb.php", params={"i": mbid}) + elapsed_ms = (time.monotonic() - t0) * 1000 + + if data is None: + logger.debug("audiodb.lookup entity=album lookup_type=mbid mbid=%s found=false elapsed_ms=%.1f", mbid, elapsed_ms) + return None + + item = _extract_first(data, "album") + if item is None: + logger.debug("audiodb.lookup entity=album lookup_type=mbid mbid=%s found=false elapsed_ms=%.1f", mbid, elapsed_ms) + return None + + try: + result = msgspec.convert(item, type=AudioDBAlbumResponse) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc: + logger.warning("audiodb.schema_error entity=album lookup_type=mbid mbid=%s error=%s", mbid, exc) + _record_degradation(f"Schema error for album mbid {mbid}: {exc}") + return None + logger.debug("audiodb.lookup entity=album lookup_type=mbid mbid=%s found=true elapsed_ms=%.1f", mbid, elapsed_ms) + return result + + async def search_artist_by_name(self, name: str) -> AudioDBArtistResponse | None: + if not self._is_enabled() or not name: + return None + + try: + return await self._search_artist_by_name(name) + except CircuitOpenError: + logger.warning("audiodb.circuit_open entity=artist lookup_type=name name=%s", name) + _record_degradation("Circuit open: artist search by name") + return None + + async def _search_artist_by_name(self, name: str) -> AudioDBArtistResponse | None: + t0 = time.monotonic() + data = await self._request("search.php", params={"s": name}) + elapsed_ms = (time.monotonic() - t0) * 1000 + + if data is None: + logger.debug("audiodb.lookup entity=artist lookup_type=name name=%s found=false elapsed_ms=%.1f", name, elapsed_ms) + return None + + item = _extract_first(data, "artists") + if item is None: + logger.debug("audiodb.lookup entity=artist lookup_type=name name=%s found=false elapsed_ms=%.1f", name, elapsed_ms) + return None + + try: + result = msgspec.convert(item, type=AudioDBArtistResponse) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc: + logger.warning("audiodb.schema_error entity=artist lookup_type=name name=%s error=%s", name, exc) + _record_degradation(f"Schema error for artist name search: {exc}") + return None + logger.debug("audiodb.lookup entity=artist lookup_type=name name=%s found=true elapsed_ms=%.1f", name, elapsed_ms) + return result + + async def search_album_by_name(self, artist: str, album: str) -> AudioDBAlbumResponse | None: + if not self._is_enabled() or not artist or not album: + return None + + try: + return await self._search_album_by_name(artist, album) + except CircuitOpenError: + logger.warning("audiodb.circuit_open entity=album lookup_type=name artist=%s album=%s", artist, album) + _record_degradation("Circuit open: album search by name") + return None + + async def _search_album_by_name(self, artist: str, album: str) -> AudioDBAlbumResponse | None: + t0 = time.monotonic() + data = await self._request("searchalbum.php", params={"s": artist, "a": album}) + elapsed_ms = (time.monotonic() - t0) * 1000 + + if data is None: + logger.debug( + "audiodb.lookup entity=album lookup_type=name artist=%s album=%s found=false elapsed_ms=%.1f", + artist, album, elapsed_ms, + ) + return None + + item = _extract_first(data, "album") + if item is None: + logger.debug( + "audiodb.lookup entity=album lookup_type=name artist=%s album=%s found=false elapsed_ms=%.1f", + artist, album, elapsed_ms, + ) + return None + + try: + result = msgspec.convert(item, type=AudioDBAlbumResponse) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError) as exc: + logger.warning( + "audiodb.schema_error entity=album lookup_type=name artist=%s album=%s error=%s", + artist, + album, + exc, + ) + _record_degradation(f"Schema error for album name search: {exc}") + return None + logger.debug( + "audiodb.lookup entity=album lookup_type=name artist=%s album=%s found=true elapsed_ms=%.1f", + artist, album, elapsed_ms, + ) + return result diff --git a/backend/repositories/coverart_album.py b/backend/repositories/coverart_album.py new file mode 100644 index 0000000..b3fd23a --- /dev/null +++ b/backend/repositories/coverart_album.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import msgspec + +from infrastructure.validators import validate_mbid, validate_audiodb_image_url +from infrastructure.queue.priority_queue import RequestPriority +from infrastructure.http.disconnect import DisconnectCallable, check_disconnected +from core.exceptions import ClientDisconnectedError + +if TYPE_CHECKING: + from services.audiodb_image_service import AudioDBImageService + from repositories.lidarr import LidarrRepository + from repositories.musicbrainz_repository import MusicBrainzRepository + from repositories.jellyfin_repository import JellyfinRepository + +logger = logging.getLogger(__name__) + + +class _ReleaseGroupMetadataResponse(msgspec.Struct): + release: str | None = None + + +def _decode_json_response(response, decode_type: type[_ReleaseGroupMetadataResponse]) -> _ReleaseGroupMetadataResponse: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=decode_type) + return msgspec.convert(response.json(), type=decode_type) + + +def _log_task_error(task: asyncio.Task) -> None: + if not task.cancelled() and task.exception(): + logger.error(f"Background cache write failed: {task.exception()}") + + +COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org" + +VALID_IMAGE_CONTENT_TYPES = frozenset([ + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "image/avif", "image/svg+xml", +]) +LOCAL_SOURCE_TIMEOUT_SECONDS = 1.0 + + +def _is_valid_image_content_type(content_type: str) -> bool: + if not content_type: + return False + base_type = content_type.split(";")[0].strip().lower() + return base_type in VALID_IMAGE_CONTENT_TYPES + + +class AlbumCoverFetcher: + def __init__( + self, + http_get_fn, + write_cache_fn, + lidarr_repo: 'LidarrRepository' | None = None, + mb_repo: 'MusicBrainzRepository' | None = None, + jellyfin_repo: 'JellyfinRepository' | None = None, + audiodb_service: 'AudioDBImageService' | None = None, + ): + self._http_get = http_get_fn + self._write_disk_cache = write_cache_fn + self._lidarr_repo = lidarr_repo + self._mb_repo = mb_repo + self._jellyfin_repo = jellyfin_repo + self._audiodb_service = audiodb_service + + async def fetch_release_group_cover( + self, + release_group_id: str, + size: str | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> tuple[bytes, str, str] | None: + size_int = int(size) if size and size.isdigit() else 500 + await check_disconnected(is_disconnected) + result = await self._fetch_from_audiodb(release_group_id, file_path, priority=priority) + if result: + return result + result = None + try: + await check_disconnected(is_disconnected) + result = await asyncio.wait_for( + self._fetch_release_group_local_sources(release_group_id, file_path, size_int, priority=priority), + timeout=LOCAL_SOURCE_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.debug(f"Timed out local source lookup for release group {release_group_id[:8]}...") + if result: + return result + size_suffix = f"-{size}" if size else "" + front_url = f"{COVER_ART_ARCHIVE_BASE}/release-group/{release_group_id}/front{size_suffix}" + await check_disconnected(is_disconnected) + try: + response = await self._http_get( + front_url, + priority, + source="coverart", + ) + if response.status_code == 200: + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"Non-image content-type from CoverArtArchive: {content_type}") + else: + content = response.content + task = asyncio.create_task( + self._write_disk_cache( + file_path, + content, + content_type, + {"source": "cover-art-archive"}, + ) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "cover-art-archive") + except ClientDisconnectedError: + raise + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to fetch cover via release group: {e}") + await check_disconnected(is_disconnected) + return await self._get_cover_from_best_release(release_group_id, size, file_path, priority=priority, is_disconnected=is_disconnected) + + async def _fetch_release_group_local_sources( + self, + release_group_id: str, + file_path: Path, + size: int, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + result = await self._fetch_from_lidarr(release_group_id, file_path, size=size, priority=priority) + if result: + return result + return await self._fetch_from_jellyfin(release_group_id, file_path, priority=priority) + + async def _fetch_from_audiodb( + self, + release_group_id: str, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if self._audiodb_service is None: + return None + logger.debug(f"[IMG:AudioDB] Fetching album image for {release_group_id[:8]}...") + try: + cached_images = await self._audiodb_service.fetch_and_cache_album_images(release_group_id) + if cached_images is None or cached_images.is_negative or not cached_images.album_thumb_url: + return None + if not validate_audiodb_image_url(cached_images.album_thumb_url): + logger.warning("[IMG:AudioDB] Rejected unsafe URL for album %s", release_group_id[:8]) + return None + response = await self._http_get( + cached_images.album_thumb_url, + priority, + source="audiodb", + ) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"[IMG:AudioDB] Non-image content-type ({content_type}) for {release_group_id[:8]}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache(file_path, content, content_type, {"source": "audiodb"}) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "audiodb") + except ClientDisconnectedError: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"[IMG:AudioDB] Exception for {release_group_id[:8]}: {e}") + return None + async def _get_cover_from_best_release( + self, + release_group_id: str, + size: str | None, + cache_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> tuple[bytes, str, str] | None: + try: + metadata_url = f"{COVER_ART_ARCHIVE_BASE}/release-group/{release_group_id}" + response = await self._http_get( + metadata_url, + priority, + source="coverart", + headers={"Accept": "application/json"}, + ) + if response.status_code != 200: + return None + data = _decode_json_response(response, _ReleaseGroupMetadataResponse) + release_url = data.release or "" + if not release_url: + return None + release_id = release_url.split("/")[-1] + try: + release_id = validate_mbid(release_id, "release") + except ValueError as e: + logger.warning(f"Invalid release MBID extracted from metadata: {e}") + return None + await check_disconnected(is_disconnected) + size_suffix = f"-{size}" if size else "" + release_front_url = f"{COVER_ART_ARCHIVE_BASE}/release/{release_id}/front{size_suffix}" + response = await self._http_get( + release_front_url, + priority, + source="coverart", + ) + if response.status_code == 200: + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"Non-image content-type from release: {content_type}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache( + cache_path, + content, + content_type, + {"source": "cover-art-archive"}, + ) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "cover-art-archive") + except ClientDisconnectedError: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to fetch cover from best release: {e}") + return None + + async def _fetch_from_lidarr( + self, + release_group_id: str, + file_path: Path, + size: int | None = 500, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if not self._lidarr_repo: + return None + if not self._lidarr_repo.is_configured(): + return None + try: + image_url = await self._lidarr_repo.get_album_image_url(release_group_id, size=size) + if not image_url: + return None + logger.debug(f"Fetching album cover from Lidarr: {release_group_id[:8]}...") + response = await self._http_get( + image_url, + priority, + source="lidarr", + ) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"Non-image content-type from Lidarr album: {content_type}") + return None + content = response.content + task = asyncio.create_task(self._write_disk_cache(file_path, content, content_type, {"source": "lidarr"})) + task.add_done_callback(_log_task_error) + return (content, content_type, "lidarr") + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to fetch album cover from Lidarr for {release_group_id}: {e}") + return None + + async def _fetch_from_jellyfin( + self, + musicbrainz_id: str, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if not self._jellyfin_repo or not self._jellyfin_repo.is_configured(): + return None + try: + album = await self._jellyfin_repo.get_album_by_mbid(musicbrainz_id) + if not album: + return None + image_url = self._jellyfin_repo.get_image_url(album.id, album.image_tag) + if not image_url: + return None + response = await self._http_get( + image_url, + priority, + source="jellyfin", + headers=self._jellyfin_repo.get_auth_headers(), + ) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"Non-image content-type from Jellyfin album: {content_type}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache(file_path, content, content_type, {"source": "jellyfin"}) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "jellyfin") + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to fetch album cover from Jellyfin for {musicbrainz_id}: {e}") + return None + + async def fetch_release_cover( + self, + release_id: str, + size: str | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> tuple[bytes, str, str] | None: + release_group_id = None + if self._mb_repo: + await check_disconnected(is_disconnected) + try: + release_group_id = await self._mb_repo.get_release_group_id_from_release(release_id) + except ClientDisconnectedError: + raise + except Exception as e: # noqa: BLE001 + logger.debug( + f"[IMG:AudioDB] Failed resolving release group for release {release_id[:8]}: {e}" + ) + result = None + try: + await check_disconnected(is_disconnected) + result = await asyncio.wait_for( + self._fetch_release_local_sources(release_id, file_path, size, release_group_id, priority=priority), + timeout=LOCAL_SOURCE_TIMEOUT_SECONDS, + ) + except TimeoutError: + logger.debug(f"Timed out local source lookup for release {release_id[:8]}...") + if result: + return result + if release_group_id: + await check_disconnected(is_disconnected) + result = await self._fetch_from_audiodb(release_group_id, file_path, priority=priority) + if result: + return result + + size_suffix = f"-{size}" if size else "" + url = f"{COVER_ART_ARCHIVE_BASE}/release/{release_id}/front{size_suffix}" + await check_disconnected(is_disconnected) + try: + response = await self._http_get(url, priority) + if response.status_code == 200: + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"Non-image content-type from release cover: {content_type}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache( + file_path, + content, + content_type, + {"source": "cover-art-archive"}, + ) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "cover-art-archive") + except ClientDisconnectedError: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to fetch release cover for {release_id}: {e}") + return None + + async def _fetch_release_local_sources( + self, + release_id: str, + file_path: Path, + size: str | None, + release_group_id: str | None = None, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + size_int = int(size) if size and size.isdigit() else 500 + if release_group_id is None and self._mb_repo: + release_group_id = await self._mb_repo.get_release_group_id_from_release(release_id) + + if release_group_id: + result = await self._fetch_from_lidarr(release_group_id, file_path, size=size_int, priority=priority) + if result: + return result + result = await self._fetch_from_jellyfin(release_group_id, file_path, priority=priority) + if result: + return result + + return await self._fetch_from_jellyfin(release_id, file_path, priority=priority) diff --git a/backend/repositories/coverart_artist.py b/backend/repositories/coverart_artist.py new file mode 100644 index 0000000..c19f0ff --- /dev/null +++ b/backend/repositories/coverart_artist.py @@ -0,0 +1,471 @@ +from __future__ import annotations + +import asyncio +import logging +import re +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar +from urllib.parse import quote + +import httpx +import msgspec + +from core.exceptions import ExternalServiceError, RateLimitedError +from infrastructure.cache.cache_keys import ARTIST_WIKIDATA_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.queue.priority_queue import RequestPriority +from infrastructure.resilience.retry import CircuitOpenError +from infrastructure.validators import validate_audiodb_image_url +from infrastructure.http.disconnect import DisconnectCallable, check_disconnected + +if TYPE_CHECKING: + from services.audiodb_image_service import AudioDBImageService + from repositories.musicbrainz_repository import MusicBrainzRepository + from repositories.lidarr import LidarrRepository + from repositories.jellyfin_repository import JellyfinRepository + +logger = logging.getLogger(__name__) +LOCAL_SOURCE_TIMEOUT_SECONDS = 1.0 +T = TypeVar("T") +DEFAULT_EXTERNAL_USER_AGENT = "Musicseerr/1.0 (contact@musicseerr.com; https://www.musicseerr.com)" + + +class TransientImageFetchError(Exception): + pass + + +TRANSIENT_FETCH_EXCEPTIONS = ( + CircuitOpenError, + httpx.TimeoutException, + httpx.NetworkError, + ExternalServiceError, + RateLimitedError, +) + + +class _WikidataValue(msgspec.Struct): + value: str | None = None + + +class _WikidataSnak(msgspec.Struct): + datavalue: _WikidataValue | None = None + + +class _WikidataClaim(msgspec.Struct): + mainsnak: _WikidataSnak | None = None + + +class _WikidataClaimsResponse(msgspec.Struct): + claims: dict[str, list[_WikidataClaim]] = {} + + +class _CommonsImageInfo(msgspec.Struct): + url: str | None = None + thumburl: str | None = None + + +class _CommonsPage(msgspec.Struct): + imageinfo: list[_CommonsImageInfo] = [] + + +class _CommonsQuery(msgspec.Struct): + pages: dict[str, _CommonsPage] = {} + + +class _CommonsQueryResponse(msgspec.Struct): + query: _CommonsQuery | None = None + + +def _decode_json_response(response: httpx.Response, decode_type: type[T]) -> T: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=decode_type) + return msgspec.convert(response.json(), type=decode_type) + + +def _log_task_error(task: asyncio.Task) -> None: + if not task.cancelled() and task.exception(): + logger.error(f"Background cache write failed: {task.exception()}") + + +def _is_valid_image_content_type(content_type: str) -> bool: + if not content_type: + return False + base_type = content_type.split(";")[0].strip().lower() + return base_type in frozenset([ + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "image/avif", "image/svg+xml", + ]) + + +class ArtistImageFetcher: + def __init__( + self, + http_get_fn, + write_cache_fn, + cache: CacheInterface, + mb_repo: 'MusicBrainzRepository' | None = None, + lidarr_repo: 'LidarrRepository' | None = None, + jellyfin_repo: 'JellyfinRepository' | None = None, + audiodb_service: 'AudioDBImageService' | None = None, + user_agent: str | None = None, + ): + self._http_get = http_get_fn + self._write_disk_cache = write_cache_fn + self._cache = cache + self._mb_repo = mb_repo + self._lidarr_repo = lidarr_repo + self._jellyfin_repo = jellyfin_repo + self._audiodb_service = audiodb_service + resolved_user_agent = user_agent + if not resolved_user_agent or resolved_user_agent.lower().startswith("python-httpx"): + resolved_user_agent = DEFAULT_EXTERNAL_USER_AGENT + self._external_headers = {"User-Agent": resolved_user_agent} + + async def fetch_artist_image( + self, + artist_id: str, + size: int | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> tuple[bytes, str, str] | None: + logger.info(f"[IMG] Fetching artist image for {artist_id[:8]}... (size={size})") + result = None + had_transient_failure = False + last_transient_error: Exception | None = None + try: + await check_disconnected(is_disconnected) + result = await self._fetch_from_audiodb(artist_id, file_path, priority=priority) + except TRANSIENT_FETCH_EXCEPTIONS as exc: + had_transient_failure = True + last_transient_error = exc + logger.warning(f"[IMG:AudioDB] Transient fetch failure for {artist_id[:8]}...: {exc}") + result = None + if result: + logger.info(f"[IMG] SUCCESS from AudioDB for {artist_id[:8]}...") + return result + logger.info(f"[IMG] AudioDB failed for {artist_id[:8]}..., trying local sources") + try: + await check_disconnected(is_disconnected) + local_result, local_transient = await asyncio.wait_for( + self._fetch_local_sources(artist_id, size, file_path, priority=priority), + timeout=LOCAL_SOURCE_TIMEOUT_SECONDS, + ) + if local_transient: + had_transient_failure = True + result = local_result + except TimeoutError: + logger.debug(f"[IMG] Timed out local source lookup for {artist_id[:8]}...") + had_transient_failure = True + last_transient_error = TimeoutError( + f"Timed out local source lookup for {artist_id}" + ) + if result: + logger.info(f"[IMG] SUCCESS from local source for {artist_id[:8]}...") + return result + logger.info(f"[IMG] Local sources missed for {artist_id[:8]}..., trying Wikidata") + try: + await check_disconnected(is_disconnected) + result = await self._fetch_from_wikidata(artist_id, size, file_path, priority=priority) + except TRANSIENT_FETCH_EXCEPTIONS as exc: + had_transient_failure = True + last_transient_error = exc + logger.warning(f"[IMG] Transient Wikidata fetch failure for {artist_id[:8]}...: {exc}") + result = None + if result: + logger.info(f"[IMG] SUCCESS from Wikidata for {artist_id[:8]}...") + return result + logger.info(f"[IMG] FAILED: No image found for {artist_id[:8]}... from any source") + if had_transient_failure: + raise TransientImageFetchError( + f"Transient failure while fetching artist image for {artist_id}" + ) from last_transient_error + return None + + async def _fetch_local_sources( + self, + artist_id: str, + size: int | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[tuple[bytes, str, str] | None, bool]: + had_transient_failure = False + + try: + result = await self._fetch_from_lidarr(artist_id, size, file_path, priority=priority) + except TRANSIENT_FETCH_EXCEPTIONS as exc: + had_transient_failure = True + logger.warning(f"[IMG:Lidarr] Transient failure for {artist_id[:8]}: {exc}") + result = None + + if result: + return result, had_transient_failure + + try: + result = await self._fetch_from_jellyfin(artist_id, file_path, priority=priority) + except TRANSIENT_FETCH_EXCEPTIONS as exc: + had_transient_failure = True + logger.warning(f"[IMG:Jellyfin] Transient failure for {artist_id[:8]}: {exc}") + result = None + + return result, had_transient_failure + + async def _fetch_from_audiodb( + self, + artist_id: str, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if self._audiodb_service is None: + return None + logger.debug(f"[IMG:AudioDB] Fetching artist image for {artist_id[:8]}...") + try: + images = await self._audiodb_service.fetch_and_cache_artist_images(artist_id) + if images is None or images.is_negative or not images.thumb_url: + return None + if not validate_audiodb_image_url(images.thumb_url): + logger.warning("[IMG:AudioDB] Rejected unsafe URL for artist %s", artist_id[:8]) + return None + response = await self._http_get( + images.thumb_url, + priority, + source="audiodb", + headers=self._external_headers, + ) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"[IMG:AudioDB] Non-image content-type ({content_type}) for {artist_id[:8]}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache(file_path, content, content_type, {"source": "audiodb"}) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "audiodb") + except TRANSIENT_FETCH_EXCEPTIONS: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"[IMG:AudioDB] Exception for {artist_id[:8]}: {e}") + return None + + async def _fetch_from_lidarr( + self, + artist_id: str, + size: int | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if not self._lidarr_repo: + logger.debug(f"[IMG:Lidarr] No Lidarr repo configured for {artist_id[:8]}") + return None + if not self._lidarr_repo.is_configured(): + return None + try: + image_url = await self._lidarr_repo.get_artist_image_url(artist_id, size=size or 250) + if not image_url: + logger.info(f"[IMG:Lidarr] No image URL returned for {artist_id[:8]}") + return None + logger.info(f"[IMG:Lidarr] Fetching from URL for {artist_id[:8]}...") + response = await self._http_get( + image_url, + priority, + source="lidarr", + ) + if response.status_code != 200: + logger.warning(f"[IMG:Lidarr] HTTP {response.status_code} for {artist_id[:8]}") + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"[IMG:Lidarr] Non-image content-type ({content_type}) for {artist_id[:8]}") + return None + content = response.content + task = asyncio.create_task(self._write_disk_cache(file_path, content, content_type, {"source": "lidarr"})) + task.add_done_callback(_log_task_error) + return (content, content_type, "lidarr") + except TRANSIENT_FETCH_EXCEPTIONS: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"[IMG:Lidarr] Exception for {artist_id[:8]}: {e}") + return None + + async def _fetch_from_jellyfin( + self, + artist_id: str, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + if not self._jellyfin_repo or not self._jellyfin_repo.is_configured(): + return None + try: + artist = await self._jellyfin_repo.get_artist_by_mbid(artist_id) + if not artist: + return None + image_url = self._jellyfin_repo.get_image_url(artist.id, artist.image_tag) + if not image_url: + return None + response = await self._http_get( + image_url, + priority, + source="jellyfin", + headers=self._jellyfin_repo.get_auth_headers(), + ) + if response.status_code != 200: + return None + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"[IMG:Jellyfin] Non-image content-type ({content_type}) for {artist_id[:8]}") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache(file_path, content, content_type, {"source": "jellyfin"}) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "jellyfin") + except TRANSIENT_FETCH_EXCEPTIONS: + raise + except Exception as e: # noqa: BLE001 + logger.warning(f"[IMG:Jellyfin] Exception for {artist_id[:8]}: {e}") + return None + + async def _fetch_from_wikidata( + self, + artist_id: str, + size: int | None, + file_path: Path, + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + ) -> tuple[bytes, str, str] | None: + cache_key = f"{ARTIST_WIKIDATA_PREFIX}{artist_id}" + wikidata_url = await self._cache.get(cache_key) + if wikidata_url is None: + wikidata_url = await self._lookup_wikidata_url(artist_id) + if wikidata_url: + await self._cache.set(cache_key, wikidata_url, ttl_seconds=86400) + if not wikidata_url: + return None + try: + match = re.search(r'/(?:wiki|entity)/(Q\d+)', wikidata_url) + wikidata_id = match.group(1) if match else None + if not wikidata_id: + logger.debug(f"Could not parse Wikidata Q-id from URL: {wikidata_url}") + return None + api_url = ( + f"https://www.wikidata.org/w/api.php" + f"?action=wbgetclaims&entity={wikidata_id}&property=P18&format=json" + ) + response = await self._http_get( + api_url, + priority, + source="wikidata", + headers=self._external_headers, + ) + if response.status_code != 200: + return None + data = _decode_json_response(response, _WikidataClaimsResponse) + image_claims = data.claims.get("P18", []) + if not image_claims: + return None + first_claim = image_claims[0] + filename = ( + first_claim.mainsnak.datavalue.value + if first_claim.mainsnak and first_claim.mainsnak.datavalue + else None + ) + if not filename: + return None + commons_api = ( + f"https://commons.wikimedia.org/w/api.php" + f"?action=query&titles=File:{quote(filename)}" + f"&prop=imageinfo&iiprop=url&format=json" + ) + if size: + commons_api += f"&iiurlwidth={size}" + commons_response = await self._http_get( + commons_api, + priority, + source="wikimedia", + headers=self._external_headers, + ) + if commons_response.status_code != 200: + return None + commons_data = _decode_json_response(commons_response, _CommonsQueryResponse) + pages = commons_data.query.pages if commons_data.query else {} + image_url = None + for page in pages.values(): + imageinfo = page.imageinfo + if imageinfo: + if size and imageinfo[0].thumburl: + image_url = imageinfo[0].thumburl + else: + image_url = imageinfo[0].url + break + if not image_url: + return None + response = await self._http_get( + image_url, + priority, + source="wikimedia", + headers=self._external_headers, + ) + if response.status_code == 200: + content_type = response.headers.get("content-type", "") + if not _is_valid_image_content_type(content_type): + logger.warning(f"[IMG:Wikidata] Non-image content-type ({content_type})") + return None + content = response.content + task = asyncio.create_task( + self._write_disk_cache( + file_path, + content, + content_type, + {"wikidata_id": wikidata_id, "source": "wikidata"}, + ) + ) + task.add_done_callback(_log_task_error) + return (content, content_type, "wikidata") + except TRANSIENT_FETCH_EXCEPTIONS: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Error fetching artist image for {artist_id}: {e}") + return None + + async def _lookup_wikidata_url(self, artist_id: str) -> str | None: + logger.info(f"[IMG:Wikidata] Looking up wikidata URL for {artist_id[:8]}...") + if not self._mb_repo: + logger.warning(f"[IMG:Wikidata] MusicBrainz repository not available for {artist_id}") + return None + try: + artist_data = await self._mb_repo.get_artist_relations(artist_id) + if not artist_data: + logger.info(f"[IMG:Wikidata] No artist data from MB for {artist_id[:8]}") + return None + url_relations = artist_data.get("relations", []) + if url_relations: + for url_rel in url_relations: + if isinstance(url_rel, dict): + typ = url_rel.get("type") or url_rel.get("link_type") + url_obj = url_rel.get("url", {}) + target = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + if typ == "wikidata" and target: + logger.info(f"[IMG:Wikidata] Found URL for {artist_id[:8]}: {target}") + return target + external_links = artist_data.get("external_links") or artist_data.get("external_links_list") + if external_links: + for ext in external_links: + try: + ext_type = getattr(ext, "type", None) if not isinstance(ext, dict) else ext.get("type") + ext_url = getattr(ext, "url", None) if not isinstance(ext, dict) else ext.get("url") + except (AttributeError, TypeError): + ext_type = None + ext_url = None + if ext_type == "wikidata" and ext_url: + return ext_url + logger.info(f"[IMG:Wikidata] No wikidata link found for {artist_id[:8]}") + return None + except TRANSIENT_FETCH_EXCEPTIONS: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"[IMG:Wikidata] Failed to fetch artist metadata for {artist_id}: {e}") + return None diff --git a/backend/repositories/coverart_disk_cache.py b/backend/repositories/coverart_disk_cache.py new file mode 100644 index 0000000..cedd4be --- /dev/null +++ b/backend/repositories/coverart_disk_cache.py @@ -0,0 +1,419 @@ +import asyncio +import hashlib +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Optional + +import aiofiles +import msgspec + +logger = logging.getLogger(__name__) + + +def _encode_json(data: object) -> str: + return msgspec.json.encode(data).decode("utf-8") + + +def _decode_json(text: str) -> dict[str, Any]: + return msgspec.json.decode(text.encode("utf-8"), type=dict[str, Any]) + + +def _log_task_error(task: asyncio.Task) -> None: + if not task.cancelled() and task.exception(): + logger.error(f"Background task failed: {task.exception()}") + + +VALID_IMAGE_CONTENT_TYPES = frozenset([ + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "image/avif", "image/svg+xml", +]) + + +def is_valid_image_content_type(content_type: str) -> bool: + if not content_type: + return False + base_type = content_type.split(";")[0].strip().lower() + return base_type in VALID_IMAGE_CONTENT_TYPES + + +def get_cache_filename(identifier: str, suffix: str = "") -> str: + content = f"{identifier}:{suffix}" + hash_digest = hashlib.sha1(content.encode()).hexdigest() + return hash_digest + + +class CoverDiskCache: + def __init__( + self, + cache_dir: Path, + max_size_mb: Optional[int] = None, + eviction_check_interval_seconds: int = 60, + non_monitored_ttl_seconds: int = 86400, + ): + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.max_size_bytes = max_size_mb * 1024 * 1024 if max_size_mb and max_size_mb > 0 else None + self._eviction_check_interval_seconds = max(eviction_check_interval_seconds, 1) + self._non_monitored_ttl_seconds = max(non_monitored_ttl_seconds, 1) + self._last_eviction_check = 0.0 + self._eviction_lock = asyncio.Lock() + + async def write( + self, + file_path: Path, + content: bytes, + content_type: str, + extra_meta: Optional[dict[str, object]] = None, + is_monitored: bool = False, + ) -> None: + try: + now = datetime.now().timestamp() + ttl = None if is_monitored else self._non_monitored_ttl_seconds + content_sha1 = hashlib.sha1(content).hexdigest() + meta = { + 'content_type': content_type, + 'created_at': now, + 'last_accessed': now, + 'size_bytes': len(content), + 'is_monitored': is_monitored, + 'content_sha1': content_sha1, + } + if ttl: + meta['expires_at'] = now + ttl + if extra_meta: + meta.update(extra_meta) + + async def write_content(): + async with aiofiles.open(file_path, 'wb') as f: + await f.write(content) + + async def write_meta(): + meta_path = file_path.with_suffix('.meta.json') + async with aiofiles.open(meta_path, 'w') as f: + await f.write(_encode_json(meta)) + + async def write_wikidata(): + if extra_meta and 'wikidata_url' in extra_meta: + wikidata_path = file_path.with_suffix('.wikidata') + async with aiofiles.open(wikidata_path, 'w') as f: + await f.write(str(extra_meta['wikidata_url'])) + + await asyncio.gather(write_content(), write_meta(), write_wikidata()) + await self.enforce_size_limit() + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to write disk cache: {e}") + + async def write_negative( + self, + file_path: Path, + ttl_seconds: int = 4 * 3600, + ) -> None: + try: + now = datetime.now().timestamp() + meta = { + "created_at": now, + "last_accessed": now, + "expires_at": now + ttl_seconds, + "negative": True, + "is_monitored": False, + } + meta_path = file_path.with_suffix(".meta.json") + async with aiofiles.open(meta_path, "w") as f: + await f.write(_encode_json(meta)) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to write negative disk cache: {e}") + + async def is_negative(self, file_path: Path) -> bool: + meta_path = file_path.with_suffix(".meta.json") + if not meta_path.exists(): + return False + try: + async with aiofiles.open(meta_path, "r") as f: + meta = _decode_json(await f.read()) + + if not meta.get("negative", False): + return False + + expires_at = meta.get("expires_at") + if expires_at is None: + return False + + now = datetime.now().timestamp() + if now > expires_at: + meta_path.unlink(missing_ok=True) + return False + + task = asyncio.create_task(self._update_meta_access(meta_path, meta)) + task.add_done_callback(_log_task_error) + return True + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to read negative disk cache: {e}") + return False + + async def read( + self, + file_path: Path, + extra_keys: Optional[list[str]] = None + ) -> Optional[tuple[bytes, str, Optional[dict]]]: + if not file_path.exists(): + return None + try: + async def read_content(): + async with aiofiles.open(file_path, 'rb') as f: + return await f.read() + + async def read_meta(): + meta_path = file_path.with_suffix('.meta.json') + if meta_path.exists(): + async with aiofiles.open(meta_path, 'r') as f: + return _decode_json(await f.read()) + return None + + content, meta = await asyncio.gather(read_content(), read_meta()) + if not content: + return None + content_type = 'image/jpeg' + extra_data = {} + if meta: + content_type = meta.get('content_type', content_type) + if 'expires_at' in meta: + now = datetime.now().timestamp() + if now > meta['expires_at'] and not meta.get('is_monitored', False): + file_path.unlink(missing_ok=True) + file_path.with_suffix('.meta.json').unlink(missing_ok=True) + return None + if extra_keys: + async def read_extra_key(key: str): + if key in meta: + return key, meta.get(key) + ext_path = file_path.with_suffix(f'.{key}') + if ext_path.exists(): + async with aiofiles.open(ext_path, 'r') as f: + return key, await f.read() + return key, None + results = await asyncio.gather(*[read_extra_key(k) for k in extra_keys]) + for k, v in results: + if v is not None: + extra_data[k] = v + task = asyncio.create_task(self._update_meta_access(file_path.with_suffix('.meta.json'), meta)) + task.add_done_callback(_log_task_error) + return content, content_type, extra_data if extra_data else None + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to read disk cache: {e}") + return None + + async def _update_meta_access(self, meta_file: Path, meta: dict) -> None: + if meta is None or not meta_file.exists(): + return + try: + meta['last_accessed'] = datetime.now().timestamp() + async with aiofiles.open(meta_file, 'w') as f: + await f.write(_encode_json(meta)) + except OSError as exc: + logger.debug("Failed to update coverart disk cache meta %s: %s", meta_file, exc) + + async def get_content_hash(self, file_path: Path) -> Optional[str]: + meta_path = file_path.with_suffix('.meta.json') + if not meta_path.exists(): + return None + + try: + async with aiofiles.open(meta_path, 'r') as f: + meta = _decode_json(await f.read()) + + if 'expires_at' in meta and not meta.get('is_monitored', False): + now = datetime.now().timestamp() + if now > meta['expires_at']: + file_path.unlink(missing_ok=True) + meta_path.unlink(missing_ok=True) + file_path.with_suffix('.wikidata').unlink(missing_ok=True) + return None + + content_hash = meta.get('content_sha1') + if content_hash: + task = asyncio.create_task(self._update_meta_access(meta_path, meta)) + task.add_done_callback(_log_task_error) + return str(content_hash) + + if not file_path.exists(): + return None + + async with aiofiles.open(file_path, 'rb') as f: + content = await f.read() + + if not content: + return None + + content_hash = hashlib.sha1(content).hexdigest() + meta['content_sha1'] = content_hash + await self._update_meta_access(meta_path, meta) + return content_hash + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get disk cache content hash: {e}") + return None + + async def enforce_size_limit(self, force: bool = False) -> int: + if self.max_size_bytes is None: + return 0 + + now = datetime.now().timestamp() + if not force and (now - self._last_eviction_check) < self._eviction_check_interval_seconds: + return 0 + + async with self._eviction_lock: + now = datetime.now().timestamp() + if not force and (now - self._last_eviction_check) < self._eviction_check_interval_seconds: + return 0 + + self._last_eviction_check = now + + total_bytes = 0 + candidates: list[tuple[float, Path, int]] = [] + + for file_path in self.cache_dir.glob('*.bin'): + try: + size_bytes = file_path.stat().st_size + except FileNotFoundError: + continue + + total_bytes += size_bytes + + meta_path = file_path.with_suffix('.meta.json') + meta: dict = {} + if meta_path.exists(): + try: + async with aiofiles.open(meta_path, 'r') as f: + meta = _decode_json(await f.read()) + except Exception: # noqa: BLE001 + meta = {} + + if meta.get('is_monitored', False): + continue + + last_accessed = float(meta.get('last_accessed', meta.get('created_at', 0.0)) or 0.0) + candidates.append((last_accessed, file_path, size_bytes)) + + if total_bytes <= self.max_size_bytes: + return 0 + + bytes_to_free = total_bytes - self.max_size_bytes + bytes_freed = 0 + + candidates.sort(key=lambda item: item[0]) + + for _, file_path, size_bytes in candidates: + file_path.unlink(missing_ok=True) + file_path.with_suffix('.meta.json').unlink(missing_ok=True) + file_path.with_suffix('.wikidata').unlink(missing_ok=True) + bytes_freed += size_bytes + + if bytes_freed >= bytes_to_free: + break + + if bytes_freed > 0: + logger.info( + "Evicted %d bytes from cover cache (target max=%d bytes)", + bytes_freed, + self.max_size_bytes, + ) + + return bytes_freed + + async def delete_by_identifiers(self, identifiers: list[tuple[str, str]]) -> int: + count = 0 + for identifier, suffix in identifiers: + cache_filename = get_cache_filename(identifier, suffix) + bin_path = self.cache_dir / f"{cache_filename}.bin" + existed = bin_path.exists() + bin_path.unlink(missing_ok=True) + (self.cache_dir / f"{cache_filename}.meta.json").unlink(missing_ok=True) + (self.cache_dir / f"{cache_filename}.wikidata").unlink(missing_ok=True) + if existed: + count += 1 + return count + + def cleanup_expired(self) -> int: + """Sync — call via asyncio.to_thread() from background tasks.""" + count = 0 + now = datetime.now().timestamp() + if not self.cache_dir.exists(): + return 0 + for meta_path in self.cache_dir.glob("*.meta.json"): + try: + meta = _decode_json(meta_path.read_text()) + except Exception: # noqa: BLE001 + continue + if not meta.get("is_monitored", False) and "expires_at" in meta and meta["expires_at"] < now: + stem = meta_path.name.removesuffix(".meta.json") + (self.cache_dir / f"{stem}.bin").unlink(missing_ok=True) + meta_path.unlink(missing_ok=True) + (self.cache_dir / f"{stem}.wikidata").unlink(missing_ok=True) + count += 1 + if count: + logger.info("Expired cover cache cleanup: removed %d entries", count) + return count + + def demote_orphaned(self, valid_hashes: set[str]) -> int: + """Sync — call via asyncio.to_thread() from background tasks.""" + count = 0 + now = datetime.now().timestamp() + if not self.cache_dir.exists(): + return 0 + for meta_path in self.cache_dir.glob("*.meta.json"): + try: + meta = _decode_json(meta_path.read_text()) + except Exception: # noqa: BLE001 + continue + if not meta.get("is_monitored", False): + continue + stem = meta_path.name.removesuffix(".meta.json") + if stem in valid_hashes: + continue + meta["is_monitored"] = False + meta["expires_at"] = now + 48 * 3600 + try: + meta_path.write_text(_encode_json(meta)) + except Exception: # noqa: BLE001 + continue + count += 1 + if count: + logger.info("Demoted %d orphaned monitored covers to expiring", count) + return count + + def get_file_path(self, identifier: str, suffix: str) -> Path: + cache_filename = get_cache_filename(identifier, suffix) + return self.cache_dir / f"{cache_filename}.bin" + + async def promote_to_persistent(self, identifier: str, identifier_type: str = "album") -> bool: + try: + if identifier_type == "album": + prefixes = ["rg_"] + sizes = ["250", "500"] + else: + prefixes = ["artist_"] + sizes = ["250", "500"] + for prefix in prefixes: + for size in sizes: + full_id = f"{prefix}{identifier}" if prefix == "artist_" else f"{prefix}{identifier}" + if prefix == "artist_": + full_id = f"artist_{identifier}_{size}" + suffix = "img" + else: + suffix = size + cache_filename = get_cache_filename(full_id, suffix) + file_path = self.cache_dir / f"{cache_filename}.bin" + meta_path = file_path.with_suffix('.meta.json') + if file_path.exists() and meta_path.exists(): + async with aiofiles.open(meta_path, 'r') as f: + meta = _decode_json(await f.read()) + if not meta.get('is_monitored', False): + meta['is_monitored'] = True + meta.pop('expires_at', None) + async with aiofiles.open(meta_path, 'w') as f: + await f.write(_encode_json(meta)) + logger.debug(f"Promoted cover cache to persistent: {identifier_type}={identifier}, size={size}") + return True + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to promote cover cache to persistent: {e}") + return False diff --git a/backend/repositories/coverart_repository.py b/backend/repositories/coverart_repository.py new file mode 100644 index 0000000..59359a1 --- /dev/null +++ b/backend/repositories/coverart_repository.py @@ -0,0 +1,760 @@ +import asyncio +import hashlib +import logging +from collections import OrderedDict +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from pathlib import Path +from typing import Optional, TYPE_CHECKING +from urllib.parse import urlparse + +import aiofiles +import httpx +import msgspec + +from core.exceptions import ExternalServiceError, RateLimitedError, ClientDisconnectedError +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.cache_keys import ARTIST_WIKIDATA_PREFIX +from infrastructure.resilience.retry import with_retry, CircuitBreaker, CircuitOpenError +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from infrastructure.validators import validate_mbid +from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue +from infrastructure.http.deduplication import RequestDeduplicator +from infrastructure.http.disconnect import DisconnectCallable +from repositories.coverart_artist import ArtistImageFetcher, TransientImageFetchError +from repositories.coverart_album import AlbumCoverFetcher +from repositories.coverart_disk_cache import CoverDiskCache +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +if TYPE_CHECKING: + from repositories.musicbrainz_repository import MusicBrainzRepository + from repositories.lidarr import LidarrRepository + from repositories.jellyfin_repository import JellyfinRepository + from services.audiodb_image_service import AudioDBImageService + +logger = logging.getLogger(__name__) + +_SOURCE = "coverart" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +COVER_ART_ARCHIVE_BASE = "https://coverartarchive.org" +DEFAULT_CACHE_DIR = Path("/app/cache/covers") +COVER_NEGATIVE_TTL_SECONDS = 4 * 3600 +COVER_MEMORY_MAX_ENTRIES = 128 +COVER_MEMORY_MAX_BYTES = 16 * 1024 * 1024 + +_coverart_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart" +) + +_lidarr_cover_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart_lidarr", +) + +_jellyfin_cover_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart_jellyfin", +) + +_wikidata_cover_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart_wikidata", +) + +_wikimedia_cover_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart_wikimedia", +) + +_generic_cover_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="coverart_generic", +) + +_coverart_rate_limiter = TokenBucketRateLimiter(rate=1.0, capacity=1) + +_deduplicator = RequestDeduplicator() + + +class _CoverMemoryEntry(msgspec.Struct): + content: bytes + content_type: str + source: str + content_sha1: str + size_bytes: int + + +class _CoverMemoryLRU: + def __init__(self, max_entries: int, max_bytes: int): + self._max_entries = max(1, max_entries) + self._max_bytes = max(1, max_bytes) + self._entries: OrderedDict[str, _CoverMemoryEntry] = OrderedDict() + self._total_bytes = 0 + self._lock = asyncio.Lock() + + async def get(self, key: str) -> Optional[_CoverMemoryEntry]: + async with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + self._entries.move_to_end(key) + return entry + + async def get_hash(self, key: str) -> Optional[str]: + entry = await self.get(key) + if entry is None: + return None + return entry.content_sha1 + + async def set(self, key: str, content: bytes, content_type: str, source: str) -> None: + content_size = len(content) + if content_size <= 0: + return + + async with self._lock: + existing = self._entries.pop(key, None) + if existing is not None: + self._total_bytes -= existing.size_bytes + + entry = _CoverMemoryEntry( + content=content, + content_type=content_type, + source=source, + content_sha1=hashlib.sha1(content).hexdigest(), + size_bytes=content_size, + ) + self._entries[key] = entry + self._entries.move_to_end(key) + self._total_bytes += content_size + + while self._entries and ( + len(self._entries) > self._max_entries or self._total_bytes > self._max_bytes + ): + _, evicted = self._entries.popitem(last=False) + self._total_bytes -= evicted.size_bytes + + async def evict(self, key: str) -> None: + async with self._lock: + entry = self._entries.pop(key, None) + if entry is not None: + self._total_bytes -= entry.size_bytes + + +def _log_task_error(task: asyncio.Task) -> None: + if not task.cancelled() and task.exception(): + logger.error(f"Background task failed: {task.exception()}") + + + +class CoverArtRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + cache: CacheInterface, + mb_repo: Optional['MusicBrainzRepository'] = None, + lidarr_repo: Optional['LidarrRepository'] = None, + jellyfin_repo: Optional['JellyfinRepository'] = None, + audiodb_service: Optional['AudioDBImageService'] = None, + cache_dir: Path = DEFAULT_CACHE_DIR, + cover_cache_max_size_mb: Optional[int] = None, + cover_memory_cache_max_entries: int = COVER_MEMORY_MAX_ENTRIES, + cover_memory_cache_max_bytes: int = COVER_MEMORY_MAX_BYTES, + cover_non_monitored_ttl_seconds: int = 604800, # 7 days; non-monitored covers change rarely + ): + self._client = http_client + self._cache = cache + self._mb_repo = mb_repo + self._lidarr_repo = lidarr_repo + self._jellyfin_repo = jellyfin_repo + self.cache_dir = cache_dir + self.cache_dir.mkdir(parents=True, exist_ok=True) + self._disk_cache = CoverDiskCache( + cache_dir, + max_size_mb=cover_cache_max_size_mb, + non_monitored_ttl_seconds=cover_non_monitored_ttl_seconds, + ) + self._cover_memory_cache = _CoverMemoryLRU( + max_entries=cover_memory_cache_max_entries, + max_bytes=cover_memory_cache_max_bytes, + ) + self._artist_fetcher = ArtistImageFetcher( + http_get_fn=self._http_get, + write_cache_fn=self._disk_cache.write, + cache=cache, + mb_repo=mb_repo, + lidarr_repo=lidarr_repo, + jellyfin_repo=jellyfin_repo, + audiodb_service=audiodb_service, + user_agent=self._client.headers.get("User-Agent"), + ) + self._album_fetcher = AlbumCoverFetcher( + http_get_fn=self._http_get, + write_cache_fn=self._disk_cache.write, + lidarr_repo=lidarr_repo, + mb_repo=mb_repo, + jellyfin_repo=jellyfin_repo, + audiodb_service=audiodb_service, + ) + + try: + task = asyncio.create_task(self._disk_cache.enforce_size_limit(force=True)) + task.add_done_callback(_log_task_error) + except RuntimeError: + logger.debug("No running event loop to enforce cover cache size at initialization") + + @property + def disk_cache(self) -> CoverDiskCache: + return self._disk_cache + + async def delete_covers_for_album(self, album_mbid: str) -> int: + identifiers = [(f"rg_{album_mbid}", suffix) for suffix in ("500", "250", "1200", "orig")] + count = await self._disk_cache.delete_by_identifiers(identifiers) + for identifier, suffix in identifiers: + await self._cover_memory_cache.evict(f"{identifier}:{suffix}") + return count + + async def delete_covers_for_artist(self, artist_mbid: str) -> int: + identifiers = [(f"artist_{artist_mbid}_{size}", "img") for size in ("250", "500")] + identifiers.append((f"artist_{artist_mbid}", "img")) + count = await self._disk_cache.delete_by_identifiers(identifiers) + for identifier, suffix in identifiers: + await self._cover_memory_cache.evict(f"{identifier}:{suffix}") + return count + + @staticmethod + def _memory_cache_key(identifier: str, suffix: str) -> str: + return f"{identifier}:{suffix}" + + @staticmethod + def _is_successful_image_payload(content: bytes, content_type: str) -> bool: + return bool(content) and content_type.lower().startswith("image/") + + async def _memory_get( + self, + identifier: str, + suffix: str, + ) -> Optional[tuple[bytes, str, str]]: + entry = await self._cover_memory_cache.get(self._memory_cache_key(identifier, suffix)) + if entry is None: + return None + return entry.content, entry.content_type, entry.source + + async def _memory_get_hash(self, identifier: str, suffix: str) -> Optional[str]: + return await self._cover_memory_cache.get_hash(self._memory_cache_key(identifier, suffix)) + + async def _memory_set_from_result( + self, + identifier: str, + suffix: str, + result: Optional[tuple[bytes, str, str]], + ) -> None: + if result is None: + return + + content, content_type, source = result + if not self._is_successful_image_payload(content, content_type): + return + + await self._cover_memory_cache.set( + self._memory_cache_key(identifier, suffix), + content, + content_type, + source, + ) + + @staticmethod + def _parse_retry_after_seconds(retry_after: Optional[str]) -> Optional[float]: + if not retry_after: + return None + + try: + seconds = float(retry_after) + return seconds if seconds > 0 else None + except ValueError: + pass + + try: + parsed_dt = parsedate_to_datetime(retry_after) + if parsed_dt.tzinfo is None: + parsed_dt = parsed_dt.replace(tzinfo=timezone.utc) + seconds = (parsed_dt - datetime.now(timezone.utc)).total_seconds() + return seconds if seconds > 0 else None + except (TypeError, ValueError): + return None + + @staticmethod + def _infer_source_from_url(url: str) -> str: + netloc = urlparse(url).netloc.lower() + if "coverartarchive.org" in netloc: + return "coverart" + if "wikidata.org" in netloc: + return "wikidata" + if "wikimedia.org" in netloc: + return "wikimedia" + return "generic" + + @staticmethod + def _raise_retryable_status(response: httpx.Response, source: str, url: str) -> None: + status_code = response.status_code + + if status_code == 429: + retry_after = CoverArtRepository._parse_retry_after_seconds( + response.headers.get("Retry-After") + ) + raise RateLimitedError( + f"{source} rate limited (429): {url}", + retry_after_seconds=retry_after, + ) + + if 500 <= status_code <= 599: + raise ExternalServiceError(f"{source} transient error ({status_code})", url) + + async def _perform_http_get( + self, + url: str, + priority: RequestPriority, + source: str, + **kwargs, + ) -> httpx.Response: + priority_mgr = get_priority_queue() + semaphore = await priority_mgr.acquire_slot(priority) + async with semaphore: + response = await self._client.get(url, **kwargs) + self._raise_retryable_status(response, source, url) + return response + + @with_retry( + max_attempts=3, + circuit_breaker=_coverart_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError, RateLimitedError), + ) + async def _http_get_coverart(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + await _coverart_rate_limiter.acquire() + return await self._perform_http_get(url, priority, "coverart", **kwargs) + + @with_retry( + max_attempts=3, + circuit_breaker=_lidarr_cover_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _http_get_lidarr(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + return await self._perform_http_get(url, priority, "lidarr", **kwargs) + + @with_retry( + max_attempts=3, + circuit_breaker=_jellyfin_cover_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _http_get_jellyfin(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + return await self._perform_http_get(url, priority, "jellyfin", **kwargs) + + @with_retry( + max_attempts=3, + circuit_breaker=_wikidata_cover_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _http_get_wikidata(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + return await self._perform_http_get(url, priority, "wikidata", **kwargs) + + @with_retry( + max_attempts=3, + circuit_breaker=_wikimedia_cover_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _http_get_wikimedia(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + return await self._perform_http_get(url, priority, "wikimedia", **kwargs) + + @with_retry( + max_attempts=3, + circuit_breaker=_generic_cover_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _http_get_generic(self, url: str, priority: RequestPriority, **kwargs) -> httpx.Response: + return await self._perform_http_get(url, priority, "generic", **kwargs) + + async def _http_get( + self, + url: str, + priority: RequestPriority, + source: Optional[str] = None, + **kwargs, + ) -> httpx.Response: + request_source = source or self._infer_source_from_url(url) + if request_source == "coverart": + return await self._http_get_coverart(url, priority, **kwargs) + if request_source == "lidarr": + return await self._http_get_lidarr(url, priority, **kwargs) + if request_source == "jellyfin": + return await self._http_get_jellyfin(url, priority, **kwargs) + if request_source == "wikidata": + return await self._http_get_wikidata(url, priority, **kwargs) + if request_source == "wikimedia": + return await self._http_get_wikimedia(url, priority, **kwargs) + return await self._http_get_generic(url, priority, **kwargs) + + async def get_release_group_cover_etag( + self, + release_group_id: str, + size: Optional[str] = "500", + ) -> Optional[str]: + try: + release_group_id = validate_mbid(release_group_id, "release-group") + except ValueError: + return None + + identifier = f"rg_{release_group_id}" + suffix = size or "orig" + + if content_hash := await self._memory_get_hash(identifier, suffix): + return content_hash + + file_path = self._disk_cache.get_file_path(identifier, suffix) + return await self._disk_cache.get_content_hash(file_path) + + async def get_release_cover_etag( + self, + release_id: str, + size: Optional[str] = "500", + ) -> Optional[str]: + try: + release_id = validate_mbid(release_id, "release") + except ValueError: + return None + + identifier = f"rel_{release_id}" + suffix = size or "orig" + + if content_hash := await self._memory_get_hash(identifier, suffix): + return content_hash + + file_path = self._disk_cache.get_file_path(identifier, suffix) + return await self._disk_cache.get_content_hash(file_path) + + async def get_artist_image_etag( + self, + artist_id: str, + size: Optional[int] = None, + ) -> Optional[str]: + try: + artist_id = validate_mbid(artist_id, "artist") + except ValueError: + return None + + size_suffix = f"_{size}" if size else "" + identifier = f"artist_{artist_id}{size_suffix}" + + if content_hash := await self._memory_get_hash(identifier, "img"): + return content_hash + + file_path = self._disk_cache.get_file_path(identifier, "img") + + content_hash = await self._disk_cache.get_content_hash(file_path) + if content_hash: + return content_hash + + if size and size != 250: + fallback_identifier = f"artist_{artist_id}_250" + if content_hash := await self._memory_get_hash(fallback_identifier, "img"): + return content_hash + fallback_path = self._disk_cache.get_file_path(fallback_identifier, "img") + return await self._disk_cache.get_content_hash(fallback_path) + + return None + + async def get_artist_image(self, artist_id: str, size: Optional[int] = None, priority: RequestPriority = RequestPriority.IMAGE_FETCH, is_disconnected: DisconnectCallable | None = None) -> Optional[tuple[bytes, str, str]]: + try: + artist_id = validate_mbid(artist_id, "artist") + except ValueError as e: + logger.warning(f"Invalid artist MBID: {e}") + return None + + size_suffix = f"_{size}" if size else "" + identifier = f"artist_{artist_id}{size_suffix}" + file_path = self._disk_cache.get_file_path(identifier, "img") + + if cached_memory := await self._memory_get(identifier, "img"): + logger.debug(f"Cache HIT (memory): Artist image {artist_id[:8]}...") + return cached_memory + + if cached := await self._disk_cache.read(file_path, ["source", "wikidata_id"]): + logger.debug(f"Cache HIT (disk): Artist image {artist_id[:8]}...") + source = "wikidata" + if cached[2] and isinstance(cached[2], dict): + source = cached[2].get("source") or source + result = (cached[0], cached[1], source) + await self._memory_set_from_result(identifier, "img", result) + return result + + if size and size != 250: + fallback_identifier = f"artist_{artist_id}_250" + if cached_memory := await self._memory_get(fallback_identifier, "img"): + logger.debug(f"Cache HIT (memory - fallback 250px): Artist image {artist_id[:8]}...") + return cached_memory + + fallback_path = self._disk_cache.get_file_path(fallback_identifier, "img") + if cached := await self._disk_cache.read(fallback_path, ["source", "wikidata_id"]): + logger.debug(f"Cache HIT (disk - fallback 250px): Artist image {artist_id[:8]}...") + source = "wikidata" + if cached[2] and isinstance(cached[2], dict): + source = cached[2].get("source") or source + result = (cached[0], cached[1], source) + await self._memory_set_from_result(fallback_identifier, "img", result) + return result + + if await self._disk_cache.is_negative(file_path): + logger.debug(f"Cache HIT (disk-negative): Artist image {artist_id[:8]}...") + return None + + logger.debug(f"Cache MISS (disk): Artist image {artist_id[:8]}... - fetching from Wikidata") + + dedupe_key = f"artist:img:{artist_id}:{size}" + try: + result = await _deduplicator.dedupe( + dedupe_key, + lambda: self._artist_fetcher.fetch_artist_image(artist_id, size, file_path, priority=priority, is_disconnected=is_disconnected) + ) + except ClientDisconnectedError: + raise + except (TransientImageFetchError, CircuitOpenError, httpx.HTTPError, ExternalServiceError, RateLimitedError) as e: + logger.warning( + "Transient artist image fetch failure for %s: %s", + artist_id[:8], + e, + ) + _record_degradation(f"Artist image fetch failed for {artist_id[:8]}: {e}") + return None + + if result is None: + await self._disk_cache.write_negative(file_path, ttl_seconds=COVER_NEGATIVE_TTL_SECONDS) + else: + await self._memory_set_from_result(identifier, "img", result) + return result + + async def get_release_group_cover( + self, + release_group_id: str, + size: Optional[str] = "500", + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> Optional[tuple[bytes, str, str]]: + try: + release_group_id = validate_mbid(release_group_id, "release-group") + except ValueError as e: + logger.warning(f"Invalid release-group MBID: {e}") + return None + + identifier = f"rg_{release_group_id}" + suffix = size or 'orig' + file_path = self._disk_cache.get_file_path(identifier, suffix) + + if cached_memory := await self._memory_get(identifier, suffix): + logger.debug(f"Cache HIT (memory): Album cover {release_group_id[:8]}...") + return cached_memory + + if cached := await self._disk_cache.read(file_path, ["source"]): + logger.debug(f"Cache HIT (disk): Album cover {release_group_id[:8]}...") + source = "cover-art-archive" + if cached[2] and isinstance(cached[2], dict): + source = cached[2].get("source") or source + result = (cached[0], cached[1], source) + await self._memory_set_from_result(identifier, suffix, result) + return result + + if await self._disk_cache.is_negative(file_path): + logger.debug(f"Cache HIT (disk-negative): Album cover {release_group_id[:8]}...") + return None + + logger.debug(f"Cache MISS (disk): Album cover {release_group_id[:8]}... - fetching from CoverArtArchive") + + dedupe_key = f"cover:rg:{release_group_id}:{size}" + result = await _deduplicator.dedupe( + dedupe_key, + lambda: self._album_fetcher.fetch_release_group_cover(release_group_id, size, file_path, priority=priority, is_disconnected=is_disconnected) + ) + if result is None: + await self._disk_cache.write_negative(file_path, ttl_seconds=COVER_NEGATIVE_TTL_SECONDS) + else: + await self._memory_set_from_result(identifier, suffix, result) + return result + + async def get_release_cover( + self, + release_id: str, + size: Optional[str] = "500", + priority: RequestPriority = RequestPriority.IMAGE_FETCH, + is_disconnected: DisconnectCallable | None = None, + ) -> Optional[tuple[bytes, str, str]]: + try: + release_id = validate_mbid(release_id, "release") + except ValueError as e: + logger.warning(f"Invalid release MBID: {e}") + return None + + identifier = f"rel_{release_id}" + suffix = size or 'orig' + file_path = self._disk_cache.get_file_path(identifier, suffix) + + if cached_memory := await self._memory_get(identifier, suffix): + logger.debug(f"Cache HIT (memory): Release cover {release_id[:8]}...") + return cached_memory + + if cached := await self._disk_cache.read(file_path, ["source"]): + source = "cover-art-archive" + if cached[2] and isinstance(cached[2], dict): + source = cached[2].get("source") or source + result = (cached[0], cached[1], source) + await self._memory_set_from_result(identifier, suffix, result) + return result + + if await self._disk_cache.is_negative(file_path): + logger.debug(f"Cache HIT (disk-negative): Release cover {release_id[:8]}...") + return None + + dedupe_key = f"cover:rel:{release_id}:{size}" + result = await _deduplicator.dedupe( + dedupe_key, + lambda: self._album_fetcher.fetch_release_cover(release_id, size, file_path, priority=priority, is_disconnected=is_disconnected) + ) + if result is None: + await self._disk_cache.write_negative(file_path, ttl_seconds=COVER_NEGATIVE_TTL_SECONDS) + else: + await self._memory_set_from_result(identifier, suffix, result) + return result + + async def batch_prefetch_covers( + self, + album_ids: list[str], + size: str = "250", + max_concurrent: int = 5 + ) -> None: + if not album_ids: + return + + from infrastructure.validators import is_valid_mbid + valid_album_ids = [aid for aid in album_ids if is_valid_mbid(aid)] + invalid_count = len(album_ids) - len(valid_album_ids) + + if not valid_album_ids: + logger.warning("No valid MBIDs in batch prefetch request") + return + + if invalid_count > 0: + invalid_rate = (invalid_count / len(album_ids)) * 100 + logger.warning(f"Filtered out {invalid_count} invalid MBIDs from batch prefetch ({invalid_rate:.1f}%)") + + if invalid_rate > 10.0: + logger.error( + f"HIGH INVALID MBID RATE: {invalid_count}/{len(album_ids)} " + f"({invalid_rate:.1f}%) - This indicates a potential upstream bug!" + ) + + semaphore = asyncio.Semaphore(max_concurrent) + + async def fetch_with_limit(album_id: str): + async with semaphore: + try: + await self.get_release_group_cover(album_id, size) + except Exception as e: # noqa: BLE001 + error_msg = str(e) + if "Invalid" in error_msg or "MBID" in error_msg: + logger.warning(f"Invalid MBID in batch prefetch: {album_id} - {e}") + else: + logger.debug(f"Failed to prefetch cover for {album_id}: {e}") + + logger.info(f"Batch prefetching {len(valid_album_ids)} covers with max {max_concurrent} concurrent requests") + await asyncio.gather(*[fetch_with_limit(aid) for aid in valid_album_ids], return_exceptions=True) + logger.debug(f"Completed batch prefetch of {len(valid_album_ids)} covers") + + async def promote_cover_to_persistent(self, identifier: str, identifier_type: str = "album") -> bool: + return await self._disk_cache.promote_to_persistent(identifier, identifier_type) + + async def debug_artist_image(self, artist_id: str, debug_info: dict) -> dict: + file_path_250 = self._disk_cache.get_file_path(f"artist_{artist_id}_250", "img") + file_path_500 = self._disk_cache.get_file_path(f"artist_{artist_id}_500", "img") + + debug_info["disk_cache"]["exists_250"] = file_path_250.exists() + debug_info["disk_cache"]["exists_500"] = file_path_500.exists() + debug_info["disk_cache"]["negative_250"] = await self._disk_cache.is_negative(file_path_250) + debug_info["disk_cache"]["negative_500"] = await self._disk_cache.is_negative(file_path_500) + + debug_info["circuit_breakers"] = { + "coverart": _coverart_circuit_breaker.get_state(), + "lidarr": _lidarr_cover_circuit_breaker.get_state(), + "jellyfin": _jellyfin_cover_circuit_breaker.get_state(), + "wikidata": _wikidata_cover_circuit_breaker.get_state(), + "wikimedia": _wikimedia_cover_circuit_breaker.get_state(), + "generic": _generic_cover_circuit_breaker.get_state(), + } + + for size, file_path in [("250", file_path_250), ("500", file_path_500)]: + meta_path = file_path.with_suffix('.meta.json') + if meta_path.exists(): + try: + async with aiofiles.open(meta_path, 'r') as f: + debug_info["disk_cache"][f"meta_{size}"] = msgspec.json.decode( + (await f.read()).encode("utf-8"), + type=dict[str, object], + ) + except Exception as e: # noqa: BLE001 + debug_info["disk_cache"][f"meta_{size}"] = f"Error reading: {e}" + + if self._lidarr_repo: + debug_info["lidarr"]["configured"] = True + try: + image_url = await self._lidarr_repo.get_artist_image_url(artist_id) + if image_url: + debug_info["lidarr"]["has_image_url"] = True + debug_info["lidarr"]["image_url"] = image_url + except Exception as e: # noqa: BLE001 + debug_info["lidarr"]["error"] = str(e) + + cache_key = f"{ARTIST_WIKIDATA_PREFIX}{artist_id}" + cached_wikidata = await self._cache.get(cache_key) + if cached_wikidata is not None: + debug_info["memory_cache"]["wikidata_url_cached"] = True + debug_info["memory_cache"]["cached_value"] = cached_wikidata if cached_wikidata else "(negative cache)" + + if self._mb_repo and not cached_wikidata: + try: + artist_data = await self._mb_repo.get_artist_by_id(artist_id) + if artist_data: + debug_info["musicbrainz"]["artist_found"] = True + debug_info["musicbrainz"]["artist_name"] = artist_data.get("name") + url_relations = artist_data.get("relations", []) + if url_relations: + for url_rel in url_relations: + if isinstance(url_rel, dict): + typ = url_rel.get("type") or url_rel.get("link_type") + url_obj = url_rel.get("url", {}) + target = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + if typ == "wikidata" and target: + debug_info["musicbrainz"]["has_wikidata_relation"] = True + debug_info["musicbrainz"]["wikidata_url"] = target + break + except Exception as e: # noqa: BLE001 + debug_info["musicbrainz"]["error"] = str(e) + elif cached_wikidata: + debug_info["musicbrainz"]["has_wikidata_relation"] = True + debug_info["musicbrainz"]["wikidata_url"] = cached_wikidata + + return debug_info diff --git a/backend/repositories/jellyfin_models.py b/backend/repositories/jellyfin_models.py new file mode 100644 index 0000000..98b861a --- /dev/null +++ b/backend/repositories/jellyfin_models.py @@ -0,0 +1,97 @@ +from typing import Any + +import msgspec + + +class JellyfinItem(msgspec.Struct): + """Represents a Jellyfin library item (artist, album, or track).""" + + id: str + name: str + type: str + artist_name: str | None = None + album_name: str | None = None + play_count: int = 0 + is_favorite: bool = False + last_played: str | None = None + image_tag: str | None = None + parent_id: str | None = None + album_id: str | None = None + artist_id: str | None = None + provider_ids: dict[str, str] | None = None + index_number: int | None = None + parent_index_number: int | None = None + duration_ticks: int | None = None + codec: str | None = None + bitrate: int | None = None + year: int | None = None + sort_name: str | None = None + album_count: int | None = None + child_count: int | None = None + + +class JellyfinUser(msgspec.Struct): + id: str + name: str + + +class PlaybackUrlResult(msgspec.Struct): + url: str + seekable: bool + play_session_id: str + play_method: str + + +def parse_item(item: dict[str, Any]) -> JellyfinItem: + user_data = item.get("UserData", {}) + provider_ids = item.get("ProviderIds", {}) + + artist_items = item.get("ArtistItems") + + artist_name = None + if artist_items: + artist_name = artist_items[0].get("Name") + elif album_artist := item.get("AlbumArtist"): + artist_name = album_artist + + return JellyfinItem( + id=item.get("Id") or item.get("ItemId", ""), + name=item.get("Name", "Unknown"), + type=item.get("Type", "Unknown"), + artist_name=artist_name, + album_name=item.get("Album"), + play_count=user_data.get("PlayCount", 0), + is_favorite=user_data.get("IsFavorite", False), + last_played=user_data.get("LastPlayedDate"), + image_tag=item.get("ImageTags", {}).get("Primary"), + parent_id=item.get("ParentId"), + album_id=item.get("AlbumId"), + artist_id=artist_items[0].get("Id") if artist_items else None, + provider_ids=provider_ids if provider_ids else None, + index_number=item.get("IndexNumber"), + parent_index_number=item.get("ParentIndexNumber"), + duration_ticks=item.get("RunTimeTicks"), + codec=_extract_codec(item), + bitrate=item.get("Bitrate"), + year=item.get("ProductionYear"), + sort_name=item.get("SortName"), + album_count=item.get("AlbumCount"), + child_count=item.get("ChildCount"), + ) + + +def _extract_codec(item: dict[str, Any]) -> str | None: + media_streams = item.get("MediaStreams") + if media_streams: + for stream in media_streams: + if stream.get("Type") == "Audio": + return stream.get("Codec") + container = item.get("Container") + return container if container else None + + +def parse_user(user: dict[str, Any]) -> JellyfinUser: + return JellyfinUser( + id=user.get("Id", ""), + name=user.get("Name", "Unknown") + ) diff --git a/backend/repositories/jellyfin_repository.py b/backend/repositories/jellyfin_repository.py new file mode 100644 index 0000000..f1f7c35 --- /dev/null +++ b/backend/repositories/jellyfin_repository.py @@ -0,0 +1,739 @@ +import httpx +import logging +from typing import Any + +import msgspec +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError +from infrastructure.cache.cache_keys import JELLYFIN_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.persistence import MBIDStore +from infrastructure.constants import BROWSER_AUDIO_DEVICE_PROFILE +from infrastructure.resilience.retry import with_retry, CircuitBreaker +from repositories.jellyfin_models import ( + JellyfinItem, + JellyfinUser, + PlaybackUrlResult, + parse_item, + parse_user, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "jellyfin" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +_jellyfin_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="jellyfin" +) + +JellyfinJsonObject = dict[str, Any] +JellyfinJsonArray = list[JellyfinJsonObject] +JellyfinJson = JellyfinJsonObject | JellyfinJsonArray + + +def _decode_json_response(response: httpx.Response) -> JellyfinJson: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=JellyfinJson) + return response.json() + + +class JellyfinRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + cache: CacheInterface, + base_url: str = "", + api_key: str = "", + user_id: str = "", + mbid_store: MBIDStore | None = None, + ): + self._client = http_client + self._cache = cache + self._mbid_store = mbid_store + self._base_url = base_url.rstrip("/") if base_url else "" + self._api_key = api_key + self._user_id = user_id + + def configure(self, base_url: str, api_key: str, user_id: str = "") -> None: + self._base_url = base_url.rstrip("/") if base_url else "" + self._api_key = api_key + self._user_id = user_id + + @staticmethod + def reset_circuit_breaker() -> None: + _jellyfin_circuit_breaker.reset() + + def is_configured(self) -> bool: + return bool(self._base_url and self._api_key) + + def _get_headers(self) -> dict[str, str]: + return { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Emby-Token": self._api_key, + } + + @with_retry( + max_attempts=3, + base_delay=1.0, + max_delay=5.0, + circuit_breaker=_jellyfin_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError) + ) + async def _request( + self, + method: str, + endpoint: str, + params: dict[str, Any] | None = None, + json_data: dict[str, Any] | None = None, + ) -> Any: + if not self._base_url or not self._api_key: + raise ExternalServiceError("Jellyfin not configured") + + url = f"{self._base_url}{endpoint}" + + try: + response = await self._client.request( + method, + url, + headers=self._get_headers(), + params=params, + json=json_data, + timeout=15.0, + ) + + if response.status_code == 401: + raise ExternalServiceError("Jellyfin authentication failed - check API key") + + if response.status_code == 404: + return None + + if response.status_code not in (200, 204): + raise ExternalServiceError( + f"Jellyfin {method} failed ({response.status_code})", + response.text + ) + + if response.status_code == 204: + return None + + try: + return _decode_json_response(response) + except (msgspec.DecodeError, ValueError, TypeError): + _record_degradation(f"Jellyfin returned invalid JSON for {method} {endpoint}") + return None + + except httpx.HTTPError as e: + raise ExternalServiceError(f"Jellyfin request failed: {str(e)}") + + async def _get( + self, + endpoint: str, + params: dict[str, Any] | None = None + ) -> Any: + return await self._request("GET", endpoint, params=params) + + async def validate_connection(self) -> tuple[bool, str]: + if not self._base_url or not self._api_key: + return False, "Jellyfin URL or API key not configured" + + try: + url = f"{self._base_url}/System/Info" + response = await self._client.request( + "GET", + url, + headers=self._get_headers(), + timeout=10.0, + ) + + if response.status_code == 401: + return False, "Authentication failed - check API key" + + if response.status_code != 200: + return False, f"Connection failed (HTTP {response.status_code})" + + result = _decode_json_response(response) + server_name = result.get("ServerName", "Unknown") + version = result.get("Version", "Unknown") + return True, f"Connected to {server_name} (v{version})" + except httpx.TimeoutException: + return False, "Connection timed out - check URL" + except httpx.ConnectError: + return False, "Could not connect - check URL and ensure server is running" + except Exception as e: # noqa: BLE001 + return False, f"Connection failed: {str(e)}" + + async def get_users(self) -> list[JellyfinUser]: + try: + result = await self._get("/Users") + if not result: + return [] + return [parse_user(user) for user in result if user.get("Id")] + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get Jellyfin users: {e}") + _record_degradation(f"Failed to get users: {e}") + return [] + + async def fetch_users_direct(self) -> list[JellyfinUser]: + if not self._base_url or not self._api_key: + return [] + + try: + url = f"{self._base_url}/Users" + response = await self._client.request( + "GET", + url, + headers=self._get_headers(), + timeout=10.0, + ) + + if response.status_code != 200: + return [] + + result = _decode_json_response(response) + if not result: + return [] + return [parse_user(user) for user in result if user.get("Id")] + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch Jellyfin users: {e}") + _record_degradation(f"Failed to fetch users: {e}") + return [] + + async def get_current_user(self) -> JellyfinUser | None: + try: + result = await self._get("/Users/Me") + return parse_user(result) if result else None + except Exception: # noqa: BLE001 + _record_degradation("Failed to get current user") + return None + + async def _fetch_items( + self, + endpoint: str, + cache_key: str, + params: dict[str, Any], + error_msg: str, + ttl: int = 300, + filter_fn=None, + raise_on_error: bool = False, + ) -> list[JellyfinItem]: + cached = await self._cache.get(cache_key) + if cached: + return cached + try: + result = await self._get(endpoint, params=params) + if not result: + if raise_on_error: + raise ExternalServiceError(f"{error_msg}: empty response from Jellyfin") + logger.warning(f"{error_msg}: _get returned None/empty") + return [] + raw_items = result.get("Items", []) if isinstance(result, dict) else result + items = [parse_item(i) for i in raw_items if not filter_fn or filter_fn(i)] + if items: + await self._cache.set(cache_key, items, ttl_seconds=ttl) + return items + except ExternalServiceError: + raise + except Exception as e: + logger.error(f"{error_msg}: {e}") + if raise_on_error: + raise ExternalServiceError(f"{error_msg}: {e}") from e + _record_degradation(f"{error_msg}: {e}") + return [] + + async def get_recently_played( + self, + user_id: str | None = None, + limit: int = 20, + ttl_seconds: int = 300, + ) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "includeItemTypes": "Audio", "sortBy": "DatePlayed", + "sortOrder": "Descending", "isPlayed": "true", "enableUserData": "true", + "limit": limit, "recursive": "true", "Fields": "ProviderIds"} + return await self._fetch_items( + "/Items", + f"jellyfin_recent:{uid}:{limit}", + params, + "Failed to get recently played", + ttl=ttl_seconds, + ) + + async def get_favorite_artists(self, user_id: str | None = None, limit: int = 20) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "isFavorite": "true", "enableUserData": "true", "limit": limit, "Fields": "ProviderIds"} + return await self._fetch_items("/Artists", f"jellyfin_fav_artists:{uid}:{limit}", params, "Failed to get favorite artists") + + async def get_favorite_albums( + self, + user_id: str | None = None, + limit: int = 20, + ttl_seconds: int = 300, + ) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "includeItemTypes": "MusicAlbum", "isFavorite": "true", + "enableUserData": "true", "limit": limit, "recursive": "true"} + return await self._fetch_items( + "/Items", + f"jellyfin_fav_albums:{uid}:{limit}", + params, + "Failed to get favorite albums", + ttl=ttl_seconds, + ) + + async def get_most_played_artists(self, user_id: str | None = None, limit: int = 20) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "sortBy": "PlayCount", "sortOrder": "Descending", + "enableUserData": "true", "limit": limit} + filter_fn = lambda i: i.get("UserData", {}).get("PlayCount", 0) > 0 + return await self._fetch_items("/Artists", f"jellyfin_top_artists:{uid}:{limit}", params, "Failed to get most played artists", filter_fn=filter_fn) + + async def get_most_played_albums(self, user_id: str | None = None, limit: int = 20) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "includeItemTypes": "MusicAlbum", "sortBy": "PlayCount", + "sortOrder": "Descending", "enableUserData": "true", "limit": limit, "recursive": "true"} + filter_fn = lambda i: i.get("UserData", {}).get("PlayCount", 0) > 0 + return await self._fetch_items("/Items", f"jellyfin_top_albums:{uid}:{limit}", params, "Failed to get most played albums", filter_fn=filter_fn) + + async def get_recently_added(self, user_id: str | None = None, limit: int = 20) -> list[JellyfinItem]: + uid = user_id or self._user_id + if not uid: + return [] + params = {"userId": uid, "includeItemTypes": "MusicAlbum", "limit": limit, "enableUserData": "true"} + try: + result = await self._get("/Items/Latest", params=params) + return [parse_item(item) for item in result] if result else [] + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get recently added: {e}") + _record_degradation(f"Failed to get recently added: {e}") + return [] + + async def get_genres(self, user_id: str | None = None, ttl_seconds: int = 3600) -> list[str]: + uid = user_id or self._user_id + cache_key = f"{JELLYFIN_PREFIX}genres:{uid}" + cached = await self._cache.get(cache_key) + if cached: + return cached + params: dict[str, Any] = {"userId": uid} if uid else {} + try: + result = await self._get("/MusicGenres", params=params) + if not result: + return [] + genres = [item.get("Name", "") for item in result.get("Items", []) if item.get("Name")] + await self._cache.set(cache_key, genres, ttl_seconds=ttl_seconds) + return genres + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get genres: {e}") + _record_degradation(f"Failed to get genres: {e}") + return [] + + async def get_artists_by_genre(self, genre: str, user_id: str | None = None, limit: int = 50) -> list[JellyfinItem]: + uid = user_id or self._user_id + params: dict[str, Any] = {"genres": genre, "limit": limit, "enableUserData": "true"} + if uid: + params["userId"] = uid + try: + result = await self._get("/Artists", params=params) + return [parse_item(item) for item in result.get("Items", [])] if result else [] + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get artists by genre: {e}") + _record_degradation(f"Failed to get artists by genre: {e}") + return [] + + def get_auth_headers(self) -> dict[str, str]: + return {"X-Emby-Token": self._api_key} + + def get_image_url(self, item_id: str, image_tag: str | None = None) -> str | None: + if not self._base_url or not item_id: + return None + + url = f"{self._base_url}/Items/{item_id}/Images/Primary" + if image_tag: + url += f"?tag={image_tag}" + + return url + + async def _post( + self, + endpoint: str, + json_data: dict[str, Any] | None = None, + ) -> Any: + return await self._request("POST", endpoint, json_data=json_data) + + async def get_albums( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "SortName", + sort_order: str = "Ascending", + genre: str | None = None, + ) -> tuple[list[JellyfinItem], int]: + uid = self._user_id + params: dict[str, Any] = { + "includeItemTypes": "MusicAlbum", + "recursive": "true", + "sortBy": sort_by, + "sortOrder": sort_order, + "limit": limit, + "startIndex": offset, + "enableUserData": "true", + "Fields": "ProviderIds,ChildCount", + } + if uid: + params["userId"] = uid + if genre: + params["genres"] = genre + cache_key = f"{JELLYFIN_PREFIX}albums:{uid}:{limit}:{offset}:{sort_by}:{sort_order}:{genre}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + try: + result = await self._get("/Items", params=params) + if not result: + return [], 0 + raw_items = result.get("Items", []) + total = result.get("TotalRecordCount", len(raw_items)) + items = [parse_item(i) for i in raw_items] + pair = (items, total) + if items: + await self._cache.set(cache_key, pair, ttl_seconds=120) + return pair + except Exception as e: # noqa: BLE001 + logger.error("Failed to get albums: %s", e) + _record_degradation(f"Failed to get albums: {e}") + return [], 0 + + async def get_album_tracks(self, album_id: str) -> list[JellyfinItem]: + uid = self._user_id + params: dict[str, Any] = { + "albumIds": album_id, + "includeItemTypes": "Audio", + "sortBy": "IndexNumber", + "sortOrder": "Ascending", + "recursive": "true", + "enableUserData": "true", + "Fields": "ProviderIds,MediaStreams", + } + if uid: + params["userId"] = uid + cache_key = f"{JELLYFIN_PREFIX}album_tracks:{album_id}" + return await self._fetch_items( + "/Items", + cache_key, + params, + f"Failed to get tracks for album {album_id}", + ttl=120, + raise_on_error=True, + ) + + async def get_album_detail(self, album_id: str) -> JellyfinItem | None: + uid = self._user_id + params: dict[str, Any] = {"Fields": "ProviderIds,ChildCount"} + if uid: + params["userId"] = uid + try: + result = await self._get(f"/Items/{album_id}", params=params) + return parse_item(result) if result else None + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get album detail {album_id}: {e}") + _record_degradation(f"Failed to get album detail: {e}") + return None + + async def get_album_by_mbid(self, musicbrainz_id: str) -> JellyfinItem | None: + index = await self.build_mbid_index() + jellyfin_id = index.get(musicbrainz_id) + if jellyfin_id: + return await self.get_album_detail(jellyfin_id) + + try: + results = await self.search_items(musicbrainz_id, item_types="MusicAlbum") + for item in results: + if not item.provider_ids: + continue + if ( + item.provider_ids.get("MusicBrainzReleaseGroup") == musicbrainz_id + or item.provider_ids.get("MusicBrainzAlbum") == musicbrainz_id + ): + return item + except Exception as e: # noqa: BLE001 + logger.debug(f"MBID search fallback failed for {musicbrainz_id}: {e}") + _record_degradation(f"Album MBID search fallback failed: {e}") + + return None + + async def get_artist_by_mbid(self, musicbrainz_id: str) -> JellyfinItem | None: + try: + results = await self.search_items(musicbrainz_id, item_types="MusicArtist") + for item in results: + if not item.provider_ids: + continue + if item.provider_ids.get("MusicBrainzArtist") == musicbrainz_id: + return item + except Exception as e: # noqa: BLE001 + logger.debug(f"Artist MBID search fallback failed for {musicbrainz_id}: {e}") + _record_degradation(f"Artist MBID search fallback failed: {e}") + + return None + + async def get_artists( + self, limit: int = 50, offset: int = 0 + ) -> list[JellyfinItem]: + params: dict[str, Any] = { + "limit": limit, + "startIndex": offset, + "enableUserData": "true", + "Fields": "ProviderIds", + } + if self._user_id: + params["userId"] = self._user_id + cache_key = f"{JELLYFIN_PREFIX}artists:{self._user_id}:{limit}:{offset}" + return await self._fetch_items( + "/Artists", cache_key, params, "Failed to get artists", ttl=120 + ) + + async def build_mbid_index(self) -> dict[str, str]: + cache_key = f"{JELLYFIN_PREFIX}mbid_index:{self._user_id or 'default'}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + if self._mbid_store: + sqlite_index = await self._mbid_store.load_jellyfin_mbid_index( + max_age_seconds=3600 + ) + if sqlite_index: + await self._cache.set(cache_key, sqlite_index, ttl_seconds=3600) + logger.info( + f"Loaded MBID index from SQLite with {len(sqlite_index)} entries" + ) + return sqlite_index + + index: dict[str, str] = {} + try: + offset = 0 + batch_size = 500 + while True: + params: dict[str, Any] = { + "includeItemTypes": "MusicAlbum", + "recursive": "true", + "Fields": "ProviderIds", + "limit": batch_size, + "startIndex": offset, + } + if self._user_id: + params["userId"] = self._user_id + + result = await self._get("/Items", params=params) + if not result: + break + + items = result.get("Items", []) + if not items: + break + + for item in items: + provider_ids = item.get("ProviderIds", {}) + item_id = item.get("Id") + if not item_id: + continue + rg_mbid = provider_ids.get("MusicBrainzReleaseGroup") + if rg_mbid: + index[rg_mbid] = item_id + release_mbid = provider_ids.get("MusicBrainzAlbum") + if release_mbid: + index[release_mbid] = item_id + + total = result.get("TotalRecordCount", 0) + offset += batch_size + if offset >= total: + break + + if index: + await self._cache.set(cache_key, index, ttl_seconds=3600) + if self._mbid_store: + await self._mbid_store.save_jellyfin_mbid_index(index) + logger.info(f"Built Jellyfin MBID index with {len(index)} entries") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to build MBID index: {e}") + _record_degradation(f"Failed to build MBID index: {e}") + + return index + + async def search_items( + self, + query: str, + item_types: str = "MusicAlbum,Audio,MusicArtist", + ) -> list[JellyfinItem]: + params: dict[str, Any] = { + "searchTerm": query, + "includeItemTypes": item_types, + "limit": 50, + "Fields": "ProviderIds", + } + if self._user_id: + params["userId"] = self._user_id + try: + result = await self._get("/Search/Hints", params=params) + if not result: + return [] + raw_items = result.get("SearchHints", []) + return [parse_item(item) for item in raw_items] + except Exception as e: # noqa: BLE001 + logger.error(f"Jellyfin search failed for '{query}': {e}") + _record_degradation(f"Search failed: {e}") + return [] + + async def get_library_stats(self, ttl_seconds: int = 600) -> dict[str, Any]: + cache_key = "jellyfin_library_stats" + cached = await self._cache.get(cache_key) + if cached: + return cached + + stats: dict[str, Any] = {"total_albums": 0, "total_artists": 0, "total_tracks": 0} + try: + for item_type, key in [ + ("MusicAlbum", "total_albums"), + ("MusicArtist", "total_artists"), + ("Audio", "total_tracks"), + ]: + params: dict[str, Any] = { + "includeItemTypes": item_type, + "recursive": "true", + "limit": 0, + } + if self._user_id: + params["userId"] = self._user_id + result = await self._get("/Items", params=params) + if result: + stats[key] = result.get("TotalRecordCount", 0) + + await self._cache.set(cache_key, stats, ttl_seconds=ttl_seconds) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get library stats: {e}") + _record_degradation(f"Failed to get library stats: {e}") + + return stats + + async def get_playback_info(self, item_id: str) -> dict[str, Any]: + params: dict[str, Any] = {} + if self._user_id: + params["userId"] = self._user_id + result = await self._get(f"/Items/{item_id}/PlaybackInfo", params=params) + if not result: + raise ResourceNotFoundError(f"Playback info not found for {item_id}") + return result + + async def get_playback_url(self, item_id: str) -> PlaybackUrlResult: + params: dict[str, Any] = {} + if self._user_id: + params["userId"] = self._user_id + + result = await self._request( + "POST", + f"/Items/{item_id}/PlaybackInfo", + params=params, + json_data={"DeviceProfile": BROWSER_AUDIO_DEVICE_PROFILE}, + ) + + if not result: + raise ResourceNotFoundError(f"Playback info not found for {item_id}") + + error_code = result.get("ErrorCode") + if error_code: + raise PlaybackNotAllowedError(f"Jellyfin playback not allowed: {error_code}") + + raw_play_session_id = result.get("PlaySessionId") + if not raw_play_session_id: + logger.warning( + "PlaybackInfo returned null PlaySessionId", + extra={"item_id": item_id}, + ) + play_session_id = raw_play_session_id or "" + media_sources = result.get("MediaSources") or [] + if not media_sources: + raise ExternalServiceError(f"Playback info missing media sources for {item_id}") + + primary_source = media_sources[0] + supports_direct_play = bool(primary_source.get("SupportsDirectPlay")) + supports_direct_stream = bool(primary_source.get("SupportsDirectStream")) + transcoding_url = primary_source.get("TranscodingUrl") + + if supports_direct_play or supports_direct_stream: + playback_url = f"{self._base_url}/Audio/{item_id}/stream?static=true&api_key={self._api_key}" + play_method = "DirectPlay" if supports_direct_play else "DirectStream" + seekable = True + elif isinstance(transcoding_url, str) and transcoding_url: + playback_url = ( + transcoding_url + if transcoding_url.startswith(("http://", "https://")) + else f"{self._base_url}{transcoding_url}" + ) + play_method = "Transcode" + seekable = False + else: + raise ExternalServiceError(f"Playback info has no playable stream for {item_id}") + return PlaybackUrlResult( + url=playback_url, + seekable=seekable, + play_session_id=play_session_id, + play_method=play_method, + ) + + async def report_playback_start( + self, item_id: str, play_session_id: str, play_method: str = "Transcode" + ) -> None: + body: dict[str, Any] = { + "ItemId": item_id, + "PlaySessionId": play_session_id, + "CanSeek": True, + "PlayMethod": play_method, + } + await self._post("/Sessions/Playing", json_data=body) + + async def report_playback_progress( + self, + item_id: str, + play_session_id: str, + position_ticks: int, + is_paused: bool, + ) -> None: + body: dict[str, Any] = { + "ItemId": item_id, + "PlaySessionId": play_session_id, + "PositionTicks": position_ticks, + "IsPaused": is_paused, + "CanSeek": True, + } + await self._post("/Sessions/Playing/Progress", json_data=body) + + async def report_playback_stopped( + self, item_id: str, play_session_id: str, position_ticks: int + ) -> None: + body: dict[str, Any] = { + "ItemId": item_id, + "PlaySessionId": play_session_id, + "PositionTicks": position_ticks, + } + await self._post("/Sessions/Playing/Stopped", json_data=body) diff --git a/backend/repositories/lastfm_models.py b/backend/repositories/lastfm_models.py new file mode 100644 index 0000000..f613eba --- /dev/null +++ b/backend/repositories/lastfm_models.py @@ -0,0 +1,292 @@ +import msgspec + +from core.exceptions import ExternalServiceError + + +class LastFmToken(msgspec.Struct): + token: str + + +class LastFmSession(msgspec.Struct): + name: str + key: str + subscriber: int = 0 + + +class LastFmTag(msgspec.Struct): + name: str + url: str = "" + + +class LastFmArtist(msgspec.Struct): + name: str + mbid: str | None = None + playcount: int = 0 + listeners: int = 0 + url: str = "" + + +class LastFmAlbum(msgspec.Struct): + name: str + artist_name: str + mbid: str | None = None + playcount: int = 0 + listeners: int = 0 + url: str = "" + image_url: str = "" + + +class LastFmTrack(msgspec.Struct): + name: str + artist_name: str + mbid: str | None = None + playcount: int = 0 + listeners: int = 0 + url: str = "" + + +class LastFmSimilarArtist(msgspec.Struct): + name: str + mbid: str | None = None + match: float = 0.0 + url: str = "" + + +class LastFmArtistInfo(msgspec.Struct): + name: str + mbid: str | None = None + listeners: int = 0 + playcount: int = 0 + url: str = "" + bio_summary: str = "" + tags: list[LastFmTag] | None = None + similar: list[LastFmSimilarArtist] | None = None + + +class LastFmAlbumTrack(msgspec.Struct): + name: str + duration: int = 0 + rank: int = 0 + url: str = "" + + +class LastFmAlbumInfo(msgspec.Struct): + name: str + artist_name: str + mbid: str | None = None + listeners: int = 0 + playcount: int = 0 + url: str = "" + image_url: str = "" + summary: str = "" + tags: list[LastFmTag] | None = None + tracks: list[LastFmAlbumTrack] | None = None + + +class LastFmRecentTrack(msgspec.Struct): + track_name: str + artist_name: str + album_name: str = "" + artist_mbid: str | None = None + album_mbid: str | None = None + timestamp: int = 0 + now_playing: bool = False + image_url: str = "" + + +class LastFmLovedTrack(msgspec.Struct): + track_name: str + artist_name: str + album_name: str = "" + track_mbid: str | None = None + artist_mbid: str | None = None + url: str = "" + image_url: str = "" + + +ALLOWED_LASTFM_PERIOD = [ + "overall", "7day", "1month", "3month", "6month", "12month", +] + + +def parse_weekly_album_chart_item(item: dict) -> "LastFmAlbum": + artist = item.get("artist", {}) + artist_name = artist.get("#text", "") if isinstance(artist, dict) else str(artist) + return LastFmAlbum( + name=item.get("name", ""), + artist_name=artist_name, + mbid=item.get("mbid") or None, + playcount=_safe_int(item.get("playcount")), + url=item.get("url", ""), + image_url=_extract_image(item.get("image")), + ) + + +def parse_token(data: dict) -> LastFmToken: + token_value = data.get("token") + if not token_value: + raise ExternalServiceError("Last.fm auth.getToken response missing 'token'") + return LastFmToken(token=token_value) + + +def parse_session(data: dict) -> LastFmSession: + session_data = data.get("session", data) + name = session_data.get("name") + key = session_data.get("key") + if not name or not key: + raise ExternalServiceError("Last.fm auth.getSession response missing 'name' or 'key'") + return LastFmSession( + name=name, + key=key, + subscriber=int(session_data.get("subscriber", 0)), + ) + + +def _extract_image(images: list[dict] | None, size: str = "extralarge") -> str: + if not images: + return "" + for img in images: + if img.get("size") == size: + return img.get("#text", "") + return images[-1].get("#text", "") if images else "" + + +def _safe_int(value: str | int | None, default: int = 0) -> int: + if value is None: + return default + try: + return int(value) + except (ValueError, TypeError): + return default + + +def _safe_float(value: str | float | None, default: float = 0.0) -> float: + if value is None: + return default + try: + return float(value) + except (ValueError, TypeError): + return default + + +def parse_top_artist(item: dict) -> LastFmArtist: + return LastFmArtist( + name=item.get("name", ""), + mbid=item.get("mbid") or None, + playcount=_safe_int(item.get("playcount")), + listeners=_safe_int(item.get("listeners")), + url=item.get("url", ""), + ) + + +def parse_top_album(item: dict) -> LastFmAlbum: + artist = item.get("artist", {}) + artist_name = artist.get("name", "") if isinstance(artist, dict) else str(artist) + return LastFmAlbum( + name=item.get("name", ""), + artist_name=artist_name, + mbid=item.get("mbid") or None, + playcount=_safe_int(item.get("playcount")), + listeners=_safe_int(item.get("listeners")), + url=item.get("url", ""), + image_url=_extract_image(item.get("image")), + ) + + +def parse_top_track(item: dict) -> LastFmTrack: + artist = item.get("artist", {}) + artist_name = artist.get("name", "") if isinstance(artist, dict) else str(artist) + return LastFmTrack( + name=item.get("name", ""), + artist_name=artist_name, + mbid=item.get("mbid") or None, + playcount=_safe_int(item.get("playcount")), + listeners=_safe_int(item.get("listeners")), + url=item.get("url", ""), + ) + + +def parse_similar_artist(item: dict) -> LastFmSimilarArtist: + return LastFmSimilarArtist( + name=item.get("name", ""), + mbid=item.get("mbid") or None, + match=_safe_float(item.get("match")), + url=item.get("url", ""), + ) + + +def parse_artist_info(data: dict) -> LastFmArtistInfo: + artist = data.get("artist", {}) + stats = artist.get("stats", {}) + tags_data = artist.get("tags", {}).get("tag", []) + similar_data = artist.get("similar", {}).get("artist", []) + bio = artist.get("bio", {}) + return LastFmArtistInfo( + name=artist.get("name", ""), + mbid=artist.get("mbid") or None, + listeners=_safe_int(stats.get("listeners")), + playcount=_safe_int(stats.get("playcount")), + url=artist.get("url", ""), + bio_summary=bio.get("summary", ""), + tags=[LastFmTag(name=t.get("name", ""), url=t.get("url", "")) for t in tags_data], + similar=[parse_similar_artist(s) for s in similar_data], + ) + + +def parse_album_info(data: dict) -> LastFmAlbumInfo: + album = data.get("album", {}) + tags_data = album.get("tags", {}).get("tag", []) + tracks_data = album.get("tracks", {}).get("track", []) + wiki = album.get("wiki", {}) + tracks = [ + LastFmAlbumTrack( + name=t.get("name", ""), + duration=_safe_int(t.get("duration")), + rank=_safe_int(t.get("@attr", {}).get("rank")), + url=t.get("url", ""), + ) + for t in tracks_data + ] if tracks_data else None + return LastFmAlbumInfo( + name=album.get("name", ""), + artist_name=album.get("artist", ""), + mbid=album.get("mbid") or None, + listeners=_safe_int(album.get("listeners")), + playcount=_safe_int(album.get("playcount")), + url=album.get("url", ""), + image_url=_extract_image(album.get("image")), + summary=wiki.get("summary", ""), + tags=[LastFmTag(name=t.get("name", ""), url=t.get("url", "")) for t in tags_data], + tracks=tracks, + ) + + +def parse_recent_track(item: dict) -> LastFmRecentTrack: + artist = item.get("artist", {}) + album = item.get("album", {}) + date = item.get("date", {}) + attr = item.get("@attr", {}) + return LastFmRecentTrack( + track_name=item.get("name", ""), + artist_name=artist.get("#text", "") if isinstance(artist, dict) else str(artist), + album_name=album.get("#text", "") if isinstance(album, dict) else str(album), + artist_mbid=artist.get("mbid") or None if isinstance(artist, dict) else None, + album_mbid=album.get("mbid") or None if isinstance(album, dict) else None, + timestamp=_safe_int(date.get("uts")) if isinstance(date, dict) else 0, + now_playing=attr.get("nowplaying") == "true", + image_url=_extract_image(item.get("image")), + ) + + +def parse_loved_track(item: dict) -> LastFmLovedTrack: + artist = item.get("artist", {}) + album = item.get("album", {}) + return LastFmLovedTrack( + track_name=item.get("name", ""), + artist_name=artist.get("name", "") if isinstance(artist, dict) else str(artist), + album_name=album.get("#text", "") if isinstance(album, dict) else str(album), + track_mbid=item.get("mbid") or None, + artist_mbid=artist.get("mbid") if isinstance(artist, dict) else None, + url=item.get("url", ""), + image_url=_extract_image(item.get("image")), + ) diff --git a/backend/repositories/lastfm_repository.py b/backend/repositories/lastfm_repository.py new file mode 100644 index 0000000..6fea154 --- /dev/null +++ b/backend/repositories/lastfm_repository.py @@ -0,0 +1,607 @@ +import hashlib +import logging +from typing import Any + +import httpx +import msgspec + +from core.exceptions import ( + ConfigurationError, + ExternalServiceError, + ResourceNotFoundError, + TokenNotAuthorizedError, +) +from infrastructure.cache.cache_keys import LFM_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from infrastructure.resilience.retry import CircuitBreaker, with_retry +from repositories.lastfm_models import ( + ALLOWED_LASTFM_PERIOD, + LastFmAlbum, + LastFmAlbumInfo, + LastFmArtist, + LastFmArtistInfo, + LastFmLovedTrack, + LastFmRecentTrack, + LastFmSession, + LastFmSimilarArtist, + LastFmToken, + LastFmTrack, + parse_album_info, + parse_artist_info, + parse_loved_track, + parse_recent_track, + parse_session, + parse_similar_artist, + parse_token, + parse_top_album, + parse_top_artist, + parse_top_track, + parse_weekly_album_chart_item, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "lastfm" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/" + +_lastfm_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="lastfm", +) + +_lastfm_rate_limiter = TokenBucketRateLimiter(rate=5.0, capacity=10) + +LASTFM_ERROR_MAP: dict[int, tuple[type[Exception], str]] = { + 2: (ExternalServiceError, "Invalid service - This service does not exist"), + 3: (ExternalServiceError, "Invalid method - No method with that name in this package"), + 4: (ConfigurationError, "Authentication failed - invalid API key or shared secret"), + 6: (ResourceNotFoundError, "Not found"), + 9: (ConfigurationError, "Session key expired - please re-authorize with Last.fm"), + 10: (ConfigurationError, "Invalid API key - check your Last.fm API key"), + 11: (ExternalServiceError, "Last.fm service is temporarily offline"), + 26: (ConfigurationError, "API key has been suspended - contact Last.fm support"), + 14: (TokenNotAuthorizedError, "Token not yet authorized"), + 29: (ExternalServiceError, "Rate limit exceeded"), +} + +LASTFM_USER_CACHE_TTL = 300 +LASTFM_ENTITY_CACHE_TTL = 3600 +LASTFM_GLOBAL_CACHE_TTL = 3600 + +LastFmJsonObject = dict[str, Any] +LastFmJsonArray = list[LastFmJsonObject] +LastFmJson = LastFmJsonObject | LastFmJsonArray + + +def _decode_json_response(response: httpx.Response) -> LastFmJson: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=LastFmJson) + return response.json() + + +class LastFmRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + cache: CacheInterface, + api_key: str = "", + shared_secret: str = "", + session_key: str = "", + ): + self._client = http_client + self._cache = cache + self._api_key = api_key + self._shared_secret = shared_secret + self._session_key = session_key + + def configure(self, api_key: str, shared_secret: str, session_key: str = "") -> None: + self._api_key = api_key + self._shared_secret = shared_secret + self._session_key = session_key + + @staticmethod + def reset_circuit_breaker() -> None: + _lastfm_circuit_breaker.reset() + + def _build_api_sig(self, params: dict[str, str]) -> str: + filtered = {k: v for k, v in sorted(params.items()) if k not in ("format", "callback")} + sig_string = "".join(f"{k}{v}" for k, v in filtered.items()) + sig_string += self._shared_secret + return hashlib.md5(sig_string.encode("utf-8")).hexdigest() + + def _handle_error_response(self, data: dict[str, Any]) -> None: + error_code = data.get("error") + error_message = data.get("message", "Unknown Last.fm error") + + if error_code is None: + return + + mapped = LASTFM_ERROR_MAP.get(error_code) + if mapped: + exc_type, default_msg = mapped + raise exc_type(f"{default_msg}: {error_message}") + + logger.warning("Last.fm error code=%d message=%s", error_code, error_message) + raise ExternalServiceError(f"Last.fm error ({error_code}): {error_message}") + + @with_retry( + max_attempts=3, + base_delay=1.0, + max_delay=3.0, + circuit_breaker=_lastfm_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _request( + self, + method: str, + params: dict[str, str] | None = None, + signed: bool = False, + http_method: str = "GET", + ) -> dict[str, Any]: + if not self._api_key: + raise ConfigurationError("Last.fm API key is not configured") + + await _lastfm_rate_limiter.acquire() + + request_params: dict[str, str] = { + "method": method, + "api_key": self._api_key, + "format": "json", + } + if params: + request_params.update(params) + + if signed: + if not self._shared_secret: + raise ConfigurationError("Last.fm shared secret is required for signed requests") + if self._session_key and "sk" not in request_params: + request_params["sk"] = self._session_key + request_params["api_sig"] = self._build_api_sig(request_params) + + try: + if http_method == "POST": + response = await self._client.post( + LASTFM_API_URL, + data=request_params, + timeout=15.0, + ) + else: + response = await self._client.get( + LASTFM_API_URL, + params=request_params, + timeout=15.0, + ) + + if response.status_code != 200: + raise ExternalServiceError( + f"Last.fm request failed ({response.status_code})", + response.text, + ) + + try: + data = _decode_json_response(response) + except (msgspec.DecodeError, ValueError, TypeError): + raise ExternalServiceError("Last.fm returned invalid JSON") + + self._handle_error_response(data) + _lastfm_circuit_breaker.record_success() + return data + + except (ConfigurationError, ExternalServiceError, ResourceNotFoundError): + raise + except httpx.HTTPError as e: + raise ExternalServiceError(f"Last.fm request failed: {e}") + + async def get_token(self) -> LastFmToken: + data = await self._request("auth.getToken", signed=True, http_method="GET") + return parse_token(data) + + async def get_session(self, token: str) -> LastFmSession: + data = await self._request( + "auth.getSession", + params={"token": token}, + signed=True, + http_method="GET", + ) + return parse_session(data) + + async def validate_api_key(self) -> tuple[bool, str]: + try: + await self._request( + "chart.getTopArtists", + params={"limit": "1"}, + http_method="GET", + ) + return True, "API key is valid" + except ConfigurationError as e: + return False, str(e.message) + except ExternalServiceError as e: + return False, f"Connection failed: {e.message}" + + async def validate_session(self) -> tuple[bool, str]: + if not self._session_key: + return False, "No session key configured" + try: + data = await self._request( + "user.getInfo", + signed=True, + http_method="GET", + ) + user = data.get("user", {}) + username = user.get("name", "") + return True, f"Connected as {username}" + except ConfigurationError as e: + return False, str(e.message) + except ExternalServiceError as e: + return False, f"Session validation failed: {e.message}" + + async def update_now_playing( + self, + artist: str, + track: str, + album: str = "", + duration: int = 0, + mbid: str | None = None, + ) -> bool: + params: dict[str, str] = {"artist": artist, "track": track} + if album: + params["album"] = album + if duration > 0: + params["duration"] = str(duration) + if mbid: + params["mbid"] = mbid + await self._request( + "track.updateNowPlaying", + params=params, + signed=True, + http_method="POST", + ) + logger.info( + "Now playing reported to Last.fm", + extra={"artist": artist, "track": track}, + ) + return True + + async def scrobble( + self, + artist: str, + track: str, + timestamp: int, + album: str = "", + duration: int = 0, + mbid: str | None = None, + ) -> bool: + params: dict[str, str] = { + "artist": artist, + "track": track, + "timestamp": str(timestamp), + } + if album: + params["album"] = album + if duration > 0: + params["duration"] = str(duration) + if mbid: + params["mbid"] = mbid + await self._request( + "track.scrobble", + params=params, + signed=True, + http_method="POST", + ) + logger.info( + "Scrobble submitted to Last.fm", + extra={"artist": artist, "track": track, "timestamp": timestamp}, + ) + return True + + + async def get_user_top_artists( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmArtist]: + if period not in ALLOWED_LASTFM_PERIOD: + period = "overall" + cache_key = f"{LFM_PREFIX}user_top_artists:{username}:{period}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getTopArtists", + params={"user": username, "period": period, "limit": str(limit)}, + ) + artists = [ + parse_top_artist(item) + for item in data.get("topartists", {}).get("artist", []) + ] + await self._cache.set(cache_key, artists, ttl_seconds=LASTFM_USER_CACHE_TTL) + return artists + + async def get_user_top_albums( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmAlbum]: + if period not in ALLOWED_LASTFM_PERIOD: + period = "overall" + cache_key = f"{LFM_PREFIX}user_top_albums:{username}:{period}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getTopAlbums", + params={"user": username, "period": period, "limit": str(limit)}, + ) + albums = [ + parse_top_album(item) + for item in data.get("topalbums", {}).get("album", []) + ] + await self._cache.set(cache_key, albums, ttl_seconds=LASTFM_USER_CACHE_TTL) + return albums + + async def get_user_top_tracks( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmTrack]: + if period not in ALLOWED_LASTFM_PERIOD: + period = "overall" + cache_key = f"{LFM_PREFIX}user_top_tracks:{username}:{period}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getTopTracks", + params={"user": username, "period": period, "limit": str(limit)}, + ) + tracks = [ + parse_top_track(item) + for item in data.get("toptracks", {}).get("track", []) + ] + await self._cache.set(cache_key, tracks, ttl_seconds=LASTFM_USER_CACHE_TTL) + return tracks + + async def get_user_recent_tracks( + self, username: str, limit: int = 50 + ) -> list[LastFmRecentTrack]: + cache_key = f"{LFM_PREFIX}user_recent:{username}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getRecentTracks", + params={"user": username, "limit": str(limit), "extended": "0"}, + ) + tracks = [ + parse_recent_track(item) + for item in data.get("recenttracks", {}).get("track", []) + ] + await self._cache.set(cache_key, tracks, ttl_seconds=LASTFM_USER_CACHE_TTL) + return tracks + + async def get_user_loved_tracks( + self, username: str, limit: int = 50 + ) -> list[LastFmLovedTrack]: + cache_key = f"{LFM_PREFIX}user_loved_tracks:{username}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getLovedTracks", + params={"user": username, "limit": str(limit)}, + ) + tracks = [ + parse_loved_track(item) + for item in data.get("lovedtracks", {}).get("track", []) + ] + await self._cache.set(cache_key, tracks, ttl_seconds=LASTFM_USER_CACHE_TTL) + return tracks + + async def get_user_weekly_artist_chart( + self, username: str + ) -> list[LastFmArtist]: + cache_key = f"{LFM_PREFIX}user_weekly_artists:{username}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getWeeklyArtistChart", + params={"user": username}, + ) + artists = [ + parse_top_artist(item) + for item in data.get("weeklyartistchart", {}).get("artist", []) + ] + await self._cache.set(cache_key, artists, ttl_seconds=LASTFM_USER_CACHE_TTL) + return artists + + async def get_user_weekly_album_chart( + self, username: str + ) -> list[LastFmAlbum]: + cache_key = f"{LFM_PREFIX}user_weekly_albums:{username}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "user.getWeeklyAlbumChart", + params={"user": username}, + ) + albums = [ + parse_weekly_album_chart_item(item) + for item in data.get("weeklyalbumchart", {}).get("album", []) + ] + await self._cache.set(cache_key, albums, ttl_seconds=LASTFM_USER_CACHE_TTL) + return albums + + + async def get_artist_top_tracks( + self, artist: str, mbid: str | None = None, limit: int = 10 + ) -> list[LastFmTrack]: + lookup = mbid or artist + cache_key = f"{LFM_PREFIX}artist_top_tracks:{lookup}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + params: dict[str, str] = {"limit": str(limit)} + if mbid: + params["mbid"] = mbid + else: + params["artist"] = artist + data = await self._request("artist.getTopTracks", params=params) + tracks = [ + parse_top_track(item) + for item in data.get("toptracks", {}).get("track", []) + ] + await self._cache.set(cache_key, tracks, ttl_seconds=LASTFM_ENTITY_CACHE_TTL) + return tracks + + async def get_artist_top_albums( + self, artist: str, mbid: str | None = None, limit: int = 10 + ) -> list[LastFmAlbum]: + lookup = mbid or artist + cache_key = f"{LFM_PREFIX}artist_top_albums:{lookup}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + params: dict[str, str] = {"limit": str(limit)} + if mbid: + params["mbid"] = mbid + else: + params["artist"] = artist + data = await self._request("artist.getTopAlbums", params=params) + albums = [ + parse_top_album(item) + for item in data.get("topalbums", {}).get("album", []) + ] + await self._cache.set(cache_key, albums, ttl_seconds=LASTFM_ENTITY_CACHE_TTL) + return albums + + async def get_artist_info( + self, artist: str, mbid: str | None = None, username: str | None = None + ) -> LastFmArtistInfo | None: + lookup = mbid or artist + cache_key = f"{LFM_PREFIX}artist_info:{lookup}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + params: dict[str, str] = {} + if mbid: + params["mbid"] = mbid + else: + params["artist"] = artist + if username: + params["username"] = username + try: + data = await self._request("artist.getInfo", params=params) + except ResourceNotFoundError: + return None + info = parse_artist_info(data) + await self._cache.set(cache_key, info, ttl_seconds=LASTFM_ENTITY_CACHE_TTL) + return info + + async def get_album_info( + self, + artist: str, + album: str, + mbid: str | None = None, + username: str | None = None, + ) -> LastFmAlbumInfo | None: + lookup = mbid or f"{artist}:{album}" + cache_key = f"{LFM_PREFIX}album_info:{lookup}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + params: dict[str, str] = {} + if mbid: + params["mbid"] = mbid + else: + params["artist"] = artist + params["album"] = album + if username: + params["username"] = username + try: + data = await self._request("album.getInfo", params=params) + except ResourceNotFoundError: + return None + info = parse_album_info(data) + await self._cache.set(cache_key, info, ttl_seconds=LASTFM_ENTITY_CACHE_TTL) + return info + + async def get_similar_artists( + self, artist: str, mbid: str | None = None, limit: int = 30 + ) -> list[LastFmSimilarArtist]: + lookup = mbid or artist + cache_key = f"{LFM_PREFIX}similar_artists:{lookup}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + params: dict[str, str] = {"limit": str(limit)} + if mbid: + params["mbid"] = mbid + else: + params["artist"] = artist + data = await self._request("artist.getSimilar", params=params) + similar = [ + parse_similar_artist(item) + for item in data.get("similarartists", {}).get("artist", []) + ] + await self._cache.set(cache_key, similar, ttl_seconds=LASTFM_ENTITY_CACHE_TTL) + return similar + + + async def get_global_top_artists(self, limit: int = 50) -> list[LastFmArtist]: + cache_key = f"{LFM_PREFIX}global_top_artists:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "chart.getTopArtists", + params={"limit": str(limit)}, + ) + artists = [ + parse_top_artist(item) + for item in data.get("artists", {}).get("artist", []) + ] + await self._cache.set(cache_key, artists, ttl_seconds=LASTFM_GLOBAL_CACHE_TTL) + return artists + + async def get_global_top_tracks(self, limit: int = 50) -> list[LastFmTrack]: + cache_key = f"{LFM_PREFIX}global_top_tracks:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "chart.getTopTracks", + params={"limit": str(limit)}, + ) + tracks = [ + parse_top_track(item) + for item in data.get("toptracks", {}).get("track", []) + ] + await self._cache.set(cache_key, tracks, ttl_seconds=LASTFM_GLOBAL_CACHE_TTL) + return tracks + + async def get_tag_top_artists( + self, tag: str, limit: int = 50 + ) -> list[LastFmArtist]: + cache_key = f"{LFM_PREFIX}tag_top_artists:{tag}:{limit}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._request( + "tag.getTopArtists", + params={"tag": tag, "limit": str(limit)}, + ) + artists = [ + parse_top_artist(item) + for item in data.get("topartists", {}).get("artist", []) + ] + await self._cache.set(cache_key, artists, ttl_seconds=LASTFM_GLOBAL_CACHE_TTL) + return artists diff --git a/backend/repositories/lidarr/__init__.py b/backend/repositories/lidarr/__init__.py new file mode 100644 index 0000000..b87054a --- /dev/null +++ b/backend/repositories/lidarr/__init__.py @@ -0,0 +1,19 @@ +from .base import LidarrBase +from .library import LidarrLibraryRepository +from .artist import LidarrArtistRepository +from .history import LidarrHistoryRepository +from .album import LidarrAlbumRepository +from .config import LidarrConfigRepository +from .queue import LidarrQueueRepository +from .repository import LidarrRepository + +__all__ = [ + "LidarrBase", + "LidarrLibraryRepository", + "LidarrArtistRepository", + "LidarrHistoryRepository", + "LidarrAlbumRepository", + "LidarrConfigRepository", + "LidarrQueueRepository", + "LidarrRepository", +] diff --git a/backend/repositories/lidarr/album.py b/backend/repositories/lidarr/album.py new file mode 100644 index 0000000..a76bec7 --- /dev/null +++ b/backend/repositories/lidarr/album.py @@ -0,0 +1,451 @@ +import asyncio +import logging +import time +from typing import Any, Optional +from core.exceptions import ExternalServiceError +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.cache.cache_keys import ( + LIDARR_ALBUM_IMAGE_PREFIX, LIDARR_ALBUM_DETAILS_PREFIX, + LIDARR_ALBUM_TRACKS_PREFIX, LIDARR_TRACKFILE_PREFIX, LIDARR_ALBUM_TRACKFILES_PREFIX, + LIDARR_PREFIX, +) +from infrastructure.http.deduplication import RequestDeduplicator +from .base import LidarrBase +from .history import LidarrHistoryRepository + +logger = logging.getLogger(__name__) + +_album_details_deduplicator = RequestDeduplicator() + + +def _safe_int(value: Any, fallback: int = 0) -> int: + """Coerce a value to int, returning fallback for non-numeric inputs.""" + if isinstance(value, int): + return value + try: + return int(value) + except (TypeError, ValueError): + return fallback + + +class LidarrAlbumRepository(LidarrHistoryRepository): + async def get_all_albums(self) -> list[dict[str, Any]]: + return await self._get_all_albums_raw() + + async def search_for_album(self, term: str) -> list[dict]: + params = {"term": term} + return await self._get("/api/v1/album/lookup", params=params) + + async def get_album_image_url(self, album_mbid: str, size: Optional[int] = 500) -> Optional[str]: + cache_key = f"{LIDARR_ALBUM_IMAGE_PREFIX}{album_mbid}:{size or 'orig'}" + cached_url = await self._cache.get(cache_key) + if cached_url is not None: + return cached_url if cached_url else None + + try: + data = await self._get("/api/v1/album", params={"foreignAlbumId": album_mbid}) + if not data or not isinstance(data, list) or len(data) == 0: + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + album = data[0] + album_id = album.get("id") + images = album.get("images", []) + + if not album_id or not images: + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + cover_url = None + for img in images: + cover_type = img.get("coverType", "").lower() + url_path = img.get("url", "") + + if not url_path: + continue + + if url_path.startswith("http"): + constructed_url = url_path + else: + constructed_url = self._build_api_media_cover_url_album(album_id, url_path, size) + + if cover_type == "cover": + cover_url = constructed_url + break + elif not cover_url: + cover_url = constructed_url + + if cover_url: + logger.debug(f"[Lidarr:Album] Found cover for {album_mbid[:8]}: {cover_url[:60]}...") + await self._cache.set(cache_key, cover_url, ttl_seconds=3600) + return cover_url + + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get album image from Lidarr for {album_mbid}: {e}") + return None + + async def get_album_details(self, album_mbid: str) -> Optional[dict[str, Any]]: + cache_key = f"{LIDARR_ALBUM_DETAILS_PREFIX}{album_mbid}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached if cached else None + + return await _album_details_deduplicator.dedupe( + f"lidarr-album-details:{album_mbid}", + lambda: self._fetch_album_details(album_mbid, cache_key), + ) + + async def _fetch_album_details(self, album_mbid: str, cache_key: str) -> Optional[dict[str, Any]]: + + try: + data = await self._get("/api/v1/album", params={"foreignAlbumId": album_mbid}) + if not data or not isinstance(data, list) or len(data) == 0: + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + album = data[0] + album_id = album.get("id") + + cover_url = prefer_release_group_cover_url( + album.get("foreignAlbumId"), + self._get_album_cover_url(album.get("images", []), album_id), + size=500, + ) + + links = [] + for link in album.get("links", []): + link_name = link.get("name", "") + link_url = link.get("url", "") + if link_url: + links.append({"name": link_name, "url": link_url}) + + artist_data = album.get("artist", {}) + + releases = album.get("releases", []) + primary_release = None + for rel in releases: + if rel.get("monitored"): + primary_release = rel + break + if not primary_release and releases: + primary_release = releases[0] + + media = [] + track_count = 0 + if primary_release: + for medium in primary_release.get("media", []): + medium_info = { + "number": medium.get("mediumNumber", 1), + "format": medium.get("mediumFormat", "Unknown"), + "track_count": medium.get("mediumTrackCount", 0), + } + media.append(medium_info) + track_count += medium.get("mediumTrackCount", 0) + + result = { + "id": album_id, + "title": album.get("title", "Unknown"), + "mbid": album.get("foreignAlbumId"), + "overview": album.get("overview"), + "disambiguation": album.get("disambiguation"), + "album_type": album.get("albumType"), + "secondary_types": album.get("secondaryTypes", []), + "release_date": album.get("releaseDate"), + "genres": album.get("genres", []), + "links": links, + "cover_url": cover_url, + "monitored": album.get("monitored", False), + "statistics": album.get("statistics", {}), + "ratings": album.get("ratings", {}), + "artist_name": artist_data.get("artistName", "Unknown"), + "artist_mbid": artist_data.get("foreignArtistId"), + "media": media, + "track_count": track_count, + } + + await self._cache.set(cache_key, result, ttl_seconds=300) + logger.debug(f"[Lidarr] Fetched album details for {album_mbid[:8]}") + return result + + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get album details from Lidarr for {album_mbid}: {e}") + return None + + async def get_album_tracks(self, album_id: int) -> list[dict[str, Any]]: + cache_key = f"{LIDARR_ALBUM_TRACKS_PREFIX}{album_id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + data = await self._get("/api/v1/track", params={"albumId": album_id}) + if not data or not isinstance(data, list): + await self._cache.set(cache_key, [], ttl_seconds=300) + return [] + + tracks = [] + for track in data: + raw_track_num = track.get("trackNumber") or track.get("position") or track.get("absoluteTrackNumber", 0) + track_number = _safe_int(raw_track_num) + track_info = { + "position": track_number, + "track_number": track_number, + "absolute_position": _safe_int(track.get("absoluteTrackNumber", 0)), + "disc_number": _safe_int(track.get("mediumNumber", 1), fallback=1), + "title": track.get("title", "Unknown"), + "duration_ms": track.get("duration", 0), + "track_file_id": track.get("trackFileId"), + "has_file": track.get("hasFile", False), + } + tracks.append(track_info) + + tracks.sort(key=lambda t: (t["disc_number"], t["track_number"])) + + await self._cache.set(cache_key, tracks, ttl_seconds=300) + logger.debug(f"[Lidarr] Fetched {len(tracks)} tracks for album ID {album_id}") + return tracks + + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get tracks from Lidarr for album ID {album_id}: {e}") + return [] + + async def get_track_file(self, track_file_id: int) -> dict[str, Any] | None: + cache_key = f"{LIDARR_TRACKFILE_PREFIX}{track_file_id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + data = await self._get(f"/api/v1/trackfile/{track_file_id}") + if data: + await self._cache.set(cache_key, data, ttl_seconds=600) + return data + except Exception as e: # noqa: BLE001 + logger.error("Failed to get track file %s: %s", track_file_id, e) + return None + + async def get_track_files_by_album(self, album_id: int) -> list[dict[str, Any]]: + cache_key = f"{LIDARR_ALBUM_TRACKFILES_PREFIX}{album_id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + data = await self._get( + "/api/v1/trackfile", + params={"albumId": album_id}, + ) + if not data or not isinstance(data, list): + return [] + await self._cache.set(cache_key, data, ttl_seconds=300) + return data + except Exception as e: # noqa: BLE001 + logger.error("Failed to get track files for album %s: %s", album_id, e) + return [] + + async def _get_album_by_foreign_id(self, album_mbid: str) -> Optional[dict[str, Any]]: + try: + items = await self._get("/api/v1/album", params={"foreignAlbumId": album_mbid}) + return items[0] if items else None + except Exception as e: # noqa: BLE001 + logger.warning(f"Error getting album by foreign ID {album_mbid}: {e}") + return None + + async def delete_album(self, album_id: int, delete_files: bool = False) -> bool: + try: + params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"} + await self._delete(f"/api/v1/album/{album_id}", params=params) + await self._invalidate_album_list_caches() + logger.info(f"Deleted album ID {album_id} (deleteFiles={delete_files})") + return True + except Exception as e: + logger.error(f"Failed to delete album {album_id}: {e}") + raise + + async def add_album(self, musicbrainz_id: str, artist_repo) -> dict: + if not musicbrainz_id or not isinstance(musicbrainz_id, str): + raise ExternalServiceError("Invalid MBID provided") + + lookup = await self._get("/api/v1/album/lookup", params={"term": f"mbid:{musicbrainz_id}"}) + if not lookup: + raise ExternalServiceError( + f"Album not found in Lidarr lookup (MBID: {musicbrainz_id})" + ) + + candidate = next( + (a for a in lookup if a.get("foreignAlbumId") == musicbrainz_id), + lookup[0] + ) + album_title = candidate.get("title", "Unknown Album") + album_type = candidate.get("albumType", "Unknown") + secondary_types = candidate.get("secondaryTypes", []) + + artist_info = candidate.get("artist") or {} + artist_mbid = artist_info.get("mbId") or artist_info.get("foreignArtistId") + artist_name = artist_info.get("artistName") + if not artist_mbid: + raise ExternalServiceError("Album lookup did not include artist MBID") + + artist = await artist_repo._ensure_artist_exists(artist_mbid, artist_name) + artist_id = artist["id"] + + album_obj = await self._get_album_by_foreign_id(musicbrainz_id) + action = "exists" + + if not album_obj: + async def album_is_indexed(): + a = await self._get_album_by_foreign_id(musicbrainz_id) + return a and a.get("id") and a.get("releases") + + album_obj = await self._wait_for(album_is_indexed, timeout=60.0, poll=3.0) + + if not album_obj: + profile_id = artist.get("qualityProfileId") + if profile_id is None: + try: + qps = await self._get("/api/v1/qualityprofile") + if not qps: + raise ExternalServiceError("No quality profiles in Lidarr") + profile_id = qps[0]["id"] + except Exception: # noqa: BLE001 + profile_id = self._settings.quality_profile_id + + payload = { + "title": album_title, + "artistId": artist_id, + "foreignAlbumId": musicbrainz_id, + "monitored": True, + "anyReleaseOk": True, + "profileId": profile_id, + "addOptions": {"addType": "automatic", "searchForNewAlbum": True}, + } + + try: + album_obj = await self._post("/api/v1/album", payload) + action = "added" + album_obj = await self._wait_for(album_is_indexed, timeout=120.0, poll=2.0) + except Exception as e: + if "POST failed" in str(e) or "405" in str(e): + raise ExternalServiceError( + f"Cannot add this {album_type}. " + f"Lidarr rejected adding '{album_title}'. This is likely because your Lidarr " + f"Metadata Profile is configured to exclude {album_type}s{' (' + ', '.join(secondary_types) + ')' if secondary_types else ''}. " + f"To fix this: Go to Lidarr -> Settings -> Profiles -> Metadata Profiles, " + f"and enable '{album_type}' in your active profile." + ) + else: + raise + + if not album_obj or "id" not in album_obj: + raise ExternalServiceError( + f"Cannot add this {album_type}. " + f"'{album_title}' could not be found in Lidarr after the artist refresh. This usually means " + f"your Lidarr Metadata Profile is configured to exclude {album_type}s. " + f"To fix this: Go to Lidarr -> Settings -> Profiles -> Metadata Profiles, " + f"enable '{album_type}', then refresh the artist in Lidarr." + ) + + album_id = album_obj["id"] + + await self._wait_for_artist_commands_to_complete(artist_id, timeout=600.0) + await self._monitor_artist_and_album(artist_id, album_id, musicbrainz_id, album_title) + + try: + await self._post_command({"name": "AlbumSearch", "albumIds": [album_id]}) + except ExternalServiceError as exc: + logger.warning("Failed to queue Lidarr AlbumSearch for %s: %s", musicbrainz_id, exc) + + final_album = await self._get_album_by_foreign_id(musicbrainz_id) + + if final_album and not final_album.get("monitored"): + try: + await self._put("/api/v1/album/monitor", { + "albumIds": [album_id], + "monitored": True + }) + await asyncio.sleep(2.0) + final_album = await self._get_album_by_foreign_id(musicbrainz_id) + except ExternalServiceError as exc: + logger.warning("Failed to update Lidarr album monitor state for %s: %s", musicbrainz_id, exc) + + await self._invalidate_album_list_caches() + await self._cache.clear_prefix(f"{LIDARR_PREFIX}artists:mbids") + + msg = "Album added & monitored" if action == "added" else "Album exists; monitored ensured" + return { + "message": f"{msg}: {album_title}", + "payload": final_album or album_obj + } + + async def _wait_for_artist_commands_to_complete(self, artist_id: int, timeout: float = 600.0) -> None: + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + try: + commands = await self._get("/api/v1/command") + if not commands: + break + + has_running_commands = False + for cmd in commands: + status = cmd.get("status") or cmd.get("state") + if str(status).lower() in ["queued", "started"]: + body = cmd.get("body", {}) + cmd_artist_id = body.get("artistId") + cmd_artist_ids = body.get("artistIds", []) + + if cmd_artist_id == artist_id or artist_id in cmd_artist_ids: + has_running_commands = True + break + + if not has_running_commands: + break + + except Exception as e: # noqa: BLE001 + logger.warning(f"Error checking command status: {e}") + + await asyncio.sleep(5.0) + + await asyncio.sleep(5.0) + + async def _monitor_artist_and_album( + self, + artist_id: int, + album_id: int, + album_mbid: str, + album_title: str, + max_attempts: int = 3 + ) -> None: + for attempt in range(max_attempts): + try: + await self._put( + "/api/v1/artist/editor", + {"artistIds": [artist_id], "monitored": True, "monitorNewItems": "none"}, + ) + + await asyncio.sleep(5.0 + (attempt * 3.0)) + + await self._put("/api/v1/album/monitor", {"albumIds": [album_id], "monitored": True}) + + async def both_monitored(): + album = await self._get_album_by_foreign_id(album_mbid) + artist_data = await self._get(f"/api/v1/artist/{artist_id}") + return (album and album.get("monitored")) and (artist_data and artist_data.get("monitored")) + + timeout = 20.0 + (attempt * 10.0) + if await self._wait_for(both_monitored, timeout=timeout, poll=1.0): + return + + if attempt < max_attempts - 1: + logger.warning(f"Monitoring verification failed, attempt {attempt + 1}/{max_attempts}") + await asyncio.sleep(5.0) + + except Exception as e: # noqa: BLE001 + if attempt == max_attempts - 1: + raise ExternalServiceError( + f"Failed to set monitoring status after {max_attempts} attempts: {str(e)}" + ) + await asyncio.sleep(5.0) diff --git a/backend/repositories/lidarr/artist.py b/backend/repositories/lidarr/artist.py new file mode 100644 index 0000000..a15d250 --- /dev/null +++ b/backend/repositories/lidarr/artist.py @@ -0,0 +1,286 @@ +import asyncio +import logging +from typing import Any, Optional +from core.exceptions import ExternalServiceError +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.cache.cache_keys import ( + LIDARR_ARTIST_IMAGE_PREFIX, LIDARR_ARTIST_DETAILS_PREFIX, LIDARR_ARTIST_ALBUMS_PREFIX, +) +from .base import LidarrBase + +logger = logging.getLogger(__name__) + + +class LidarrArtistRepository(LidarrBase): + async def get_artist_image_url(self, artist_mbid: str, size: Optional[int] = 250) -> Optional[str]: + cache_key = f"{LIDARR_ARTIST_IMAGE_PREFIX}{artist_mbid}:{size or 'orig'}" + cached_url = await self._cache.get(cache_key) + if cached_url is not None: + if cached_url: + logger.debug(f"[Lidarr:Image] Cache HIT for {artist_mbid[:8]}") + else: + logger.debug(f"[Lidarr:Image] Cache HIT (negative) for {artist_mbid[:8]}") + return cached_url if cached_url else None + + logger.info(f"[Lidarr:Image] Cache MISS - querying Lidarr for {artist_mbid[:8]}") + try: + data = await self._get("/api/v1/artist", params={"mbId": artist_mbid}) + if not data or not isinstance(data, list) or len(data) == 0: + logger.info(f"[Lidarr:Image] Artist not found in Lidarr for {artist_mbid[:8]}") + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + artist = data[0] + artist_id = artist.get("id") + artist_name = artist.get("artistName", "Unknown") + images = artist.get("images", []) + logger.debug(f"[Lidarr:Image] Found artist '{artist_name}' (id={artist_id}) with {len(images)} images") + + if not artist_id or not images: + logger.info(f"[Lidarr:Image] No images for {artist_mbid[:8]} ({artist_name})") + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + poster_url = None + fanart_url = None + for img in images: + cover_type = img.get("coverType", "").lower() + url_path = img.get("url", "") + + if not url_path: + continue + + if url_path.startswith("http"): + constructed_url = url_path + else: + constructed_url = self._build_api_media_cover_url(artist_id, url_path, size) + + if cover_type == "poster": + poster_url = constructed_url + break + elif cover_type == "fanart" and not fanart_url: + fanart_url = constructed_url + + image_url = poster_url or fanart_url + if image_url: + logger.info(f"[Lidarr:Image] Found image for {artist_mbid[:8]} ({artist_name}): {image_url[:60]}...") + await self._cache.set(cache_key, image_url, ttl_seconds=3600) + return image_url + + logger.info(f"[Lidarr:Image] No poster/fanart for {artist_mbid[:8]} ({artist_name})") + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + except Exception as e: # noqa: BLE001 + logger.warning(f"[Lidarr:Image] Exception for {artist_mbid[:8]}: {e}") + return None + + async def get_artist_details(self, artist_mbid: str) -> Optional[dict[str, Any]]: + cache_key = f"{LIDARR_ARTIST_DETAILS_PREFIX}{artist_mbid}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached if cached else None + + try: + data = await self._get("/api/v1/artist", params={"mbId": artist_mbid}) + if not data or not isinstance(data, list) or len(data) == 0: + await self._cache.set(cache_key, "", ttl_seconds=300) + return None + + artist = data[0] + artist_id = artist.get("id") + + image_urls = self._get_artist_image_urls(artist.get("images", []), artist_id) + + links = [] + for link in artist.get("links", []): + link_name = link.get("name", "") + link_url = link.get("url", "") + if link_url: + links.append({"name": link_name, "url": link_url}) + + result = { + "id": artist_id, + "name": artist.get("artistName", "Unknown"), + "mbid": artist.get("foreignArtistId"), + "overview": artist.get("overview"), + "disambiguation": artist.get("disambiguation"), + "artist_type": artist.get("artistType"), + "status": artist.get("status"), + "genres": artist.get("genres", []), + "links": links, + "poster_url": image_urls["poster"], + "fanart_url": image_urls["fanart"], + "banner_url": image_urls["banner"], + "monitored": artist.get("monitored", False), + "statistics": artist.get("statistics", {}), + "ratings": artist.get("ratings", {}), + } + + await self._cache.set(cache_key, result, ttl_seconds=300) + logger.debug(f"[Lidarr] Fetched artist details for {artist_mbid[:8]}") + return result + + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get artist details from Lidarr for {artist_mbid}: {e}") + return None + + async def get_artist_albums(self, artist_mbid: str) -> list[dict[str, Any]]: + cache_key = f"{LIDARR_ARTIST_ALBUMS_PREFIX}{artist_mbid}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + artist_data = await self._get("/api/v1/artist", params={"mbId": artist_mbid}) + if not artist_data or not isinstance(artist_data, list) or len(artist_data) == 0: + await self._cache.set(cache_key, [], ttl_seconds=300) + return [] + + artist_id = artist_data[0].get("id") + if not artist_id: + await self._cache.set(cache_key, [], ttl_seconds=300) + return [] + + album_data = await self._get("/api/v1/album", params={"artistId": artist_id, "includeAllArtistAlbums": True}) + if not album_data or not isinstance(album_data, list): + await self._cache.set(cache_key, [], ttl_seconds=300) + return [] + + albums = [] + for album in album_data: + album_id = album.get("id") + album_mbid = album.get("foreignAlbumId") + images = album.get("images", []) + cover_url = None + for img in images: + url_path = img.get("url", "") + if url_path: + if url_path.startswith("http"): + cover_url = url_path + else: + cover_url = self._build_api_media_cover_url_album(album_id, url_path, 250) + break + + cover_url = prefer_release_group_cover_url(album_mbid, cover_url, size=500) + + year = None + if release_date := album.get("releaseDate"): + try: + year = int(release_date.split("-")[0]) + except (ValueError, IndexError): + pass + + statistics = album.get("statistics", {}) + track_file_count = statistics.get("trackFileCount", 0) + + album_info = { + "id": album_id, + "title": album.get("title", "Unknown"), + "mbid": album_mbid, + "album_type": album.get("albumType"), + "secondary_types": album.get("secondaryTypes", []), + "release_date": album.get("releaseDate"), + "year": year, + "monitored": album.get("monitored", False), + "track_file_count": track_file_count, + "cover_url": cover_url, + "genres": album.get("genres", []), + } + albums.append(album_info) + + albums.sort(key=lambda a: a.get("release_date") or "", reverse=True) + + await self._cache.set(cache_key, albums, ttl_seconds=300) + logger.debug(f"[Lidarr] Fetched {len(albums)} albums for artist {artist_mbid[:8]}") + return albums + + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get artist albums from Lidarr for {artist_mbid}: {e}") + return [] + + async def _get_artist_by_id(self, artist_id: int) -> Optional[dict[str, Any]]: + try: + return await self._get(f"/api/v1/artist/{artist_id}") + except Exception as e: # noqa: BLE001 + logger.warning(f"Error getting artist {artist_id}: {e}") + return None + + async def delete_artist(self, artist_id: int, delete_files: bool = False) -> bool: + try: + params = {"deleteFiles": str(delete_files).lower(), "addImportListExclusion": "false"} + await self._delete(f"/api/v1/artist/{artist_id}", params=params) + logger.info(f"Deleted artist ID {artist_id} (deleteFiles={delete_files})") + return True + except Exception as e: + logger.error(f"Failed to delete artist {artist_id}: {e}") + raise + + async def _ensure_artist_exists(self, artist_mbid: str, artist_name_hint: Optional[str] = None) -> dict[str, Any]: + try: + items = await self._get("/api/v1/artist", params={"mbId": artist_mbid}) + if items: + logger.info(f"Artist already exists: {items[0].get('artistName')}") + return items[0] + except ExternalServiceError as exc: + logger.debug("Failed to query existing Lidarr artist %s: %s", artist_mbid, exc) + + try: + roots = await self._get("/api/v1/rootfolder") + if not roots: + raise ExternalServiceError("No root folders configured in Lidarr") + root = next((r for r in roots if r.get("accessible", True)), roots[0]) + except ExternalServiceError as e: + raise ExternalServiceError(f"Failed to get root folders: {e}") + + qp_id = root.get("defaultQualityProfileId") or self._settings.quality_profile_id + mp_id = root.get("defaultMetadataProfileId") or self._settings.metadata_profile_id + + try: + lookup = await self._get("/api/v1/artist/lookup", params={"term": f"mbid:{artist_mbid}"}) + if not lookup: + raise ExternalServiceError(f"Artist not found in lookup: {artist_mbid}") + remote = lookup[0] + artist_name = remote.get("artistName") or artist_name_hint or "Unknown Artist" + except Exception as e: # noqa: BLE001 + raise ExternalServiceError(f"Failed to lookup artist: {e}") + + payload = { + "artistName": artist_name, + "mbId": artist_mbid, + "foreignArtistId": artist_mbid, + "qualityProfileId": qp_id, + "metadataProfileId": mp_id, + "rootFolderPath": root.get("path"), + "monitored": False, + "monitorNewItems": "none", + "addOptions": { + "monitor": "none", + "monitored": False, + "searchForMissingAlbums": False, + }, + } + + try: + created = await self._post("/api/v1/artist", payload) + artist_id = created["id"] + logger.info(f"Created artist {artist_name} (ID: {artist_id}), triggering refresh commands") + + logger.info(f"Refreshing artist {artist_name} library (this may take several minutes)...") + await self._await_command( + {"name": "RefreshArtist", "artistId": artist_id}, + timeout=600.0 + ) + + logger.info(f"Rescanning artist {artist_name} library...") + await self._await_command( + {"name": "RescanArtist", "artistId": artist_id}, + timeout=300.0 + ) + + await asyncio.sleep(5.0) + + logger.info(f"Artist {artist_name} library refresh complete") + return created + except Exception as e: # noqa: BLE001 + raise ExternalServiceError(f"Failed to add artist: {e}") diff --git a/backend/repositories/lidarr/base.py b/backend/repositories/lidarr/base.py new file mode 100644 index 0000000..c598ab3 --- /dev/null +++ b/backend/repositories/lidarr/base.py @@ -0,0 +1,279 @@ +import asyncio +import httpx +import logging +import msgspec +import time +from typing import Any, Optional +from core.config import Settings +from core.exceptions import ExternalServiceError +from infrastructure.cache.cache_keys import lidarr_raw_albums_key, lidarr_requested_mbids_key, LIDARR_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.http.deduplication import get_deduplicator +from infrastructure.resilience.retry import with_retry, CircuitBreaker + +logger = logging.getLogger(__name__) + +_lidarr_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="lidarr" +) + +LidarrJsonObject = dict[str, Any] +LidarrJsonArray = list[LidarrJsonObject] +LidarrJson = LidarrJsonObject | LidarrJsonArray + + +def reset_lidarr_circuit_breaker(): + _lidarr_circuit_breaker.reset() + + +def _decode_json_response(response: httpx.Response) -> LidarrJson: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=LidarrJson) + return response.json() + + +class LidarrBase: + def __init__( + self, + settings: Settings, + http_client: httpx.AsyncClient, + cache: CacheInterface + ): + self._settings = settings + self._client = http_client + self._cache = cache + self._base_url = settings.lidarr_url + + def is_configured(self) -> bool: + return bool(self._settings.lidarr_api_key) + + def _get_headers(self) -> dict[str, str]: + return { + "X-Api-Key": self._settings.lidarr_api_key, + "Accept": "application/json", + "Content-Type": "application/json", + } + + @with_retry( + max_attempts=3, + base_delay=1.0, + max_delay=5.0, + circuit_breaker=_lidarr_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError) + ) + async def _request( + self, + method: str, + endpoint: str, + params: Optional[dict[str, Any]] = None, + json_data: Optional[dict[str, Any]] = None, + ) -> Any: + if not self.is_configured(): + raise ExternalServiceError("Lidarr is not configured (no API key)") + + url = f"{self._base_url}{endpoint}" + + try: + response = await self._client.request( + method, + url, + headers=self._get_headers(), + params=params, + json=json_data, + ) + + if method == "DELETE" and response.status_code in (200, 202, 204): + if response.status_code == 204 or not response.content: + return None + elif method == "DELETE": + raise ExternalServiceError( + f"Lidarr {method} failed ({response.status_code})", + response.text + ) + elif method == "GET" and response.status_code != 200: + raise ExternalServiceError( + f"Lidarr {method} failed ({response.status_code})", + response.text + ) + elif method in ("POST", "PUT") and response.status_code not in (200, 201, 202): + raise ExternalServiceError( + f"Lidarr {method} failed ({response.status_code})", + response.text + ) + + try: + return _decode_json_response(response) + except (msgspec.DecodeError, ValueError, TypeError): + return None + + except httpx.HTTPError as e: + raise ExternalServiceError(f"Lidarr request failed: {str(e)}") + + async def _get(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> Any: + return await self._request("GET", endpoint, params=params) + + async def _get_all_albums_raw(self) -> list[dict[str, Any]]: + cache_key = lidarr_raw_albums_key() + cached = await self._cache.get(cache_key) + if cached is not None: + return cached if isinstance(cached, list) else [] + + deduplicator = get_deduplicator() + data = await deduplicator.dedupe(cache_key, lambda: self._get("/api/v1/album")) + if not isinstance(data, list): + return [] + + await self._cache.set(cache_key, data, ttl_seconds=300) + return data + + async def _invalidate_album_list_caches(self) -> None: + await self._cache.delete(lidarr_raw_albums_key()) + await self._cache.clear_prefix(f"{LIDARR_PREFIX}library:") + await self._cache.delete(lidarr_requested_mbids_key()) + + async def _post(self, endpoint: str, data: dict[str, Any]) -> Any: + return await self._request("POST", endpoint, json_data=data) + + async def _put(self, endpoint: str, data: dict[str, Any]) -> Any: + return await self._request("PUT", endpoint, json_data=data) + + async def _delete(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> Any: + return await self._request("DELETE", endpoint, params=params) + + async def _post_command(self, body: dict[str, Any]) -> Any: + try: + return await self._post("/api/v1/command", body) + except ExternalServiceError as exc: + logger.warning("Failed to post Lidarr command %s: %s", body.get("name"), exc) + return None + + async def _get_command(self, cmd_id: int) -> Any: + return await self._get(f"/api/v1/command/{cmd_id}") + + async def _await_command(self, body: dict[str, Any], timeout: float = 60.0, poll: float = 0.5) -> dict[str, Any] | None: + try: + cmd = await self._post_command(body) + if not cmd or "id" not in cmd: + await asyncio.sleep(min(timeout, 5.0)) + return None + + cmd_id = cmd["id"] + deadline = time.monotonic() + timeout + last_status = None + + while time.monotonic() < deadline: + await asyncio.sleep(poll) + try: + status = await self._get_command(cmd_id) + last_status = status + except ExternalServiceError as exc: + logger.debug("Lidarr command %s status poll failed: %s", cmd_id, exc) + continue + + state = (status or {}).get("status") or (status or {}).get("state") + if str(state).lower() in {"completed", "failed", "aborted", "cancelled"}: + return status + + return last_status + except ExternalServiceError as exc: + logger.warning("Failed to await Lidarr command %s: %s", body.get("name"), exc) + return None + + async def _wait_for( + self, + fetch_coro_factory, + stop=lambda v: bool(v), + timeout: float = 30.0, + poll: float = 0.5 + ): + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + try: + last = await fetch_coro_factory() + if stop(last): + return last + except ExternalServiceError as exc: + logger.debug("Lidarr wait_for poll failed: %s", exc) + await asyncio.sleep(poll) + return last + + def _build_api_media_cover_url(self, artist_id: int, url_path: str, size: Optional[int]) -> str: + path_part = url_path.split("?")[0] + filename = path_part.rsplit("/", 1)[-1] if "/" in path_part else path_part + + if size and "." in filename: + base, ext = filename.rsplit(".", 1) + if not base.endswith(f"-{size}"): + filename = f"{base}-{size}.{ext}" + + return f"{self._base_url}/api/v1/MediaCover/artist/{artist_id}/{filename}?apikey={self._settings.lidarr_api_key}" + + def _build_api_media_cover_url_album(self, album_id: int, url_path: str, size: Optional[int]) -> str: + path_part = url_path.split("?")[0] + filename = path_part.rsplit("/", 1)[-1] if "/" in path_part else path_part + + if size and "." in filename: + base, ext = filename.rsplit(".", 1) + if not base.endswith(f"-{size}"): + filename = f"{base}-{size}.{ext}" + + return f"{self._base_url}/api/v1/MediaCover/album/{album_id}/{filename}?apikey={self._settings.lidarr_api_key}" + + def _get_album_cover_url(self, images: list[dict], album_id: Optional[int], size: int = 500) -> Optional[str]: + if not images: + return None + + cover_url = None + for img in images: + cover_type = img.get("coverType", "").lower() + remote_url = img.get("remoteUrl") + local_url = img.get("url", "") + + if remote_url: + constructed_url = remote_url + elif local_url and local_url.startswith("http"): + constructed_url = local_url + elif local_url and album_id: + constructed_url = self._build_api_media_cover_url_album(album_id, local_url, size) + else: + continue + + if cover_type == "cover": + return constructed_url + elif not cover_url: + cover_url = constructed_url + + return cover_url + + def _get_artist_image_urls(self, images: list[dict], artist_id: Optional[int], size: int = 500) -> dict[str, Optional[str]]: + result: dict[str, Optional[str]] = {"poster": None, "fanart": None, "banner": None} + + if not images: + return result + + for img in images: + cover_type = img.get("coverType", "").lower() + if cover_type not in result: + continue + + remote_url = img.get("remoteUrl") + local_url = img.get("url", "") + + if remote_url: + constructed_url = remote_url + elif local_url and local_url.startswith("http"): + constructed_url = local_url + elif local_url and artist_id: + constructed_url = self._build_api_media_cover_url(artist_id, local_url, size) + else: + continue + + if not result[cover_type]: + result[cover_type] = constructed_url + + return result diff --git a/backend/repositories/lidarr/config.py b/backend/repositories/lidarr/config.py new file mode 100644 index 0000000..f2fa6ca --- /dev/null +++ b/backend/repositories/lidarr/config.py @@ -0,0 +1,64 @@ +import logging +from typing import Any +from models.common import ServiceStatus +from models.request import QueueItem +from infrastructure.cache.cache_keys import LIDARR_PREFIX +from .base import LidarrBase + +logger = logging.getLogger(__name__) + +LIDARR_QUEUE_KEY = f"{LIDARR_PREFIX}queue" +LIDARR_QUEUE_TTL = 30 + + +class LidarrConfigRepository(LidarrBase): + async def get_status(self) -> ServiceStatus: + try: + data = await self._get("/api/v1/system/status") + return ServiceStatus(status="ok", version=data.get("version")) + except Exception as e: # noqa: BLE001 + return ServiceStatus(status="error", message=str(e)) + + async def get_queue(self) -> list[QueueItem]: + cached = await self._cache.get(LIDARR_QUEUE_KEY) + if cached is not None: + return cached + + data = await self._get("/api/v1/queue") + items = data.get("records", []) if isinstance(data, dict) else data + + queue_items = [] + for item in items: + album_data = item.get("album", {}) + artist_data = album_data.get("artist", {}) + + queue_items.append( + QueueItem( + artist=artist_data.get("artistName", "Unknown"), + album=album_data.get("title", "Unknown"), + status=item.get("status", "unknown"), + progress=None, + eta=None, + musicbrainz_id=album_data.get("foreignAlbumId"), + ) + ) + + await self._cache.set(LIDARR_QUEUE_KEY, queue_items, ttl_seconds=LIDARR_QUEUE_TTL) + return queue_items + + async def get_quality_profiles(self) -> list[dict[str, Any]]: + return await self._get("/api/v1/qualityprofile") + + async def get_metadata_profiles(self) -> list[dict[str, Any]]: + return await self._get("/api/v1/metadataprofile") + + async def get_metadata_profile(self, profile_id: int) -> dict[str, Any]: + return await self._get(f"/api/v1/metadataprofile/{profile_id}") + + async def update_metadata_profile( + self, profile_id: int, profile_data: dict[str, Any] + ) -> dict[str, Any]: + return await self._put(f"/api/v1/metadataprofile/{profile_id}", profile_data) + + async def get_root_folders(self) -> list[dict[str, Any]]: + return await self._get("/api/v1/rootfolder") diff --git a/backend/repositories/lidarr/history.py b/backend/repositories/lidarr/history.py new file mode 100644 index 0000000..4d3b7e5 --- /dev/null +++ b/backend/repositories/lidarr/history.py @@ -0,0 +1,159 @@ +import logging +from datetime import datetime +from typing import Any +from models.library import LibraryAlbum +from infrastructure.cover_urls import prefer_release_group_cover_url +from .base import LidarrBase + +logger = logging.getLogger(__name__) + + +class LidarrHistoryRepository(LidarrBase): + async def get_recently_imported(self, limit: int = 20) -> list[LibraryAlbum]: + try: + album_dates: dict[str, tuple[int, dict]] = {} + + try: + params = { + "page": 1, + "pageSize": limit * 10, + "sortKey": "date", + "sortDirection": "descending", + "includeAlbum": True, + "includeArtist": True, + "eventType": [2, 3, 8] + } + + history_data = await self._get("/api/v1/history", params=params) + + if history_data and history_data.get("records"): + for record in history_data.get("records", []): + album_data = record.get("album", {}) + if not album_data: + continue + + album_mbid = album_data.get("foreignAlbumId") + if not album_mbid: + continue + + date_added = None + if date_str := record.get("date"): + try: + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + date_added = int(dt.timestamp()) + except Exception: # noqa: BLE001 + continue + + if not date_added: + continue + + if album_mbid not in album_dates or date_added > album_dates[album_mbid][0]: + album_dates[album_mbid] = (date_added, { + 'album_data': album_data, + 'artist_data': record.get("artist", {}) + }) + + logger.info(f"Found {len(album_dates)} unique albums from {len(history_data.get('records', []))} history records") + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get history data: {e}") + + if len(album_dates) < limit * 2: + album_dates = await self._supplement_with_album_dates(album_dates, limit) + + if not album_dates: + logger.warning("No albums found with dates from either history or track files") + return [] + + sorted_albums = sorted(album_dates.items(), key=lambda x: x[1][0], reverse=True) + recent_albums = sorted_albums[:limit] + + return self._build_library_albums(recent_albums) + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get recently imported albums: {e}") + return [] + + async def _supplement_with_album_dates(self, album_dates: dict, limit: int) -> dict: + try: + albums_data = await self._get_all_albums_raw() + + albums_with_dates = [] + for album in albums_data: + if not album.get("monitored", False): + continue + + album_id = album.get("id") + album_mbid = album.get("foreignAlbumId") + if not album_id or not album_mbid: + continue + + if album_mbid in album_dates: + continue + + stats = album.get("statistics", {}) + if stats.get("trackFileCount", 0) == 0: + continue + + date_added_str = album.get("dateAdded") + if not date_added_str: + continue + + try: + date_added = datetime.fromisoformat(date_added_str.replace('Z', '+00:00')) + albums_with_dates.append((album, date_added, album_mbid)) + except Exception: # noqa: BLE001 + continue + + albums_with_dates.sort(key=lambda x: x[1], reverse=True) + + for album, most_recent, album_mbid in albums_with_dates[:limit * 2]: + album_dates[album_mbid] = (int(most_recent.timestamp()), { + 'album_data': album, + 'artist_data': album.get("artist", {}) + }) + + logger.info(f"Total {len(album_dates)} unique albums after supplementing with album dates") + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to supplement with album data: {e}") + + return album_dates + + def _build_library_albums(self, recent_albums: list) -> list[LibraryAlbum]: + out: list[LibraryAlbum] = [] + for album_mbid, (date_added, data) in recent_albums: + album_data = data['album_data'] + artist_data = data['artist_data'] + + artist = artist_data.get("artistName", "Unknown") + artist_mbid = artist_data.get("foreignArtistId") + + year = None + if date := album_data.get("releaseDate"): + try: + year = int(date.split("-")[0]) + except ValueError: + pass + + album_id = album_data.get("id") + cover_url = prefer_release_group_cover_url( + album_mbid, + self._get_album_cover_url(album_data.get("images", []), album_id), + size=500, + ) + + out.append( + LibraryAlbum( + artist=artist, + album=album_data.get("title"), + year=year, + monitored=album_data.get("monitored", False), + quality=None, + cover_url=cover_url, + musicbrainz_id=album_mbid, + artist_mbid=artist_mbid, + date_added=date_added, + ) + ) + + logger.info(f"Retrieved {len(out)} recently added albums (merged from history and track files)") + return out diff --git a/backend/repositories/lidarr/library.py b/backend/repositories/lidarr/library.py new file mode 100644 index 0000000..edaf311 --- /dev/null +++ b/backend/repositories/lidarr/library.py @@ -0,0 +1,251 @@ +import logging +from datetime import datetime +from typing import Any +from models.library import LibraryAlbum, LibraryGroupedAlbum, LibraryGroupedArtist +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.cache.cache_keys import ( + lidarr_library_albums_key, + lidarr_library_artists_key, + lidarr_library_mbids_key, + lidarr_artist_mbids_key, + lidarr_library_grouped_key, + lidarr_requested_mbids_key, +) +from .base import LidarrBase + +logger = logging.getLogger(__name__) + + +class LidarrLibraryRepository(LidarrBase): + async def get_library(self, include_unmonitored: bool = False) -> list[LibraryAlbum]: + cache_key = lidarr_library_albums_key(include_unmonitored) + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + data = await self._get_all_albums_raw() + out: list[LibraryAlbum] = [] + filtered_count = 0 + + for item in data: + is_monitored = item.get("monitored", False) + + if not is_monitored and not include_unmonitored: + filtered_count += 1 + continue + + artist_data = item.get("artist", {}) + artist = artist_data.get("artistName", "Unknown") + artist_mbid = artist_data.get("foreignArtistId") + + year = None + if date := item.get("releaseDate"): + try: + year = int(date.split("-")[0]) + except ValueError: + pass + + album_id = item.get("id") + album_mbid = item.get("foreignAlbumId") + cover = prefer_release_group_cover_url( + album_mbid, + self._get_album_cover_url(item.get("images", []), album_id), + size=500, + ) + + date_added = None + if added_str := item.get("added"): + try: + dt = datetime.fromisoformat(added_str.replace('Z', '+00:00')) + date_added = int(dt.timestamp()) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to parse date_added '{added_str}' for album '{item.get('title')}': {e}") + + out.append( + LibraryAlbum( + artist=artist, + album=item.get("title"), + year=year, + monitored=item.get("monitored", False), + quality=None, + cover_url=cover, + musicbrainz_id=album_mbid, + artist_mbid=artist_mbid, + date_added=date_added, + ) + ) + + if filtered_count > 0: + logger.info(f"Filtered out {filtered_count} unmonitored albums from library") + + await self._cache.set(cache_key, out, ttl_seconds=300) + return out + + async def get_artists_from_library(self, include_unmonitored: bool = False) -> list[dict]: + cache_key = lidarr_library_artists_key(include_unmonitored) + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + albums_data = await self._get_all_albums_raw() + artists_dict: dict[str, dict] = {} + filtered_count = 0 + + for item in albums_data: + is_monitored = item.get("monitored", False) + + if not is_monitored and not include_unmonitored: + filtered_count += 1 + continue + + artist_data = item.get("artist", {}) + artist_mbid = artist_data.get("foreignArtistId") + artist_name = artist_data.get("artistName", "Unknown") + + if not artist_mbid: + continue + + date_added = None + if added_str := item.get("added"): + try: + dt = datetime.fromisoformat(added_str.replace('Z', '+00:00')) + date_added = int(dt.timestamp()) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to parse date_added '{added_str}' for artist '{artist_name}': {e}") + + if artist_mbid not in artists_dict: + artists_dict[artist_mbid] = { + 'mbid': artist_mbid, + 'name': artist_name, + 'album_count': 0, + 'date_added': date_added + } + + artists_dict[artist_mbid]['album_count'] += 1 + if date_added and (not artists_dict[artist_mbid]['date_added'] or + date_added < artists_dict[artist_mbid]['date_added']): + artists_dict[artist_mbid]['date_added'] = date_added + + if filtered_count > 0: + logger.info(f"Filtered out {filtered_count} unmonitored albums from artist extraction") + + result = list(artists_dict.values()) + await self._cache.set(cache_key, result, ttl_seconds=300) + return result + + async def get_library_grouped(self) -> list[LibraryGroupedArtist]: + cache_key = lidarr_library_grouped_key() + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + data = await self._get_all_albums_raw() + grouped: dict[str, list[LibraryGroupedAlbum]] = {} + + for item in data: + artist = item.get("artist", {}).get("artistName", "Unknown") + title = item.get("title") + year = None + if date := item.get("releaseDate"): + try: + year = int(date.split("-")[0]) + except ValueError: + pass + + album_id = item.get("id") + album_mbid = item.get("foreignAlbumId") + cover = prefer_release_group_cover_url( + album_mbid, + self._get_album_cover_url(item.get("images", []), album_id), + size=500, + ) + + grouped.setdefault(artist, []).append( + LibraryGroupedAlbum( + title=title, + year=year, + monitored=item.get("monitored", False), + cover_url=cover, + ) + ) + + result = [ + LibraryGroupedArtist(artist=artist, albums=albums) + for artist, albums in grouped.items() + ] + await self._cache.set(cache_key, result, ttl_seconds=300) + return result + + async def get_library_mbids(self, include_release_ids: bool = True) -> set[str]: + cache_key = lidarr_library_mbids_key(include_release_ids) + + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + data = await self._get_all_albums_raw() + ids: set[str] = set() + for item in data: + if not item.get("monitored", False): + continue + + statistics = item.get("statistics", {}) + track_file_count = statistics.get("trackFileCount", 0) + if track_file_count == 0: + continue + + rg = item.get("foreignAlbumId") + if isinstance(rg, str): + ids.add(rg.lower()) + if include_release_ids: + for rel in item.get("releases", []) or []: + rid = rel.get("foreignId") + if isinstance(rid, str): + ids.add(rid.lower()) + + await self._cache.set(cache_key, ids, ttl_seconds=300) + return ids + + async def get_artist_mbids(self) -> set[str]: + cache_key = lidarr_artist_mbids_key() + + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + data = await self._get("/api/v1/artist") + ids: set[str] = set() + for item in data: + if not item.get("monitored", False): + continue + mbid = item.get("foreignArtistId") or item.get("mbId") + if isinstance(mbid, str): + ids.add(mbid.lower()) + + await self._cache.set(cache_key, ids, ttl_seconds=300) + return ids + + async def get_requested_mbids(self) -> set[str]: + cache_key = lidarr_requested_mbids_key() + + cached_result = await self._cache.get(cache_key) + if cached_result is not None: + return cached_result + + data = await self._get_all_albums_raw() + ids: set[str] = set() + for item in data: + if not item.get("monitored", False): + continue + + statistics = item.get("statistics", {}) + track_file_count = statistics.get("trackFileCount", 0) + if track_file_count > 0: + continue + + rg = item.get("foreignAlbumId") + if isinstance(rg, str): + ids.add(rg.lower()) + + await self._cache.set(cache_key, ids, ttl_seconds=300) + return ids diff --git a/backend/repositories/lidarr/queue.py b/backend/repositories/lidarr/queue.py new file mode 100644 index 0000000..c955dfd --- /dev/null +++ b/backend/repositories/lidarr/queue.py @@ -0,0 +1,80 @@ +import logging +from typing import Any, Optional +from .base import LidarrBase + +logger = logging.getLogger(__name__) + + +class LidarrQueueRepository(LidarrBase): + async def get_queue_details( + self, + include_artist: bool = True, + include_album: bool = True, + ) -> list[dict[str, Any]]: + all_records: list[dict[str, Any]] = [] + page = 1 + page_size = 200 + while True: + params = { + "page": page, + "pageSize": page_size, + "includeArtist": str(include_artist).lower(), + "includeAlbum": str(include_album).lower(), + } + data = await self._get("/api/v1/queue", params=params) + if isinstance(data, dict): + records = data.get("records", []) + all_records.extend(records) + total = data.get("totalRecords", 0) + if len(all_records) >= total or not records: + break + page += 1 + else: + if isinstance(data, list): + all_records.extend(data) + break + return all_records + + async def remove_queue_item( + self, + queue_id: int, + remove_from_client: bool = True, + ) -> bool: + params = { + "removeFromClient": str(remove_from_client).lower(), + "blocklist": "false", + "skipRedownload": "false", + "changeCategory": "false", + } + try: + await self._delete(f"/api/v1/queue/{queue_id}", params=params) + return True + except Exception as e: # noqa: BLE001 + logger.error("Couldn't remove queue item %s: %s", queue_id, e) + return False + + async def get_history_for_album( + self, + album_id: int, + include_album: bool = True, + include_artist: bool = True, + ) -> list[dict[str, Any]]: + params = { + "albumId": album_id, + "includeAlbum": str(include_album).lower(), + "includeArtist": str(include_artist).lower(), + } + data = await self._get("/api/v1/history", params=params) + if isinstance(data, dict): + return data.get("records", []) + return data if isinstance(data, list) else [] + + async def trigger_album_search(self, album_ids: list[int]) -> Optional[dict[str, Any]]: + try: + return await self._post("/api/v1/command", { + "name": "AlbumSearch", + "albumIds": album_ids, + }) + except Exception as e: # noqa: BLE001 + logger.error("Failed to trigger album search: %s", e) + return None diff --git a/backend/repositories/lidarr/repository.py b/backend/repositories/lidarr/repository.py new file mode 100644 index 0000000..77bcad9 --- /dev/null +++ b/backend/repositories/lidarr/repository.py @@ -0,0 +1,31 @@ +import httpx +from typing import Any, Optional +from core.config import Settings +from models.library import LibraryAlbum +from models.request import QueueItem +from models.common import ServiceStatus +from infrastructure.cache.memory_cache import CacheInterface +from .library import LidarrLibraryRepository +from .artist import LidarrArtistRepository +from .album import LidarrAlbumRepository +from .config import LidarrConfigRepository +from .queue import LidarrQueueRepository + + +class LidarrRepository( + LidarrLibraryRepository, + LidarrArtistRepository, + LidarrAlbumRepository, + LidarrConfigRepository, + LidarrQueueRepository +): + def __init__( + self, + settings: Settings, + http_client: httpx.AsyncClient, + cache: CacheInterface + ): + super().__init__(settings, http_client, cache) + + async def add_album(self, musicbrainz_id: str) -> dict: + return await LidarrAlbumRepository.add_album(self, musicbrainz_id, self) diff --git a/backend/repositories/listenbrainz_models.py b/backend/repositories/listenbrainz_models.py new file mode 100644 index 0000000..be8cf3c --- /dev/null +++ b/backend/repositories/listenbrainz_models.py @@ -0,0 +1,256 @@ +import msgspec + + +class ListenBrainzArtist(msgspec.Struct): + artist_name: str + listen_count: int + artist_mbids: list[str] | None = None + + +class ListenBrainzReleaseGroup(msgspec.Struct): + release_group_name: str + artist_name: str + listen_count: int + release_group_mbid: str | None = None + artist_mbids: list[str] | None = None + caa_release_mbid: str | None = None + caa_id: int | None = None + + +class ListenBrainzRecording(msgspec.Struct): + track_name: str + artist_name: str + listen_count: int + recording_mbid: str | None = None + release_name: str | None = None + release_mbid: str | None = None + artist_mbids: list[str] | None = None + + +class ListenBrainzListen(msgspec.Struct): + track_name: str + artist_name: str + listened_at: int + recording_mbid: str | None = None + release_name: str | None = None + release_mbid: str | None = None + artist_mbids: list[str] | None = None + + +class ListenBrainzGenreActivity(msgspec.Struct): + genre: str + listen_count: int + hour: int | None = None + + +class ListenBrainzSimilarArtist(msgspec.Struct): + artist_mbid: str + artist_name: str + listen_count: int + score: float | None = None + + +class ListenBrainzFeedbackRecording(msgspec.Struct): + track_name: str + artist_name: str + release_name: str | None = None + recording_mbid: str | None = None + release_mbid: str | None = None + artist_mbids: list[str] | None = None + score: int = 0 + + +ALLOWED_STATS_RANGE = [ + "this_week", "this_month", "this_year", + "week", "month", "quarter", "year", "half_yearly", "all_time" +] + + +def parse_artist(item: dict) -> ListenBrainzArtist: + mbid = item.get("artist_mbid") + mbids = [mbid] if mbid else item.get("artist_mbids") + return ListenBrainzArtist( + artist_name=item.get("artist_name", "Unknown"), + listen_count=item.get("listen_count", 0), + artist_mbids=mbids, + ) + + +def parse_release_group(item: dict) -> ListenBrainzReleaseGroup: + return ListenBrainzReleaseGroup( + release_group_name=item.get("release_group_name", "Unknown"), + artist_name=item.get("artist_name", "Unknown"), + listen_count=item.get("listen_count", 0), + release_group_mbid=item.get("release_group_mbid"), + artist_mbids=item.get("artist_mbids"), + ) + + +def parse_recording(item: dict) -> ListenBrainzRecording: + return ListenBrainzRecording( + track_name=item.get("track_name", "Unknown"), + artist_name=item.get("artist_name", "Unknown"), + listen_count=item.get("listen_count", 0), + recording_mbid=item.get("recording_mbid"), + release_name=item.get("release_name"), + release_mbid=item.get("release_mbid"), + artist_mbids=item.get("artist_mbids"), + ) + + +def parse_listen(item: dict) -> ListenBrainzListen: + track_meta = item.get("track_metadata", {}) + additional = track_meta.get("additional_info", {}) + mbid_mapping = track_meta.get("mbid_mapping", {}) + return ListenBrainzListen( + track_name=track_meta.get("track_name", "Unknown"), + artist_name=track_meta.get("artist_name", "Unknown"), + listened_at=item.get("listened_at", 0), + recording_mbid=mbid_mapping.get("recording_mbid") or additional.get("recording_mbid"), + release_name=track_meta.get("release_name"), + release_mbid=mbid_mapping.get("release_mbid") or additional.get("release_mbid"), + artist_mbids=mbid_mapping.get("artist_mbids"), + ) + + +def parse_artist_recording(item: dict) -> ListenBrainzRecording: + return ListenBrainzRecording( + track_name=item.get("recording_name", "Unknown"), + artist_name=item.get("artist_name", "Unknown"), + listen_count=item.get("total_listen_count", 0), + recording_mbid=item.get("recording_mbid"), + release_name=item.get("release_name"), + release_mbid=item.get("release_mbid"), + artist_mbids=item.get("artist_mbids"), + ) + + +def parse_similar_artist(artist_mbid: str, recordings: list[dict]) -> ListenBrainzSimilarArtist: + if not recordings: + return ListenBrainzSimilarArtist( + artist_mbid=artist_mbid, + artist_name="Unknown", + listen_count=0, + ) + first = recordings[0] + total_count = sum(r.get("total_listen_count", 0) for r in recordings) + return ListenBrainzSimilarArtist( + artist_mbid=artist_mbid, + artist_name=first.get("similar_artist_name", "Unknown"), + listen_count=total_count, + ) + + +def parse_feedback_recording(item: dict) -> ListenBrainzFeedbackRecording: + metadata = item.get("recording_metadata") or item.get("track_metadata") or item.get("metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + + mbid_mapping = metadata.get("mbid_mapping", {}) + if not isinstance(mbid_mapping, dict): + mbid_mapping = {} + + artist_mbids = mbid_mapping.get("artist_mbids") or metadata.get("artist_mbids") + if artist_mbids is None and metadata.get("artist_mbid"): + artist_mbids = [metadata.get("artist_mbid")] + + return ListenBrainzFeedbackRecording( + track_name=( + metadata.get("track_name") + or metadata.get("recording_name") + or item.get("track_name") + or "Unknown" + ), + artist_name=( + metadata.get("artist_name") + or metadata.get("artist") + or item.get("artist_name") + or "Unknown" + ), + release_name=( + metadata.get("release_name") + or metadata.get("album_name") + or item.get("release_name") + ), + recording_mbid=item.get("recording_mbid") or mbid_mapping.get("recording_mbid") or metadata.get("recording_mbid"), + release_mbid=mbid_mapping.get("release_mbid") or item.get("release_mbid") or metadata.get("release_mbid"), + artist_mbids=artist_mbids, + score=int(item.get("score", 0) or 0), + ) + + +class ListenBrainzRecommendationTrack(msgspec.Struct): + title: str + creator: str + album: str + recording_mbid: str | None = None + artist_mbids: list[str] | None = None + duration_ms: int | None = None + caa_id: int | None = None + caa_release_mbid: str | None = None + + +class ListenBrainzRecommendationPlaylist(msgspec.Struct): + identifier: str + title: str + date: str + source_patch: str + tracks: list[ListenBrainzRecommendationTrack] = [] + + +def _safe_int(value: object) -> int | None: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def parse_recommendation_track(track: dict) -> ListenBrainzRecommendationTrack | None: + title = track.get("title") + creator = track.get("creator") + if not title or not creator: + return None + + album = track.get("album", "") + + identifiers = track.get("identifier") or [] + recording_mbid = None + if identifiers and isinstance(identifiers, list): + for ident in identifiers: + if isinstance(ident, str) and "recording/" in ident: + recording_mbid = ident.rsplit("/", 1)[-1] + break + + ext = track.get("extension", {}) + track_ext = ext.get("https://musicbrainz.org/doc/jspf#track", {}) + additional = track_ext.get("additional_metadata", {}) + + artist_mbids: list[str] = [] + for artist in additional.get("artists", []): + mbid = artist.get("artist_mbid") + if mbid: + artist_mbids.append(mbid) + + raw_duration = track.get("duration") + duration_ms: int | None = None + if raw_duration is not None: + try: + duration_ms = int(raw_duration) + except (ValueError, TypeError): + pass + + caa_id = additional.get("caa_id") + caa_release_mbid = additional.get("caa_release_mbid") + + return ListenBrainzRecommendationTrack( + title=title, + creator=creator, + album=album, + recording_mbid=recording_mbid, + artist_mbids=artist_mbids or None, + duration_ms=duration_ms, + caa_id=_safe_int(caa_id), + caa_release_mbid=str(caa_release_mbid) if caa_release_mbid else None, + ) diff --git a/backend/repositories/listenbrainz_repository.py b/backend/repositories/listenbrainz_repository.py new file mode 100644 index 0000000..dd10e98 --- /dev/null +++ b/backend/repositories/listenbrainz_repository.py @@ -0,0 +1,798 @@ +import asyncio +import httpx +import logging +from typing import Any + +import msgspec +from core.exceptions import ExternalServiceError, RateLimitedError +from infrastructure.cache.cache_keys import LB_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.resilience.retry import with_retry, CircuitBreaker +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from repositories.listenbrainz_models import ( + ListenBrainzArtist, ListenBrainzReleaseGroup, ListenBrainzRecording, + ListenBrainzListen, ListenBrainzGenreActivity, ListenBrainzSimilarArtist, + ListenBrainzFeedbackRecording, + ListenBrainzRecommendationTrack, ListenBrainzRecommendationPlaylist, + ALLOWED_STATS_RANGE, + parse_artist, parse_release_group, parse_recording, parse_listen, + parse_artist_recording, parse_feedback_recording, parse_similar_artist, + parse_recommendation_track, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "listenbrainz" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + + +def _parse_retry_after(response: httpx.Response) -> float: + """Extract retry delay from ListenBrainz 429 response headers.""" + for header in ("X-RateLimit-Reset-In", "Retry-After"): + value = response.headers.get(header) + if value is not None: + try: + seconds = float(value) + if seconds > 0: + return min(seconds, 10.0) + except (TypeError, ValueError): + continue + return 2.0 + +_listenbrainz_circuit_breaker = CircuitBreaker( + failure_threshold=10, + success_threshold=2, + timeout=60.0, + name="listenbrainz" +) + +_listenbrainz_rate_limiter = TokenBucketRateLimiter(rate=5.0, capacity=10) + +LISTENBRAINZ_API_URL = "https://api.listenbrainz.org" + +ListenBrainzJsonObject = dict[str, Any] +ListenBrainzJsonArray = list[ListenBrainzJsonObject] +ListenBrainzJson = ListenBrainzJsonObject | ListenBrainzJsonArray + + +def _decode_json_response(response: httpx.Response) -> ListenBrainzJson: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=ListenBrainzJson) + return response.json() + + +class ListenBrainzRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + cache: CacheInterface, + username: str = "", + user_token: str = "" + ): + self._client = http_client + self._cache = cache + self._username = username + self._user_token = user_token + self._base_url = LISTENBRAINZ_API_URL + self._request_semaphore = asyncio.Semaphore(2) + + def configure(self, username: str, user_token: str = "") -> None: + self._username = username + self._user_token = user_token + + @staticmethod + def reset_circuit_breaker() -> None: + _listenbrainz_circuit_breaker.reset() + + def _get_headers(self) -> dict[str, str]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if self._user_token: + headers["Authorization"] = f"Token {self._user_token}" + return headers + + @with_retry( + max_attempts=3, + base_delay=1.0, + max_delay=3.0, + circuit_breaker=_listenbrainz_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + non_breaking_exceptions=(RateLimitedError,), + ) + async def _request( + self, + method: str, + endpoint: str, + params: dict[str, Any] | None = None, + json_data: dict[str, Any] | None = None, + require_auth: bool = False, + ) -> Any: + url = f"{self._base_url}{endpoint}" + + if require_auth and not self._user_token: + raise ExternalServiceError("ListenBrainz user token required for this request") + + await _listenbrainz_rate_limiter.acquire() + + async with self._request_semaphore: + try: + response = await self._client.request( + method, + url, + headers=self._get_headers(), + params=params, + json=json_data, + timeout=15.0, + ) + + if response.status_code == 204: + return None + + if response.status_code == 404: + return None + + if response.status_code == 429: + retry_after = _parse_retry_after(response) + raise RateLimitedError( + f"ListenBrainz rate limited ({method} {endpoint})", + response.text, + retry_after_seconds=retry_after, + ) + + if response.status_code != 200: + raise ExternalServiceError( + f"ListenBrainz {method} failed ({response.status_code})", + response.text + ) + + try: + return _decode_json_response(response) + except (msgspec.DecodeError, ValueError, TypeError): + _record_degradation(f"ListenBrainz returned invalid JSON for {method} {endpoint}") + return None + + except httpx.HTTPError as e: + raise ExternalServiceError(f"ListenBrainz request failed: {str(e)}") + + async def _get( + self, + endpoint: str, + params: dict[str, Any] | None = None, + require_auth: bool = False + ) -> Any: + return await self._request("GET", endpoint, params=params, require_auth=require_auth) + + async def _post( + self, + endpoint: str, + data: dict[str, Any], + require_auth: bool = False + ) -> Any: + return await self._request("POST", endpoint, json_data=data, require_auth=require_auth) + + async def validate_username(self, username: str | None = None) -> tuple[bool, str]: + user = username or self._username + if not user: + return False, "No username provided" + + try: + url = f"{self._base_url}/1/user/{user}/listen-count" + response = await self._client.request( + "GET", + url, + headers=self._get_headers(), + timeout=10.0, + ) + + if response.status_code == 404: + return False, f"User '{user}' not found" + + if response.status_code != 200: + return False, f"Validation failed (HTTP {response.status_code})" + + result = _decode_json_response(response) + if result and "payload" in result: + count = result.get("payload", {}).get("count", 0) + return True, f"User found with {count:,} listens" + return False, "User not found" + except httpx.TimeoutException: + return False, "Connection timed out" + except httpx.ConnectError: + return False, "Could not connect to ListenBrainz" + except Exception as e: # noqa: BLE001 + return False, f"Validation failed: {str(e)}" + + async def validate_token(self) -> tuple[bool, str]: + if not self._user_token: + return False, "No token provided" + + try: + url = f"{self._base_url}/1/validate-token" + headers = self._get_headers() + response = await self._client.request( + "GET", + url, + headers=headers, + timeout=10.0, + ) + + if response.status_code != 200: + return False, "Token invalid or expired" + + result = _decode_json_response(response) + if result and result.get("valid"): + username = result.get("user_name", self._username) + return True, f"Successfully connected as '{username}'" + return False, "Token invalid" + except httpx.TimeoutException: + return False, "Connection timed out" + except httpx.ConnectError: + return False, "Could not connect to ListenBrainz" + except Exception as e: # noqa: BLE001 + return False, f"Validation failed: {str(e)}" + + async def get_user_listens( + self, + username: str | None = None, + count: int = 25, + max_ts: int | None = None, + min_ts: int | None = None + ) -> list[ListenBrainzListen]: + user = username or self._username + if not user: + return [] + + params: dict[str, Any] = {"count": min(count, 100)} + if max_ts: + params["max_ts"] = max_ts + if min_ts: + params["min_ts"] = min_ts + + result = await self._get(f"/1/user/{user}/listens", params=params) + if not result: + return [] + return [parse_listen(item) for item in result.get("payload", {}).get("listens", [])] + + async def get_user_loved_recordings( + self, + username: str | None = None, + count: int = 25, + offset: int = 0, + ) -> list[ListenBrainzFeedbackRecording]: + user = username or self._username + if not user: + return [] + + cache_key = f"{LB_PREFIX}user_loved_recordings:{user}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params: dict[str, Any] = { + "score": 1, + "count": min(count, 100), + "offset": offset, + "metadata": "true", + } + result = await self._get(f"/1/feedback/user/{user}/get-feedback", params=params) + if not result: + return [] + + payload = result.get("payload", result) + feedback_items: list[dict[str, Any]] + if isinstance(payload, dict): + feedback_raw = payload.get("feedback") or payload.get("recordings") or [] + if isinstance(feedback_raw, list): + feedback_items = [item for item in feedback_raw if isinstance(item, dict)] + else: + feedback_items = [] + elif isinstance(payload, list): + feedback_items = [item for item in payload if isinstance(item, dict)] + else: + feedback_items = [] + + loved_recordings = [parse_feedback_recording(item) for item in feedback_items] + if loved_recordings: + await self._cache.set(cache_key, loved_recordings, ttl_seconds=300) + return loved_recordings + + async def get_user_top_artists( + self, + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzArtist]: + user = username or self._username + if not user: + return [] + + if range_ not in ALLOWED_STATS_RANGE: + range_ = "this_month" + + cache_key = f"{LB_PREFIX}user_artists:{user}:{range_}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get(f"/1/stats/user/{user}/artists", params=params) + if not result: + return [] + artists = [parse_artist(item) for item in result.get("payload", {}).get("artists", [])] + if artists: + await self._cache.set(cache_key, artists, ttl_seconds=300) + return artists + + async def get_user_top_release_groups( + self, + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzReleaseGroup]: + user = username or self._username + if not user: + return [] + + if range_ not in ALLOWED_STATS_RANGE: + range_ = "this_month" + + cache_key = f"{LB_PREFIX}user_release_groups:{user}:{range_}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get(f"/1/stats/user/{user}/release-groups", params=params) + if not result: + return [] + groups = [parse_release_group(item) for item in result.get("payload", {}).get("release_groups", [])] + if groups: + await self._cache.set(cache_key, groups, ttl_seconds=300) + return groups + + async def get_user_top_recordings( + self, + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzRecording]: + user = username or self._username + if not user: + return [] + + if range_ not in ALLOWED_STATS_RANGE: + range_ = "this_month" + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get(f"/1/stats/user/{user}/recordings", params=params) + if not result: + return [] + return [parse_recording(item) for item in result.get("payload", {}).get("recordings", [])] + + async def get_user_genre_activity( + self, + username: str | None = None + ) -> list[ListenBrainzGenreActivity]: + user = username or self._username + if not user: + return [] + + cache_key = f"{LB_PREFIX}user_genres:{user}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + result = await self._get(f"/1/stats/user/{user}/genre-activity") + + if not result: + return [] + + genre_counts: dict[str, int] = {} + for item in result.get("result", []): + genre = item.get("genre", "Unknown") + count = item.get("listen_count", 0) + genre_counts[genre] = genre_counts.get(genre, 0) + count + + genres = [ + ListenBrainzGenreActivity(genre=g, listen_count=c) + for g, c in sorted(genre_counts.items(), key=lambda x: -x[1]) + ] + + if genres: + await self._cache.set(cache_key, genres, ttl_seconds=300) + return genres + + async def get_sitewide_top_artists( + self, + range_: str = "week", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzArtist]: + if range_ not in ALLOWED_STATS_RANGE: + range_ = "week" + + cache_key = f"{LB_PREFIX}sitewide_artists:{range_}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get("/1/stats/sitewide/artists", params=params) + if not result: + return [] + artists = [parse_artist(item) for item in result.get("payload", {}).get("artists", [])] + if artists: + await self._cache.set(cache_key, artists, ttl_seconds=3600) + return artists + + async def get_sitewide_top_release_groups( + self, + range_: str = "week", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzReleaseGroup]: + if range_ not in ALLOWED_STATS_RANGE: + range_ = "week" + + cache_key = f"{LB_PREFIX}sitewide_release_groups:{range_}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get("/1/stats/sitewide/release-groups", params=params) + if not result: + return [] + groups = [parse_release_group(item) for item in result.get("payload", {}).get("release_groups", [])] + if groups: + await self._cache.set(cache_key, groups, ttl_seconds=3600) + return groups + + async def get_sitewide_top_recordings( + self, + range_: str = "week", + count: int = 25, + offset: int = 0 + ) -> list[ListenBrainzRecording]: + if range_ not in ALLOWED_STATS_RANGE: + range_ = "week" + + cache_key = f"{LB_PREFIX}sitewide_recordings:{range_}:{count}:{offset}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"count": min(count, 100), "offset": offset, "range": range_} + result = await self._get("/1/stats/sitewide/recordings", params=params) + if not result: + return [] + recordings = [parse_recording(item) for item in result.get("payload", {}).get("recordings", [])] + if recordings: + await self._cache.set(cache_key, recordings, ttl_seconds=3600) + return recordings + + async def get_artist_top_recordings( + self, + artist_mbid: str, + count: int = 10 + ) -> list[ListenBrainzRecording]: + cache_key = f"{LB_PREFIX}artist_recordings:{artist_mbid}:{count}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + result = await self._get(f"/1/popularity/top-recordings-for-artist/{artist_mbid}") + if not result: + return [] + recordings = [parse_artist_recording(item) for item in result[:count]] + if recordings: + await self._cache.set(cache_key, recordings, ttl_seconds=3600) + return recordings + + async def get_similar_users( + self, + username: str | None = None + ) -> list[dict[str, Any]]: + user = username or self._username + if not user: + return [] + + result = await self._get(f"/1/user/{user}/similar-users") + + if not result: + return [] + + return result.get("payload", []) + + async def get_user_fresh_releases( + self, + username: str | None = None, + past: bool = True, + future: bool = False + ) -> list[dict[str, Any]]: + user = username or self._username + if not user: + return [] + + cache_key = f"{LB_PREFIX}fresh_releases:{user}:{past}:{future}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = {"past": str(past).lower(), "future": str(future).lower()} + result = await self._get(f"/1/user/{user}/fresh_releases", params=params) + + if not result: + return [] + + releases = result.get("payload", {}).get("releases", []) + if releases: + await self._cache.set(cache_key, releases, ttl_seconds=3600) + return releases + + async def get_similar_artists( + self, + artist_mbid: str, + max_similar: int = 15, + mode: str = "easy" + ) -> list[ListenBrainzSimilarArtist]: + cache_key = f"{LB_PREFIX}similar_artists:{artist_mbid}:{max_similar}:{mode}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + params = { + "mode": mode, + "max_similar_artists": max_similar, + "max_recordings_per_artist": 5, + "pop_begin": 0, + "pop_end": 100, + } + result = await self._get(f"/1/lb-radio/artist/{artist_mbid}", params=params) + if not result or "error" in result: + return [] + + similar_artists: list[ListenBrainzSimilarArtist] = [] + for mbid, recordings in result.items(): + if mbid == artist_mbid: + continue + if not isinstance(recordings, list): + continue + similar_artists.append(parse_similar_artist(mbid, recordings)) + + similar_artists.sort(key=lambda a: a.listen_count, reverse=True) + if similar_artists: + await self._cache.set(cache_key, similar_artists, ttl_seconds=3600) + return similar_artists + + async def get_artist_top_release_groups( + self, + artist_mbid: str, + count: int = 10 + ) -> list[ListenBrainzReleaseGroup]: + cache_key = f"{LB_PREFIX}artist_release_groups:{artist_mbid}:{count}" + cached = await self._cache.get(cache_key) + if cached: + return cached + + result = await self._get(f"/1/popularity/top-release-groups-for-artist/{artist_mbid}") + if not result or not isinstance(result, list): + logger.info( + "LB top-release-groups returned empty/non-list for %s (type=%s)", + artist_mbid[:8], + type(result).__name__, + ) + return [] + + release_groups = [] + for item in result[:count]: + rg = item.get("release_group", {}) + release_groups.append(ListenBrainzReleaseGroup( + release_group_name=rg.get("name", "Unknown"), + artist_name=item.get("artist", {}).get("name", "Unknown"), + listen_count=item.get("total_listen_count", 0), + release_group_mbid=item.get("release_group_mbid"), + caa_release_mbid=rg.get("caa_release_mbid"), + caa_id=rg.get("caa_id"), + )) + + if release_groups: + await self._cache.set(cache_key, release_groups, ttl_seconds=3600) + return release_groups + + async def get_release_group_popularity_batch( + self, + release_group_mbids: list[str] + ) -> dict[str, int]: + """Get listen counts for multiple release groups in a single call. + + Returns a dict mapping mbid -> total_listen_count. + """ + if not release_group_mbids: + return {} + + result = await self._post( + "/1/popularity/release-group", + {"release_group_mbids": release_group_mbids} + ) + if not result or not isinstance(result, list): + return {} + + counts: dict[str, int] = {} + for item in result: + mbid = item.get("release_group_mbid") + count = item.get("total_listen_count") + if mbid and count is not None: + counts[mbid] = count + return counts + + def is_configured(self) -> bool: + return bool(self._username) + + async def submit_now_playing( + self, + artist_name: str, + track_name: str, + release_name: str = "", + duration_ms: int = 0, + ) -> bool: + track_metadata: dict[str, Any] = { + "artist_name": artist_name, + "track_name": track_name, + } + if release_name: + track_metadata["release_name"] = release_name + if duration_ms > 0: + track_metadata["additional_info"] = {"duration_ms": duration_ms} + + payload = { + "listen_type": "playing_now", + "payload": [{"track_metadata": track_metadata}], + } + await self._post("/1/submit-listens", payload, require_auth=True) + logger.info( + "Now playing reported to ListenBrainz", + extra={"artist": artist_name, "track": track_name}, + ) + return True + + async def submit_single_listen( + self, + artist_name: str, + track_name: str, + listened_at: int, + release_name: str = "", + duration_ms: int = 0, + ) -> bool: + track_metadata: dict[str, Any] = { + "artist_name": artist_name, + "track_name": track_name, + } + if release_name: + track_metadata["release_name"] = release_name + if duration_ms > 0: + track_metadata["additional_info"] = {"duration_ms": duration_ms} + + payload = { + "listen_type": "single", + "payload": [ + { + "listened_at": listened_at, + "track_metadata": track_metadata, + } + ], + } + await self._post("/1/submit-listens", payload, require_auth=True) + logger.info( + "Scrobble submitted to ListenBrainz", + extra={ + "artist": artist_name, + "track": track_name, + "listened_at": listened_at, + }, + ) + return True + + async def get_recommendation_playlists( + self, username: str | None = None + ) -> list[dict[str, Any]]: + user = username or self._username + if not user: + return [] + + cache_key = f"{LB_PREFIX}rec_playlists:{user}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + result = await self._get(f"/1/user/{user}/playlists/recommendations") + if not result or not isinstance(result, dict): + return [] + + playlists_raw = result.get("playlists", []) + playlists: list[dict[str, Any]] = [] + for entry in playlists_raw: + pl = entry.get("playlist", {}) + if not isinstance(pl, dict): + continue + + identifier = pl.get("identifier", "") + playlist_id = identifier.rsplit("/", 1)[-1] if identifier else "" + if not playlist_id: + continue + + ext = pl.get("extension", {}) + mb_ext = ext.get("https://musicbrainz.org/doc/jspf#playlist", {}) + algo = mb_ext.get("additional_metadata", {}).get("algorithm_metadata", {}) + + playlists.append({ + "playlist_id": playlist_id, + "identifier": identifier, + "title": pl.get("title", ""), + "date": pl.get("date", ""), + "source_patch": algo.get("source_patch", ""), + }) + + if playlists: + await self._cache.set(cache_key, playlists, ttl_seconds=21600) + return playlists + + async def get_playlist_tracks( + self, playlist_id: str + ) -> ListenBrainzRecommendationPlaylist | None: + if not playlist_id: + return None + + cache_key = f"{LB_PREFIX}rec_playlist:{playlist_id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + result = await self._get(f"/1/playlist/{playlist_id}") + if not result or not isinstance(result, dict): + return None + + pl = result.get("playlist", {}) + if not isinstance(pl, dict): + return None + + ext = pl.get("extension", {}) + mb_ext = ext.get("https://musicbrainz.org/doc/jspf#playlist", {}) + algo = mb_ext.get("additional_metadata", {}).get("algorithm_metadata", {}) + + tracks: list[ListenBrainzRecommendationTrack] = [] + for raw_track in pl.get("track", []): + parsed = parse_recommendation_track(raw_track) + if parsed: + tracks.append(parsed) + + playlist = ListenBrainzRecommendationPlaylist( + identifier=pl.get("identifier", ""), + title=pl.get("title", ""), + date=pl.get("date", ""), + source_patch=algo.get("source_patch", ""), + tracks=tracks, + ) + + if tracks: + await self._cache.set(cache_key, playlist, ttl_seconds=21600) + + logger.info( + "Fetched recommendation playlist: %s (%d tracks)", + playlist.title, + len(tracks), + ) + return playlist diff --git a/backend/repositories/musicbrainz_album.py b/backend/repositories/musicbrainz_album.py new file mode 100644 index 0000000..820afba --- /dev/null +++ b/backend/repositories/musicbrainz_album.py @@ -0,0 +1,398 @@ +import logging +from typing import Any + +import msgspec + +from models.search import SearchResult +from services.preferences_service import PreferencesService +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.cache_keys import ( + mb_album_search_key, + mb_release_group_key, + mb_release_key, + MB_RG_BY_TAG_PREFIX, + MB_RG_DETAIL_PREFIX, + MB_RELEASE_DETAIL_PREFIX, + MB_RELEASE_TO_RG_PREFIX, + MB_RELEASE_REC_PREFIX, + MB_RECORDING_PREFIX, +) +from infrastructure.queue.priority_queue import RequestPriority +from repositories.musicbrainz_base import ( + mb_api_get, + mb_deduplicator, + dedupe_by_id, + get_score, + should_include_release, + extract_artist_name, + parse_year, + build_musicbrainz_tag_query, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + + +def _record_mb_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx: + ctx.record(IntegrationResult.error(source="musicbrainz", msg=msg)) + + +class _ReleaseGroupSearchPayload(msgspec.Struct): + release_groups: list[dict[str, Any]] = msgspec.field(name="release-groups", default_factory=list) + + +class _ReleaseLookupPayload(msgspec.Struct): + release_group: dict[str, Any] = msgspec.field(name="release-group", default_factory=dict) + media: list[dict[str, Any]] = msgspec.field(default_factory=list) + + +def _rg_priority(rg: dict) -> int: + rg_type = rg.get("primary-type", "") + priority = 0 + if rg_type == "Album": + priority = 3 + elif rg_type == "EP": + priority = 2 + elif rg_type == "Single": + priority = 1 + secondary = rg.get("secondary-types", []) + if secondary: + priority = max(0, priority - 1) + return priority + + +def _pick_best_release_group(releases: list[dict]) -> tuple[str, str] | None: + candidates: list[tuple[str, str, int]] = [] + for release in releases: + rg = release.get("release-group", {}) + rg_id = rg.get("id") + rg_title = rg.get("title", "") + if rg_id: + candidates.append((rg_id, rg_title, _rg_priority(rg))) + if not candidates: + return None + candidates.sort(key=lambda c: c[2], reverse=True) + return (candidates[0][0], candidates[0][1]) + + +class MusicBrainzAlbumMixin: + _cache: CacheInterface + _preferences_service: PreferencesService + + def _map_release_group_to_result( + self, + rg: dict[str, Any], + included_secondary_types: set[str] | None = None + ) -> SearchResult | None: + if not should_include_release(rg, included_secondary_types): + return None + + primary_type = rg.get("primary-type", "") + secondary_types = rg.get("secondary-types", []) + if secondary_types: + type_info = f"{primary_type} + {', '.join(secondary_types)}" + else: + type_info = primary_type or None + + return SearchResult( + type="album", + title=rg.get("title", "Unknown Album"), + artist=extract_artist_name(rg), + year=parse_year(rg.get("first-release-date")), + musicbrainz_id=rg.get("id", ""), + in_library=False, + type_info=type_info, + disambiguation=rg.get("disambiguation") or None, + score=get_score(rg), + ) + + async def search_albums( + self, + query: str, + limit: int = 10, + offset: int = 0, + included_secondary_types: set[str] | None = None + ) -> list[SearchResult]: + cache_key = mb_album_search_key(query, limit, offset, included_secondary_types) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + internal_limit = min(100, max(int(limit * 1.5), 25)) + + result = await mb_api_get( + "/release-group", + params={ + "query": f'releasegroup:"{query}"^3 OR release:"{query}"^2 OR {query}', + "limit": internal_limit, + "offset": offset, + }, + priority=RequestPriority.USER_INITIATED, + decode_type=_ReleaseGroupSearchPayload, + ) + release_groups = result.release_groups + release_groups = dedupe_by_id(release_groups) + + results = [] + for rg in release_groups: + mapped = self._map_release_group_to_result(rg, included_secondary_types) + if mapped: + results.append(mapped) + if len(results) >= limit: + break + + advanced_settings = self._preferences_service.get_advanced_settings() + await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search) + return results + except Exception as e: # noqa: BLE001 + logger.error(f"MusicBrainz album search failed: {e}") + _record_mb_degradation(f"album search failed: {e}") + return [] + + async def search_release_groups_by_tag( + self, + tag: str, + limit: int = 50, + offset: int = 0, + included_secondary_types: set[str] | None = None + ) -> list[SearchResult]: + cache_key = f"{MB_RG_BY_TAG_PREFIX}{tag.lower()}:{limit}:{offset}" + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + internal_limit = min(100, max(int(limit * 1.5), 25)) + + result = await mb_api_get( + "/release-group", + params={ + "query": build_musicbrainz_tag_query(tag), + "limit": internal_limit, + "offset": offset, + }, + priority=RequestPriority.BACKGROUND_SYNC, + decode_type=_ReleaseGroupSearchPayload, + ) + release_groups = result.release_groups + release_groups = dedupe_by_id(release_groups) + + results = [] + for rg in release_groups: + mapped = self._map_release_group_to_result(rg, included_secondary_types) + if mapped: + results.append(mapped) + if len(results) >= limit: + break + + advanced_settings = self._preferences_service.get_advanced_settings() + await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search * 2) + return results + except Exception as e: # noqa: BLE001 + logger.error(f"MusicBrainz release group tag search failed for '{tag}': {e}") + _record_mb_degradation(f"release group tag search failed: {e}") + return [] + + async def get_release_group_by_id( + self, + mbid: str, + includes: list[str] | None = None, + priority: RequestPriority = RequestPriority.USER_INITIATED, + ) -> dict | None: + if includes is None: + includes = ["artist-credits", "releases"] + + cache_key = mb_release_group_key(mbid, includes) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + includes_str = "+".join(sorted(includes)) + dedupe_key = f"{MB_RG_DETAIL_PREFIX}{mbid}:{includes_str}" + return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_release_group_by_id(mbid, includes, cache_key, priority)) + + async def _fetch_release_group_by_id( + self, + mbid: str, + includes: list[str], + cache_key: str, + priority: RequestPriority = RequestPriority.USER_INITIATED, + ) -> dict | None: + try: + inc_str = "+".join(sorted(includes)) + result = await mb_api_get( + f"/release-group/{mbid}", + params={"inc": inc_str}, + priority=priority, + ) + if not result: + return None + await self._cache.set(cache_key, result, ttl_seconds=3600) + return result + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch release group {mbid}: {e}") + _record_mb_degradation(f"release group fetch failed: {e}") + return None + + async def get_release_by_id( + self, + release_id: str, + includes: list[str] | None = None + ) -> dict | None: + if includes is None: + includes = ["recordings", "labels"] + + cache_key = mb_release_key(release_id, includes) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + includes_str = "+".join(sorted(includes)) + dedupe_key = f"{MB_RELEASE_DETAIL_PREFIX}{release_id}:{includes_str}" + return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_release_by_id(release_id, includes, cache_key)) + + async def _fetch_release_by_id( + self, + release_id: str, + includes: list[str], + cache_key: str + ) -> dict | None: + try: + inc_str = "+".join(sorted(includes)) + result = await mb_api_get( + f"/release/{release_id}", + params={"inc": inc_str}, + priority=RequestPriority.USER_INITIATED, + ) + if not result: + return None + await self._cache.set(cache_key, result, ttl_seconds=3600) + return result + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch release {release_id}: {e}") + _record_mb_degradation(f"release fetch failed: {e}") + return None + + async def get_release_group_id_from_release( + self, + release_id: str + ) -> str | None: + cache_key = f"{MB_RELEASE_TO_RG_PREFIX}{release_id}" + cached = await self._cache.get(cache_key) + if cached is not None: + logger.info(f"[MB] Cache hit for release {release_id[:8]}: '{cached[:8] if cached else 'empty'}'") + return cached if cached != "" else None + + dedupe_key = f"{MB_RELEASE_TO_RG_PREFIX}{release_id}" + return await mb_deduplicator.dedupe( + dedupe_key, + lambda: self._fetch_release_group_id_from_release(release_id, cache_key), + ) + + async def _fetch_release_group_id_from_release( + self, + release_id: str, + cache_key: str, + ) -> str | None: + try: + logger.info(f"[MB] Fetching release group for release {release_id[:8]}") + result = await mb_api_get( + f"/release/{release_id}", + params={"inc": "release-groups+recordings"}, + priority=RequestPriority.BACKGROUND_SYNC, + decode_type=_ReleaseLookupPayload, + ) + rg = result.release_group + rg_id = rg.get("id") + logger.info(f"[MB] Resolved release {release_id[:8]} -> release_group {rg_id}") + await self._cache.set(cache_key, rg_id or "", ttl_seconds=86400) + + positions: dict[str, list[int]] = {} + for medium in result.media: + disc = medium.get("position", 1) + for track in medium.get("tracks", medium.get("track-list", [])): + rec = track.get("recording", {}) + rec_id = rec.get("id") + trk_pos = track.get("position") + if rec_id and trk_pos is not None: + positions[rec_id] = [disc, trk_pos] + if positions: + pos_cache_key = f"{MB_RELEASE_REC_PREFIX}{release_id}" + await self._cache.set(pos_cache_key, positions, ttl_seconds=86400) + + return rg_id + except Exception as e: # noqa: BLE001 + logger.warning(f"[MB] Failed to get release group for release {release_id[:8]}: {e}") + _record_mb_degradation(f"release-to-rg lookup failed: {e}") + await self._cache.set(cache_key, "", ttl_seconds=3600) + return None + + async def get_recording_position_on_release( + self, + release_id: str, + recording_mbid: str, + ) -> tuple[int, int] | None: + pos_cache_key = f"{MB_RELEASE_REC_PREFIX}{release_id}" + positions = await self._cache.get(pos_cache_key) + if positions and recording_mbid in positions: + disc, track = positions[recording_mbid] + return (disc, track) + return None + + @staticmethod + def extract_youtube_url_from_relations(entity_data: dict) -> str | None: + for rel in entity_data.get("relations", []): + url_obj = rel.get("url", {}) + url = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + if "youtube.com" in url or "youtu.be" in url: + return url + return None + + @staticmethod + def youtube_url_to_embed(url: str) -> str | None: + import re + patterns = [ + r"youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})", + r"youtu\.be/([a-zA-Z0-9_-]{11})", + r"youtube\.com/embed/([a-zA-Z0-9_-]{11})", + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + return f"https://www.youtube.com/embed/{match.group(1)}" + return None + + async def get_recording_by_id( + self, + recording_id: str, + includes: list[str] | None = None, + ) -> dict | None: + if includes is None: + includes = ["url-rels"] + inc_str = "+".join(sorted(includes)) + cache_key = f"{MB_RECORDING_PREFIX}{recording_id}:{inc_str}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + try: + result = await mb_api_get( + f"/recording/{recording_id}", + params={"inc": inc_str}, + priority=RequestPriority.BACKGROUND_SYNC, + ) + if not result: + return None + await self._cache.set(cache_key, result, ttl_seconds=3600) + return result + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch recording {recording_id}: {e}") + _record_mb_degradation(f"recording fetch failed: {e}") + return None diff --git a/backend/repositories/musicbrainz_artist.py b/backend/repositories/musicbrainz_artist.py new file mode 100644 index 0000000..613d87d --- /dev/null +++ b/backend/repositories/musicbrainz_artist.py @@ -0,0 +1,289 @@ +import asyncio +import logging +from typing import Any + +import httpx +import msgspec + +from models.search import SearchResult +from core.exceptions import ExternalServiceError +from services.preferences_service import PreferencesService +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.cache_keys import ( + mb_artist_search_key, mb_artist_detail_key, + MB_ARTISTS_BY_TAG_PREFIX, MB_ARTIST_RELS_PREFIX, +) +from infrastructure.queue.priority_queue import RequestPriority +from infrastructure.resilience.retry import CircuitOpenError +from repositories.musicbrainz_base import ( + mb_api_get, + mb_deduplicator, + dedupe_by_id, + get_score, + build_musicbrainz_tag_query, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + + +def _record_mb_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx: + ctx.record(IntegrationResult.error(source="musicbrainz", msg=msg)) + + +class _ArtistSearchPayload(msgspec.Struct): + artists: list[dict[str, Any]] = msgspec.field(default_factory=list) + + +class _ArtistReleaseGroupsPayload(msgspec.Struct): + release_groups: list[dict[str, Any]] = msgspec.field(name="release-groups", default_factory=list) + release_group_count: int = msgspec.field(name="release-group-count", default=0) + + +FILTERED_ARTIST_MBIDS = { + "89ad4ac3-39f7-470e-963a-56509c546377", # Various Artists + "41ece0f7-91f6-4c87-982c-3a39c5a02586", # /v/ + "125ec42a-7229-4250-afc5-e057484327fe", # [Unknown] +} + +FILTERED_ARTIST_NAMES = { + "various artists", + "[unknown]", + "/v/", +} + + +class MusicBrainzArtistMixin: + _cache: CacheInterface + _preferences_service: PreferencesService + + def _map_artist_to_result(self, artist: dict[str, Any]) -> SearchResult | None: + artist_id = artist.get("id", "") + if artist_id in FILTERED_ARTIST_MBIDS: + return None + + name = artist.get("name", "Unknown Artist") + if name.lower() in FILTERED_ARTIST_NAMES: + return None + + return SearchResult( + type="artist", + title=name, + musicbrainz_id=artist_id, + in_library=False, + disambiguation=artist.get("disambiguation") or None, + type_info=artist.get("type") or None, + score=get_score(artist), + ) + + async def search_artists( + self, + query: str, + limit: int = 10, + offset: int = 0 + ) -> list[SearchResult]: + cache_key = mb_artist_search_key(query, limit, offset) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + search_query = f'artist:"{query}"^3 OR artistaccent:"{query}"^3 OR alias:"{query}"^2 OR {query}' + + result = await mb_api_get( + "/artist", + params={ + "query": search_query, + "limit": min(100, max(limit * 2, 25)), + "offset": offset, + }, + priority=RequestPriority.USER_INITIATED, + decode_type=_ArtistSearchPayload, + ) + artists = result.artists + artists = dedupe_by_id(artists) + + results = [] + for a in artists: + mapped = self._map_artist_to_result(a) + if mapped: + results.append(mapped) + if len(results) >= limit: + break + + advanced_settings = self._preferences_service.get_advanced_settings() + await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search) + return results + except Exception as e: # noqa: BLE001 + logger.error(f"MusicBrainz artist search failed: {e}") + _record_mb_degradation(f"artist search failed: {e}") + return [] + + async def search_artists_by_tag( + self, + tag: str, + limit: int = 50, + offset: int = 0 + ) -> list[SearchResult]: + cache_key = f"{MB_ARTISTS_BY_TAG_PREFIX}{tag.lower()}:{limit}:{offset}" + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + result = await mb_api_get( + "/artist", + params={ + "query": build_musicbrainz_tag_query(tag), + "limit": min(100, limit), + "offset": offset, + }, + priority=RequestPriority.BACKGROUND_SYNC, + decode_type=_ArtistSearchPayload, + ) + artists = result.artists + artists = dedupe_by_id(artists) + + results = [r for a in artists[:limit] if (r := self._map_artist_to_result(a)) is not None] + + advanced_settings = self._preferences_service.get_advanced_settings() + await self._cache.set(cache_key, results, ttl_seconds=advanced_settings.cache_ttl_search * 2) + return results + except Exception as e: # noqa: BLE001 + logger.error(f"MusicBrainz artist tag search failed for '{tag}': {e}") + _record_mb_degradation(f"artist tag search failed: {e}") + return [] + + async def get_artist_by_id(self, mbid: str) -> dict | None: + cache_key = mb_artist_detail_key(mbid) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + dedupe_key = f"mb:artist:{mbid}" + return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_artist_by_id(mbid, cache_key)) + + async def get_artist_relations(self, mbid: str) -> dict | None: + detail_key = mb_artist_detail_key(mbid) + cached = await self._cache.get(detail_key) + if cached is not None: + return cached + + rels_key = f"{MB_ARTIST_RELS_PREFIX}{mbid}" + cached_rels = await self._cache.get(rels_key) + if cached_rels is not None: + return cached_rels + + dedupe_key = f"{MB_ARTIST_RELS_PREFIX}{mbid}" + return await mb_deduplicator.dedupe(dedupe_key, lambda: self._fetch_artist_relations(mbid, rels_key)) + + async def _fetch_artist_relations(self, mbid: str, cache_key: str) -> dict | None: + try: + result = await mb_api_get( + f"/artist/{mbid}", + params={"inc": "url-rels"}, + priority=RequestPriority.IMAGE_FETCH, + ) + if not result: + return None + await self._cache.set(cache_key, result, ttl_seconds=86400) + return result + except (CircuitOpenError, httpx.HTTPError, ExternalServiceError): + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch artist relations {mbid}: {e}") + _record_mb_degradation(f"artist relations failed: {e}") + return None + + async def _fetch_artist_by_id(self, mbid: str, cache_key: str) -> dict | None: + try: + limit = 50 + + artist_result, browse_result = await asyncio.gather( + mb_api_get( + f"/artist/{mbid}", + params={"inc": "tags+aliases+url-rels"}, + priority=RequestPriority.USER_INITIATED, + ), + mb_api_get( + "/release-group", + params={"artist": mbid, "limit": limit, "offset": 0}, + priority=RequestPriority.USER_INITIATED, + decode_type=_ArtistReleaseGroupsPayload, + ), + ) + + if not artist_result: + return None + + all_release_groups = browse_result.release_groups + total_count = int(browse_result.release_group_count) + + if all_release_groups: + artist_result["release-group-list"] = all_release_groups + + artist_result["release-group-count"] = total_count + + await self._cache.set(cache_key, artist_result, ttl_seconds=21600) + + from core.task_registry import TaskRegistry + registry = TaskRegistry.get_instance() + if not registry.is_running("mb-release-group-warmup"): + _rg_task = asyncio.create_task(self._warm_release_group_cache(all_release_groups[:6])) + try: + registry.register("mb-release-group-warmup", _rg_task) + except RuntimeError: + pass + + return artist_result + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch artist {mbid}: {e}") + _record_mb_degradation(f"artist fetch failed: {e}") + return None + + async def _warm_release_group_cache(self, release_groups: list[dict[str, Any]]) -> None: + for rg in release_groups: + rg_id = rg.get("id") + if not rg_id: + continue + try: + await self.get_release_group_by_id(rg_id, priority=RequestPriority.BACKGROUND_SYNC) + except (CircuitOpenError, ExternalServiceError, httpx.HTTPError) as exc: + logger.debug("Failed to warm release group cache for %s: %s", rg_id, exc) + + async def get_artist_release_groups( + self, + artist_mbid: str, + offset: int = 0, + limit: int = 50 + ) -> tuple[list[dict[str, Any]], int]: + try: + result = await mb_api_get( + "/release-group", + params={"artist": artist_mbid, "limit": limit, "offset": offset}, + priority=RequestPriority.BACKGROUND_SYNC, + decode_type=_ArtistReleaseGroupsPayload, + ) + + release_groups = result.release_groups + total_count = int(result.release_group_count) + + return release_groups, total_count + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch release groups for artist {artist_mbid} at offset {offset}: {e}") + _record_mb_degradation(f"release groups failed: {e}") + return [], 0 + + async def get_release_groups_by_artist( + self, + artist_mbid: str, + limit: int = 10 + ) -> list[dict[str, Any]]: + release_groups, _ = await self.get_artist_release_groups(artist_mbid, offset=0, limit=limit) + return release_groups diff --git a/backend/repositories/musicbrainz_base.py b/backend/repositories/musicbrainz_base.py new file mode 100644 index 0000000..dd417df --- /dev/null +++ b/backend/repositories/musicbrainz_base.py @@ -0,0 +1,188 @@ +import logging +from typing import Any, TypeVar + +import httpx +import msgspec + +from core.exceptions import ExternalServiceError +from infrastructure.resilience.retry import with_retry, CircuitBreaker +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue +from infrastructure.http.deduplication import RequestDeduplicator + +logger = logging.getLogger(__name__) + +MB_API_BASE = "https://musicbrainz.org/ws/2" + +mb_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="musicbrainz" +) + +mb_rate_limiter = TokenBucketRateLimiter(rate=1.0, capacity=6) + +mb_deduplicator = RequestDeduplicator() + +_http_client: httpx.AsyncClient | None = None +T = TypeVar("T") + + +def _decode_json_response(response: httpx.Response) -> dict[str, Any]: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=dict[str, Any]) + return response.json() + + +def _decode_typed_response(response: httpx.Response, decode_type: type[T]) -> T: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=decode_type) + return msgspec.convert(response.json(), type=decode_type) + + +def set_mb_http_client(client: httpx.AsyncClient) -> None: + global _http_client + _http_client = client + + +def get_mb_http_client() -> httpx.AsyncClient: + if _http_client is None: + raise RuntimeError("MusicBrainz HTTP client not initialized") + return _http_client + + +@with_retry( + max_attempts=3, + circuit_breaker=mb_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), +) +async def mb_api_get( + path: str, + params: dict[str, Any] | None = None, + priority: RequestPriority = RequestPriority.USER_INITIATED, + decode_type: type[T] | None = None, +) -> dict[str, Any] | T: + priority_mgr = get_priority_queue() + semaphore = await priority_mgr.acquire_slot(priority) + async with semaphore: + await mb_rate_limiter.acquire() + client = get_mb_http_client() + url = f"{MB_API_BASE}{path}" + request_params = dict(params) if params else {} + request_params["fmt"] = "json" + response = await client.get(url, params=request_params) + if response.status_code == 404: + if decode_type is not None: + return decode_type() + return {} + if response.status_code == 503: + raise ExternalServiceError(f"MusicBrainz rate limited (503): {path}") + if response.status_code != 200: + raise ExternalServiceError( + f"MusicBrainz API error ({response.status_code}): {path}" + ) + try: + if decode_type is not None: + return _decode_typed_response(response, decode_type) + return _decode_json_response(response) + except (msgspec.DecodeError, msgspec.ValidationError, TypeError) as exc: + raise ExternalServiceError(f"MusicBrainz returned invalid JSON payload for {path}: {exc}") from exc + + +def should_include_release( + release_group: dict[str, Any], + included_secondary_types: set[str] | None = None +) -> bool: + secondary_types = set(map(str.lower, release_group.get("secondary-types", []) or [])) + + if included_secondary_types is None: + exclude_types = {"compilation", "live", "remix", "soundtrack", "dj-mix", "mixtape/street", "demo"} + return secondary_types.isdisjoint(exclude_types) + + if not secondary_types: + return "studio" in included_secondary_types + + return bool(secondary_types.intersection(included_secondary_types)) + + +def extract_artist_name(release_group: dict[str, Any]) -> str | None: + artist_credit = release_group.get("artist-credit", []) + if not isinstance(artist_credit, list) or not artist_credit: + return None + + first_credit = artist_credit[0] + if isinstance(first_credit, dict): + return first_credit.get("name") or (first_credit.get("artist") or {}).get("name") + return None + + +def parse_year(date_str: str | None) -> int | None: + if not date_str: + return None + year = date_str.split("-", 1)[0] + return int(year) if year.isdigit() else None + + +def get_score(item: dict[str, Any]) -> int: + score = item.get("score") or item.get("ext:score") + try: + return int(score) if score else 0 + except (ValueError, TypeError): + return 0 + + +def dedupe_by_id(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen = {} + for item in items: + item_id = item.get("id") + if item_id and item_id not in seen: + seen[item_id] = item + + result = list(seen.values()) + result.sort(key=get_score, reverse=True) + return result + + +def _normalize_tag_phrase(tag: str) -> str: + return " ".join(tag.strip().lower().split()) + + +def _escape_lucene_phrase(value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def build_musicbrainz_tag_query(tag: str) -> str: + base = _normalize_tag_phrase(tag) + if not base: + return 'tag:""^3' + + variants: list[str] = [base] + seen = {base} + + def add_variant(value: str) -> None: + normalized = _normalize_tag_phrase(value) + if normalized and normalized not in seen: + seen.add(normalized) + variants.append(normalized) + + add_variant(base.replace("-", " ")) + add_variant(base.replace(" ", "-")) + + if "&" in base: + add_variant(base.replace("&", " and ")) + add_variant(base.replace("&", " ")) + + if " and " in base: + add_variant(base.replace(" and ", " & ")) + add_variant(base.replace(" and ", " ")) + + clauses = [] + for index, variant in enumerate(variants): + escaped = _escape_lucene_phrase(variant) + boost = "^3" if index == 0 else "^2" + clauses.append(f'tag:"{escaped}"{boost}') + + return " OR ".join(clauses) diff --git a/backend/repositories/musicbrainz_repository.py b/backend/repositories/musicbrainz_repository.py new file mode 100644 index 0000000..0e04ae8 --- /dev/null +++ b/backend/repositories/musicbrainz_repository.py @@ -0,0 +1,63 @@ +import asyncio +import logging +from typing import Optional + +import httpx + +from models.search import SearchResult +from services.preferences_service import PreferencesService +from infrastructure.cache.memory_cache import CacheInterface +from repositories.musicbrainz_base import mb_rate_limiter, set_mb_http_client +from repositories.musicbrainz_artist import MusicBrainzArtistMixin +from repositories.musicbrainz_album import MusicBrainzAlbumMixin + +logger = logging.getLogger(__name__) + + +class MusicBrainzRepository(MusicBrainzArtistMixin, MusicBrainzAlbumMixin): + def __init__(self, http_client: httpx.AsyncClient, cache: CacheInterface, preferences_service: PreferencesService): + self._cache = cache + self._preferences_service = preferences_service + set_mb_http_client(http_client) + + async def search_grouped( + self, + query: str, + limits: dict[str, int], + buckets: Optional[list[str]] = None, + included_secondary_types: Optional[set[str]] = None + ) -> dict[str, list[SearchResult]]: + advanced_settings = self._preferences_service.get_advanced_settings() + new_capacity = advanced_settings.musicbrainz_concurrent_searches + if mb_rate_limiter.capacity != new_capacity: + mb_rate_limiter.update_capacity(new_capacity) + + tasks = [] + task_keys = [] + + if not buckets or "artists" in buckets: + tasks.append(self.search_artists(query, limit=limits.get("artists", 10))) + task_keys.append("artists") + + if not buckets or "albums" in buckets: + tasks.append(self.search_albums( + query, + limit=limits.get("albums", 10), + included_secondary_types=included_secondary_types + )) + task_keys.append("albums") + + if not tasks: + return {} + + results_list = await asyncio.gather(*tasks, return_exceptions=True) + + results = {} + for key, result in zip(task_keys, results_list): + if isinstance(result, Exception): + logger.error(f"Search {key} failed: {result}") + results[key] = [] + else: + results[key] = result + + return results diff --git a/backend/repositories/navidrome_models.py b/backend/repositories/navidrome_models.py new file mode 100644 index 0000000..e55a3cb --- /dev/null +++ b/backend/repositories/navidrome_models.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from typing import Any + +import msgspec + +from core.exceptions import NavidromeApiError, NavidromeAuthError + +logger = logging.getLogger(__name__) + + +class SubsonicArtist(msgspec.Struct): + id: str + name: str + albumCount: int = 0 + coverArt: str = "" + musicBrainzId: str = "" + + +class SubsonicSong(msgspec.Struct): + id: str + title: str + album: str = "" + albumId: str = "" + artist: str = "" + artistId: str = "" + track: int = 0 + discNumber: int = 1 + year: int = 0 + duration: int = 0 + bitRate: int = 0 + suffix: str = "" + contentType: str = "" + musicBrainzId: str = "" + + +class SubsonicAlbum(msgspec.Struct): + id: str + name: str + artist: str = "" + artistId: str = "" + year: int = 0 + genre: str = "" + songCount: int = 0 + duration: int = 0 + coverArt: str = "" + musicBrainzId: str = "" + song: list[SubsonicSong] | None = None + + +class SubsonicPlaylist(msgspec.Struct): + id: str + name: str + songCount: int = 0 + duration: int = 0 + entry: list[SubsonicSong] | None = None + + +class SubsonicGenre(msgspec.Struct): + name: str = "" + songCount: int = 0 + albumCount: int = 0 + + +class SubsonicSearchResult(msgspec.Struct): + artist: list[SubsonicArtist] = msgspec.field(default_factory=list) + album: list[SubsonicAlbum] = msgspec.field(default_factory=list) + song: list[SubsonicSong] = msgspec.field(default_factory=list) + + +class StreamProxyResult(msgspec.Struct): + status_code: int + headers: dict[str, str] + media_type: str + body_chunks: AsyncIterator[bytes] | None = None + + +def parse_subsonic_response(data: dict[str, Any]) -> dict[str, Any]: + resp = data.get("subsonic-response") + if resp is None: + raise NavidromeApiError("Missing subsonic-response envelope") + status = resp.get("status", "") + if status != "ok": + error = resp.get("error", {}) + code = error.get("code", 0) + message = error.get("message", "Unknown Subsonic API error") + if code in (40, 41): + raise NavidromeAuthError(message, code=code) + raise NavidromeApiError(message, code=code) + return resp + + +def parse_artist(data: dict[str, Any]) -> SubsonicArtist: + return SubsonicArtist( + id=data.get("id", ""), + name=data.get("name", "Unknown"), + albumCount=data.get("albumCount", 0), + coverArt=data.get("coverArt", ""), + musicBrainzId=data.get("musicBrainzId", ""), + ) + + +def parse_song(data: dict[str, Any]) -> SubsonicSong: + return SubsonicSong( + id=data.get("id", ""), + title=data.get("title", "Unknown"), + album=data.get("album", ""), + albumId=data.get("albumId", ""), + artist=data.get("artist", ""), + artistId=data.get("artistId", ""), + track=data.get("track", 0), + discNumber=data.get("discNumber", 1), + year=data.get("year", 0), + duration=data.get("duration", 0), + bitRate=data.get("bitRate", 0), + suffix=data.get("suffix", ""), + contentType=data.get("contentType", ""), + musicBrainzId=data.get("musicBrainzId", ""), + ) + + +def parse_album(data: dict[str, Any]) -> SubsonicAlbum: + songs: list[SubsonicSong] | None = None + raw_songs = data.get("song") + if raw_songs is not None: + songs = [parse_song(s) for s in raw_songs] + + return SubsonicAlbum( + id=data.get("id", ""), + name=data.get("name", data.get("title", "Unknown")), + artist=data.get("artist", ""), + artistId=data.get("artistId", ""), + year=data.get("year", 0), + genre=data.get("genre", ""), + songCount=data.get("songCount", 0), + duration=data.get("duration", 0), + coverArt=data.get("coverArt", ""), + musicBrainzId=data.get("musicBrainzId", ""), + song=songs, + ) + + +def parse_genre(data: dict[str, Any]) -> SubsonicGenre: + return SubsonicGenre( + name=data.get("value", data.get("name", "")), + songCount=data.get("songCount", 0), + albumCount=data.get("albumCount", 0), + ) diff --git a/backend/repositories/navidrome_repository.py b/backend/repositories/navidrome_repository.py new file mode 100644 index 0000000..814e0df --- /dev/null +++ b/backend/repositories/navidrome_repository.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import hashlib +import logging +import secrets +from typing import Any +from urllib.parse import urlencode + +import httpx +import msgspec + +from core.exceptions import ExternalServiceError, NavidromeApiError, NavidromeAuthError +from infrastructure.cache.cache_keys import NAVIDROME_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.resilience.retry import with_retry, CircuitBreaker +from repositories.navidrome_models import ( + StreamProxyResult as StreamProxyResult, + SubsonicAlbum, + SubsonicArtist, + SubsonicGenre, + SubsonicPlaylist, + SubsonicSearchResult, + SubsonicSong, + parse_album, + parse_artist, + parse_genre, + parse_song, + parse_subsonic_response, +) +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "navidrome" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +_navidrome_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="navidrome", +) + +_DEFAULT_TTL_LIST = 300 +_DEFAULT_TTL_SEARCH = 120 +_DEFAULT_TTL_GENRES = 3600 +_DEFAULT_TTL_DETAIL = 300 + + +class NavidromeRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + cache: CacheInterface, + ) -> None: + self._client = http_client + self._cache = cache + self._url: str = "" + self._username: str = "" + self._password: str = "" + self._configured: bool = False + self._ttl_list: int = _DEFAULT_TTL_LIST + self._ttl_search: int = _DEFAULT_TTL_SEARCH + self._ttl_genres: int = _DEFAULT_TTL_GENRES + self._ttl_detail: int = _DEFAULT_TTL_DETAIL + + def configure(self, url: str, username: str, password: str) -> None: + self._url = url.rstrip("/") if url else "" + self._username = username + self._password = password + self._configured = bool(self._url and self._username and self._password) + + def is_configured(self) -> bool: + return self._configured + + def configure_cache_ttls( + self, + *, + list_ttl: int | None = None, + search_ttl: int | None = None, + genres_ttl: int | None = None, + detail_ttl: int | None = None, + ) -> None: + if list_ttl is not None: + self._ttl_list = list_ttl + if search_ttl is not None: + self._ttl_search = search_ttl + if genres_ttl is not None: + self._ttl_genres = genres_ttl + if detail_ttl is not None: + self._ttl_detail = detail_ttl + + @staticmethod + def reset_circuit_breaker() -> None: + _navidrome_circuit_breaker.reset() + + def _build_auth_params(self) -> dict[str, str]: + salt = secrets.token_hex(3) + token = hashlib.md5( + (self._password + salt).encode("utf-8") + ).hexdigest() + return { + "u": self._username, + "t": token, + "s": salt, + "v": "1.16.1", + "c": "musicseerr", + "f": "json", + } + + def build_stream_url(self, song_id: str) -> str: + """Build a full stream URL for a song, including auth params.""" + if not self._configured: + raise ValueError("Navidrome is not configured") + params = self._build_auth_params() + params["id"] = song_id + return f"{self._url}/rest/stream?{urlencode(params)}" + + async def proxy_head_stream(self, song_id: str) -> StreamProxyResult: + """HEAD proxy to Navidrome stream endpoint. Returns filtered headers.""" + stream_url = self.build_stream_url(song_id) + async with httpx.AsyncClient( + timeout=httpx.Timeout(connect=10, read=10, write=10, pool=10) + ) as client: + try: + resp = await client.head(stream_url) + except httpx.HTTPError: + raise ExternalServiceError("Failed to reach Navidrome") + + headers: dict[str, str] = {} + for h in _PROXY_FORWARD_HEADERS: + v = resp.headers.get(h) + if v: + headers[h] = v + return StreamProxyResult( + status_code=resp.status_code, + headers=headers, + media_type=headers.get("Content-Type", "audio/mpeg"), + ) + + async def proxy_get_stream( + self, song_id: str, range_header: str | None = None + ) -> StreamProxyResult: + """GET streaming proxy to Navidrome. Returns chunked body iterator with cleanup.""" + stream_url = self.build_stream_url(song_id) + + upstream_headers: dict[str, str] = {} + if range_header: + upstream_headers["Range"] = range_header + + client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10) + ) + upstream_resp = None + try: + upstream_resp = await client.send( + client.build_request("GET", stream_url, headers=upstream_headers), + stream=True, + ) + + if upstream_resp.status_code == 416: + raise ExternalServiceError("416 Range not satisfiable") + + if upstream_resp.status_code >= 400: + logger.error( + "Navidrome upstream returned %d for %s", + upstream_resp.status_code, song_id, + ) + raise ExternalServiceError("Navidrome returned an error") + + resp_headers: dict[str, str] = {} + for header_name in _PROXY_FORWARD_HEADERS: + value = upstream_resp.headers.get(header_name) + if value: + resp_headers[header_name] = value + + status_code = 206 if upstream_resp.status_code == 206 else 200 + + async def _stream_body() -> AsyncIterator[bytes]: + try: + async for chunk in upstream_resp.aiter_bytes( + chunk_size=_STREAM_CHUNK_SIZE + ): + yield chunk + finally: + await upstream_resp.aclose() + await client.aclose() + + return StreamProxyResult( + status_code=status_code, + headers=resp_headers, + media_type=resp_headers.get("Content-Type", "audio/mpeg"), + body_chunks=_stream_body(), + ) + except Exception: + if upstream_resp: + await upstream_resp.aclose() + await client.aclose() + raise + + @with_retry( + max_attempts=3, + base_delay=1.0, + max_delay=5.0, + circuit_breaker=_navidrome_circuit_breaker, + retriable_exceptions=(httpx.HTTPError, ExternalServiceError), + ) + async def _request( + self, + endpoint: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + if not self._configured: + raise ExternalServiceError("Navidrome not configured") + + merged = self._build_auth_params() + if params: + merged.update(params) + + url = f"{self._url}{endpoint}" + try: + response = await self._client.get(url, params=merged, timeout=15.0) + except httpx.TimeoutException as exc: + raise ExternalServiceError(f"Navidrome request timed out: {exc}") + except httpx.HTTPError as exc: + raise ExternalServiceError(f"Navidrome request failed: {exc}") + + if response.status_code in (401, 403): + raise NavidromeAuthError( + f"Navidrome authentication failed ({response.status_code})" + ) + if response.status_code != 200: + raise NavidromeApiError( + f"Navidrome request failed ({response.status_code})", + ) + + try: + data: dict[str, Any] = response.json() + except Exception as exc: + raise NavidromeApiError(f"Navidrome returned invalid JSON for {endpoint}") from exc + return parse_subsonic_response(data) + + async def ping(self) -> bool: + try: + await self._request("/rest/ping") + return True + except Exception: # noqa: BLE001 + logger.debug("Navidrome ping failed", exc_info=True) + _record_degradation("Navidrome ping failed") + return False + + async def get_album_list( + self, + type: str, + size: int = 20, + offset: int = 0, + genre: str | None = None, + ) -> list[SubsonicAlbum]: + cache_key = f"{NAVIDROME_PREFIX}albums:{type}:{size}:{offset}:{genre or ''}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + params: dict[str, Any] = {"type": type, "size": size, "offset": offset} + if genre and type == "byGenre": + params["genre"] = genre + resp = await self._request( + "/rest/getAlbumList2", + params, + ) + raw = resp.get("albumList2", {}).get("album", []) + albums = [parse_album(a) for a in raw] + await self._cache.set(cache_key, albums, self._ttl_list) + return albums + + async def get_album(self, id: str) -> SubsonicAlbum: + cache_key = f"{NAVIDROME_PREFIX}album:{id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getAlbum", {"id": id}) + album = parse_album(resp.get("album", {})) + await self._cache.set(cache_key, album, self._ttl_detail) + return album + + async def get_artists(self) -> list[SubsonicArtist]: + cache_key = "navidrome:artists" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getArtists") + artists: list[SubsonicArtist] = [] + for index in resp.get("artists", {}).get("index", []): + for a in index.get("artist", []): + artists.append(parse_artist(a)) + await self._cache.set(cache_key, artists, self._ttl_list) + return artists + + async def get_artist(self, id: str) -> SubsonicArtist: + cache_key = f"{NAVIDROME_PREFIX}artist:{id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getArtist", {"id": id}) + artist = parse_artist(resp.get("artist", {})) + await self._cache.set(cache_key, artist, self._ttl_detail) + return artist + + async def get_song(self, id: str) -> SubsonicSong: + cache_key = f"{NAVIDROME_PREFIX}song:{id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getSong", {"id": id}) + song = parse_song(resp.get("song", {})) + await self._cache.set(cache_key, song, self._ttl_detail) + return song + + async def search( + self, + query: str, + artist_count: int = 20, + album_count: int = 20, + song_count: int = 20, + ) -> SubsonicSearchResult: + cache_key = f"{NAVIDROME_PREFIX}search:{query}:{artist_count}:{album_count}:{song_count}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request( + "/rest/search3", + { + "query": query, + "artistCount": artist_count, + "albumCount": album_count, + "songCount": song_count, + }, + ) + sr = resp.get("searchResult3", {}) + result = SubsonicSearchResult( + artist=[parse_artist(a) for a in sr.get("artist", [])], + album=[parse_album(a) for a in sr.get("album", [])], + song=[parse_song(s) for s in sr.get("song", [])], + ) + await self._cache.set(cache_key, result, self._ttl_search) + return result + + async def get_starred(self) -> SubsonicSearchResult: + cache_key = "navidrome:starred" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getStarred2") + sr = resp.get("starred2", {}) + result = SubsonicSearchResult( + artist=[parse_artist(a) for a in sr.get("artist", [])], + album=[parse_album(a) for a in sr.get("album", [])], + song=[parse_song(s) for s in sr.get("song", [])], + ) + await self._cache.set(cache_key, result, self._ttl_list) + return result + + async def get_genres(self) -> list[SubsonicGenre]: + cache_key = "navidrome:genres" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getGenres") + raw = resp.get("genres", {}).get("genre", []) + genres = [parse_genre(g) for g in raw] + await self._cache.set(cache_key, genres, self._ttl_genres) + return genres + + async def get_playlists(self) -> list[SubsonicPlaylist]: + cache_key = "navidrome:playlists" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getPlaylists") + raw = resp.get("playlists", {}).get("playlist", []) + playlists: list[SubsonicPlaylist] = [] + for p in raw: + playlists.append( + SubsonicPlaylist( + id=p.get("id", ""), + name=p.get("name", ""), + songCount=p.get("songCount", 0), + duration=p.get("duration", 0), + ) + ) + await self._cache.set(cache_key, playlists, self._ttl_list) + return playlists + + async def get_playlist(self, id: str) -> SubsonicPlaylist: + cache_key = f"{NAVIDROME_PREFIX}playlist:{id}" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + resp = await self._request("/rest/getPlaylist", {"id": id}) + raw = resp.get("playlist", {}) + entries = raw.get("entry", []) + playlist = SubsonicPlaylist( + id=raw.get("id", ""), + name=raw.get("name", ""), + songCount=raw.get("songCount", 0), + duration=raw.get("duration", 0), + entry=[parse_song(e) for e in entries] if entries else None, + ) + await self._cache.set(cache_key, playlist, self._ttl_detail) + return playlist + + async def get_random_songs( + self, + size: int = 20, + genre: str | None = None, + ) -> list[SubsonicSong]: + params: dict[str, Any] = {"size": size} + if genre: + params["genre"] = genre + + resp = await self._request("/rest/getRandomSongs", params) + raw = resp.get("randomSongs", {}).get("song", []) + return [parse_song(s) for s in raw] + + async def scrobble( + self, + id: str, + time_ms: int | None = None, + ) -> bool: + params: dict[str, Any] = {"id": id} + if time_ms is not None: + params["time"] = time_ms + + try: + await self._request("/rest/scrobble", params) + return True + except Exception: # noqa: BLE001 + logger.warning("Navidrome scrobble failed", exc_info=True) + _record_degradation("Navidrome scrobble failed") + return False + + async def now_playing(self, id: str) -> bool: + params: dict[str, Any] = {"id": id, "submission": "false"} + try: + await self._request("/rest/scrobble", params) + return True + except Exception: # noqa: BLE001 + logger.warning("Navidrome now-playing report failed", exc_info=True) + _record_degradation("Navidrome now-playing report failed") + return False + + async def validate_connection(self) -> tuple[bool, str]: + if not self._configured: + return False, "Navidrome URL, username, or password not configured" + + try: + resp = await self._request("/rest/ping") + version = resp.get("version", "unknown") + return True, f"Connected to Navidrome (API v{version})" + except NavidromeAuthError as exc: + return False, f"Authentication failed: {exc.message}" + except ExternalServiceError as exc: + msg = str(exc) + if "timed out" in msg.lower(): + return False, "Connection timed out - check URL" + if "connect" in msg.lower() or "refused" in msg.lower(): + return False, "Could not connect - check URL and ensure server is running" + return False, f"Connection failed: {msg}" + except Exception as exc: # noqa: BLE001 + return False, f"Connection failed: {exc}" + + async def clear_cache(self) -> None: + await self._cache.clear_prefix(NAVIDROME_PREFIX) + + async def get_cover_art(self, cover_art_id: str, size: int = 500) -> tuple[bytes, str]: + if not self._configured: + raise ExternalServiceError("Navidrome not configured") + + params = self._build_auth_params() + params["id"] = cover_art_id + params["size"] = str(size) + + url = f"{self._url}/rest/getCoverArt" + try: + response = await self._client.get(url, params=params, timeout=15.0) + except httpx.TimeoutException: + raise ExternalServiceError("Navidrome cover art request timed out") + except httpx.HTTPError: + raise ExternalServiceError("Navidrome cover art request failed") + + if response.status_code != 200: + raise ExternalServiceError( + f"Navidrome cover art failed ({response.status_code})" + ) + + content_type = response.headers.get("content-type", "image/jpeg") + return response.content, content_type + + +_PROXY_FORWARD_HEADERS = {"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} +_STREAM_CHUNK_SIZE = 64 * 1024 diff --git a/backend/repositories/playlist_repository.py b/backend/repositories/playlist_repository.py new file mode 100644 index 0000000..605ccc8 --- /dev/null +++ b/backend/repositories/playlist_repository.py @@ -0,0 +1,691 @@ +import json +import logging +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +from uuid import uuid4 + +logger = logging.getLogger(__name__) + +CACHE_DB_PATH = Path("/app/cache/library.db") + +_UNSET = object() + + +class PlaylistRecord: + __slots__ = ("id", "name", "cover_image_path", "created_at", "updated_at") + + def __init__( + self, + id: str, + name: str, + cover_image_path: Optional[str], + created_at: str, + updated_at: str, + ): + self.id = id + self.name = name + self.cover_image_path = cover_image_path + self.created_at = created_at + self.updated_at = updated_at + + +class PlaylistSummaryRecord: + __slots__ = ( + "id", "name", "cover_image_path", "created_at", "updated_at", + "track_count", "total_duration", "cover_urls", + ) + + def __init__( + self, + id: str, + name: str, + cover_image_path: Optional[str], + created_at: str, + updated_at: str, + track_count: int, + total_duration: Optional[int], + cover_urls: list[str], + ): + self.id = id + self.name = name + self.cover_image_path = cover_image_path + self.created_at = created_at + self.updated_at = updated_at + self.track_count = track_count + self.total_duration = total_duration + self.cover_urls = cover_urls + + +class PlaylistTrackRecord: + __slots__ = ( + "id", "playlist_id", "position", "track_name", "artist_name", + "album_name", "album_id", "artist_id", "track_source_id", "cover_url", + "source_type", "available_sources", "format", "track_number", "disc_number", + "duration", "created_at", + ) + + def __init__( + self, + id: str, + playlist_id: str, + position: int, + track_name: str, + artist_name: str, + album_name: str, + album_id: Optional[str], + artist_id: Optional[str], + track_source_id: Optional[str], + cover_url: Optional[str], + source_type: str, + available_sources: Optional[list[str]], + format: Optional[str], + track_number: Optional[int], + disc_number: Optional[int], + duration: Optional[int], + created_at: str, + ): + self.id = id + self.playlist_id = playlist_id + self.position = position + self.track_name = track_name + self.artist_name = artist_name + self.album_name = album_name + self.album_id = album_id + self.artist_id = artist_id + self.track_source_id = track_source_id + self.cover_url = cover_url + self.source_type = source_type + self.available_sources = available_sources + self.format = format + self.track_number = track_number + self.disc_number = disc_number + self.duration = duration + self.created_at = created_at + + +class PlaylistRepository: + def __init__(self, db_path: Path = CACHE_DB_PATH): + self.db_path = db_path + self._local = threading.local() + self._write_lock = threading.Lock() + self._ensure_tables() + + def _get_connection(self) -> sqlite3.Connection: + if not hasattr(self._local, "conn") or self._local.conn is None: + conn = sqlite3.connect(self.db_path, check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + self._local.conn = conn + return self._local.conn + + def _ensure_tables(self) -> None: + conn = sqlite3.connect(self.db_path) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + conn.execute( + "ALTER TABLE playlist_tracks RENAME COLUMN video_id TO track_source_id" + ) + conn.commit() + except sqlite3.OperationalError: + pass + conn.execute(""" + CREATE TABLE IF NOT EXISTS playlists ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + cover_image_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS playlist_tracks ( + id TEXT PRIMARY KEY, + playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_id TEXT, + artist_id TEXT, + track_source_id TEXT, + cover_url TEXT, + source_type TEXT NOT NULL, + available_sources TEXT, + format TEXT, + track_number INTEGER, + duration INTEGER, + created_at TEXT NOT NULL, + UNIQUE(playlist_id, position) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_position + ON playlist_tracks(playlist_id, position) + """) + try: + conn.execute("ALTER TABLE playlist_tracks ADD COLUMN disc_number INTEGER") + conn.commit() + except sqlite3.OperationalError: + pass + conn.execute(""" + UPDATE playlist_tracks + SET cover_url = '/api/v1/covers/' || SUBSTR(cover_url, LENGTH('/api/covers/') + 1) + WHERE cover_url LIKE '/api/covers/%' + """) + conn.commit() + finally: + conn.close() + + + def create_playlist(self, name: str) -> PlaylistRecord: + playlist_id = str(uuid4()) + now = datetime.now(timezone.utc).isoformat() + with self._write_lock: + conn = self._get_connection() + conn.execute( + "INSERT INTO playlists (id, name, cover_image_path, created_at, updated_at) " + "VALUES (?, ?, NULL, ?, ?)", + (playlist_id, name, now, now), + ) + conn.commit() + return PlaylistRecord( + id=playlist_id, name=name, cover_image_path=None, + created_at=now, updated_at=now, + ) + + def get_playlist(self, playlist_id: str) -> Optional[PlaylistRecord]: + conn = self._get_connection() + row = conn.execute( + "SELECT * FROM playlists WHERE id = ?", (playlist_id,) + ).fetchone() + return self._row_to_playlist(row) if row else None + + def get_all_playlists(self) -> list[PlaylistSummaryRecord]: + conn = self._get_connection() + rows = conn.execute(""" + SELECT p.*, + COUNT(pt.id) AS track_count, + SUM(pt.duration) AS total_duration + FROM playlists p + LEFT JOIN playlist_tracks pt ON pt.playlist_id = p.id + GROUP BY p.id + ORDER BY p.updated_at DESC + """).fetchall() + + results: list[PlaylistSummaryRecord] = [] + for row in rows: + cover_urls = [ + r["cover_url"] for r in conn.execute( + "SELECT DISTINCT cover_url FROM playlist_tracks " + "WHERE playlist_id = ? AND cover_url IS NOT NULL LIMIT 4", + (row["id"],), + ).fetchall() + ] + results.append(PlaylistSummaryRecord( + id=row["id"], + name=row["name"], + cover_image_path=row["cover_image_path"], + created_at=row["created_at"], + updated_at=row["updated_at"], + track_count=row["track_count"], + total_duration=row["total_duration"], + cover_urls=cover_urls, + )) + return results + + def update_playlist( + self, + playlist_id: str, + name: Optional[str] = None, + cover_image_path: Optional[str] = _UNSET, + ) -> Optional[PlaylistRecord]: + with self._write_lock: + conn = self._get_connection() + existing = conn.execute( + "SELECT * FROM playlists WHERE id = ?", (playlist_id,) + ).fetchone() + if not existing: + return None + + new_name = name if name is not None else existing["name"] + new_cover = ( + cover_image_path + if cover_image_path is not _UNSET + else existing["cover_image_path"] + ) + now = datetime.now(timezone.utc).isoformat() + + conn.execute( + "UPDATE playlists SET name = ?, cover_image_path = ?, updated_at = ? WHERE id = ?", + (new_name, new_cover, now, playlist_id), + ) + conn.commit() + return PlaylistRecord( + id=playlist_id, name=new_name, cover_image_path=new_cover, + created_at=existing["created_at"], updated_at=now, + ) + + def delete_playlist(self, playlist_id: str) -> bool: + with self._write_lock: + conn = self._get_connection() + cursor = conn.execute( + "DELETE FROM playlists WHERE id = ?", (playlist_id,) + ) + conn.commit() + return cursor.rowcount > 0 + + def add_tracks( + self, + playlist_id: str, + tracks: list[dict], + position: Optional[int] = None, + ) -> list[PlaylistTrackRecord]: + if not tracks: + return [] + + now = datetime.now(timezone.utc).isoformat() + created_records: list[PlaylistTrackRecord] = [] + + with self._write_lock: + conn = self._get_connection() + + max_row = conn.execute( + "SELECT MAX(position) FROM playlist_tracks WHERE playlist_id = ?", + (playlist_id,), + ).fetchone() + current_max = max_row[0] if max_row[0] is not None else -1 + + if position is None or position > current_max + 1: + insert_at = current_max + 1 + else: + insert_at = max(0, position) + shift_count = len(tracks) + rows_to_shift = conn.execute( + "SELECT id, position FROM playlist_tracks " + "WHERE playlist_id = ? AND position >= ? " + "ORDER BY position DESC", + (playlist_id, insert_at), + ).fetchall() + for idx, r in enumerate(rows_to_shift): + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (-(idx + 1), r["id"]), + ) + for r in rows_to_shift: + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (r["position"] + shift_count, r["id"]), + ) + + for i, track in enumerate(tracks): + track_id = str(uuid4()) + pos = insert_at + i + available_sources_json = ( + json.dumps(track["available_sources"]) + if track.get("available_sources") else None + ) + conn.execute( + "INSERT INTO playlist_tracks " + "(id, playlist_id, position, track_name, artist_name, album_name, " + "album_id, artist_id, track_source_id, cover_url, source_type, " + "available_sources, format, track_number, disc_number, duration, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + track_id, playlist_id, pos, + track["track_name"], track["artist_name"], track["album_name"], + track.get("album_id"), track.get("artist_id"), + track.get("track_source_id"), track.get("cover_url"), + track["source_type"], available_sources_json, + track.get("format"), track.get("track_number"), track.get("disc_number"), + track.get("duration"), now, + ), + ) + created_records.append(PlaylistTrackRecord( + id=track_id, playlist_id=playlist_id, position=pos, + track_name=track["track_name"], artist_name=track["artist_name"], + album_name=track["album_name"], album_id=track.get("album_id"), + artist_id=track.get("artist_id"), track_source_id=track.get("track_source_id"), + cover_url=track.get("cover_url"), source_type=track["source_type"], + available_sources=track.get("available_sources"), + format=track.get("format"), track_number=track.get("track_number"), + disc_number=track.get("disc_number"), + duration=track.get("duration"), created_at=now, + )) + + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + + return created_records + + def remove_track(self, playlist_id: str, track_id: str) -> bool: + with self._write_lock: + conn = self._get_connection() + row = conn.execute( + "SELECT position FROM playlist_tracks WHERE id = ? AND playlist_id = ?", + (track_id, playlist_id), + ).fetchone() + if not row: + return False + + removed_pos = row["position"] + conn.execute( + "DELETE FROM playlist_tracks WHERE id = ? AND playlist_id = ?", + (track_id, playlist_id), + ) + rows_to_shift = conn.execute( + "SELECT id, position FROM playlist_tracks " + "WHERE playlist_id = ? AND position > ? " + "ORDER BY position ASC", + (playlist_id, removed_pos), + ).fetchall() + for r in rows_to_shift: + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (r["position"] - 1, r["id"]), + ) + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + return True + + def remove_tracks(self, playlist_id: str, track_ids: list[str]) -> int: + if not track_ids: + return 0 + with self._write_lock: + conn = self._get_connection() + placeholders = ",".join("?" for _ in track_ids) + existing = conn.execute( + f"SELECT id FROM playlist_tracks WHERE playlist_id = ? AND id IN ({placeholders})", + [playlist_id, *track_ids], + ).fetchall() + if not existing: + return 0 + ids_to_remove = [r["id"] for r in existing] + rm_placeholders = ",".join("?" for _ in ids_to_remove) + conn.execute( + f"DELETE FROM playlist_tracks WHERE playlist_id = ? AND id IN ({rm_placeholders})", + [playlist_id, *ids_to_remove], + ) + remaining = conn.execute( + "SELECT id FROM playlist_tracks WHERE playlist_id = ? ORDER BY position ASC", + (playlist_id,), + ).fetchall() + for new_pos, row in enumerate(remaining): + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (new_pos, row["id"]), + ) + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + return len(ids_to_remove) + + def reorder_track(self, playlist_id: str, track_id: str, new_position: int) -> Optional[int]: + with self._write_lock: + conn = self._get_connection() + row = conn.execute( + "SELECT position FROM playlist_tracks WHERE id = ? AND playlist_id = ?", + (track_id, playlist_id), + ).fetchone() + if not row: + return None + + old_position = row["position"] + + max_row = conn.execute( + "SELECT MAX(position) FROM playlist_tracks WHERE playlist_id = ?", + (playlist_id,), + ).fetchone() + max_pos = max_row[0] if max_row[0] is not None else 0 + actual_position = min(new_position, max_pos) + + if old_position == actual_position: + return actual_position + + # Move track to temp position to avoid UNIQUE violation + conn.execute( + "UPDATE playlist_tracks SET position = -1 WHERE id = ?", + (track_id,), + ) + + if old_position < actual_position: + rows = conn.execute( + "SELECT id, position FROM playlist_tracks " + "WHERE playlist_id = ? AND position > ? AND position <= ? " + "ORDER BY position ASC", + (playlist_id, old_position, actual_position), + ).fetchall() + for r in rows: + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (r["position"] - 1, r["id"]), + ) + else: + rows = conn.execute( + "SELECT id, position FROM playlist_tracks " + "WHERE playlist_id = ? AND position >= ? AND position < ? " + "ORDER BY position DESC", + (playlist_id, actual_position, old_position), + ).fetchall() + for r in rows: + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (r["position"] + 1, r["id"]), + ) + + conn.execute( + "UPDATE playlist_tracks SET position = ? WHERE id = ?", + (actual_position, track_id), + ) + + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + return actual_position + + def batch_update_available_sources( + self, + playlist_id: str, + updates: dict[str, list[str]], + ) -> int: + if not updates: + return 0 + updated = 0 + with self._write_lock: + conn = self._get_connection() + for track_id, sources in updates.items(): + conn.execute( + "UPDATE playlist_tracks SET available_sources = ? " + "WHERE id = ? AND playlist_id = ?", + (json.dumps(sources), track_id, playlist_id), + ) + updated += 1 + if updated: + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + return updated + + def update_track_source( + self, + playlist_id: str, + track_id: str, + source_type: Optional[str] = None, + available_sources: Optional[list[str]] = None, + track_source_id: Optional[str] = None, + ) -> Optional[PlaylistTrackRecord]: + with self._write_lock: + conn = self._get_connection() + row = conn.execute( + "SELECT * FROM playlist_tracks WHERE id = ? AND playlist_id = ?", + (track_id, playlist_id), + ).fetchone() + if not row: + return None + + new_source_type = source_type if source_type is not None else row["source_type"] + new_available = ( + json.dumps(available_sources) + if available_sources is not None + else row["available_sources"] + ) + new_track_source_id = track_source_id if track_source_id is not None else row["track_source_id"] + + conn.execute( + "UPDATE playlist_tracks SET source_type = ?, available_sources = ?, track_source_id = ? " + "WHERE id = ? AND playlist_id = ?", + (new_source_type, new_available, new_track_source_id, track_id, playlist_id), + ) + now = datetime.now(timezone.utc).isoformat() + conn.execute( + "UPDATE playlists SET updated_at = ? WHERE id = ?", + (now, playlist_id), + ) + conn.commit() + + avail_parsed = ( + available_sources + if available_sources is not None + else self._parse_available_sources(row["available_sources"]) + ) + return PlaylistTrackRecord( + id=row["id"], playlist_id=row["playlist_id"], position=row["position"], + track_name=row["track_name"], artist_name=row["artist_name"], + album_name=row["album_name"], album_id=row["album_id"], + artist_id=row["artist_id"], track_source_id=new_track_source_id, + cover_url=row["cover_url"], source_type=new_source_type, + available_sources=avail_parsed, format=row["format"], + track_number=row["track_number"], + disc_number=row["disc_number"] if "disc_number" in row.keys() else None, + duration=row["duration"], + created_at=row["created_at"], + ) + + def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]: + conn = self._get_connection() + rows = conn.execute( + "SELECT * FROM playlist_tracks WHERE playlist_id = ? ORDER BY position", + (playlist_id,), + ).fetchall() + return [self._row_to_track(r) for r in rows] + + def get_track(self, playlist_id: str, track_id: str) -> Optional[PlaylistTrackRecord]: + conn = self._get_connection() + row = conn.execute( + "SELECT * FROM playlist_tracks WHERE id = ? AND playlist_id = ?", + (track_id, playlist_id), + ).fetchone() + if not row: + return None + return self._row_to_track(row) + + def check_track_membership( + self, tracks: list[tuple[str, str, str]], + ) -> dict[str, list[int]]: + """Check which playlists already contain the given tracks. + + Args: + tracks: list of (track_name, artist_name, album_name) tuples. + + Returns: + dict mapping playlist_id to list of input indices that are already present. + """ + if not tracks: + return {} + + conn = self._get_connection() + rows = conn.execute( + "SELECT playlist_id, LOWER(track_name) AS tn, " + "LOWER(artist_name) AS an, LOWER(album_name) AS aln " + "FROM playlist_tracks" + ).fetchall() + + lookup: dict[str, set[tuple[str, str, str]]] = {} + for row in rows: + pid = row["playlist_id"] + key = (row["tn"], row["an"], row["aln"]) + lookup.setdefault(pid, set()).add(key) + + result: dict[str, list[int]] = {} + normalised = [ + (t[0].lower(), t[1].lower(), t[2].lower()) for t in tracks + ] + for pid, existing in lookup.items(): + matched = [i for i, t in enumerate(normalised) if t in existing] + if matched: + result[pid] = matched + return result + + + def close(self) -> None: + conn = getattr(self._local, "conn", None) + if conn is not None: + try: + conn.close() + except sqlite3.Error as exc: + logger.warning("Failed to close playlist repository connection: %s", exc) + self._local.conn = None + + + @staticmethod + def _parse_available_sources(raw: Optional[str]) -> Optional[list[str]]: + if raw is None: + return None + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return None + + @staticmethod + def _row_to_playlist(row: sqlite3.Row) -> PlaylistRecord: + return PlaylistRecord( + id=row["id"], + name=row["name"], + cover_image_path=row["cover_image_path"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + @classmethod + def _row_to_track(cls, row: sqlite3.Row) -> PlaylistTrackRecord: + return PlaylistTrackRecord( + id=row["id"], + playlist_id=row["playlist_id"], + position=row["position"], + track_name=row["track_name"], + artist_name=row["artist_name"], + album_name=row["album_name"], + album_id=row["album_id"], + artist_id=row["artist_id"], + track_source_id=row["track_source_id"], + cover_url=row["cover_url"], + source_type=row["source_type"], + available_sources=cls._parse_available_sources(row["available_sources"]), + format=row["format"], + track_number=row["track_number"], + disc_number=row["disc_number"] if "disc_number" in row.keys() else None, + duration=row["duration"], + created_at=row["created_at"], + ) diff --git a/backend/repositories/protocols/__init__.py b/backend/repositories/protocols/__init__.py new file mode 100644 index 0000000..3fea8f7 --- /dev/null +++ b/backend/repositories/protocols/__init__.py @@ -0,0 +1,36 @@ +"""Domain-specific repository protocol definitions. + +Re-exports all protocols and associated data classes for backward compatibility +with code that previously imported from `repositories.protocols`. +""" + +from repositories.protocols.coverart import CoverArtRepositoryProtocol as CoverArtRepositoryProtocol +from repositories.protocols.jellyfin import JellyfinRepositoryProtocol as JellyfinRepositoryProtocol +from repositories.protocols.lastfm import LastFmRepositoryProtocol as LastFmRepositoryProtocol +from repositories.protocols.lidarr import LidarrRepositoryProtocol as LidarrRepositoryProtocol +from repositories.protocols.listenbrainz import ListenBrainzRepositoryProtocol as ListenBrainzRepositoryProtocol +from repositories.protocols.musicbrainz import MusicBrainzRepositoryProtocol as MusicBrainzRepositoryProtocol +from repositories.protocols.navidrome import NavidromeRepositoryProtocol as NavidromeRepositoryProtocol +from repositories.protocols.wikidata import WikidataRepositoryProtocol as WikidataRepositoryProtocol +from repositories.protocols.youtube import YouTubeRepositoryProtocol as YouTubeRepositoryProtocol + +from repositories.listenbrainz_models import ( + ListenBrainzArtist as ListenBrainzArtist, + ListenBrainzFeedbackRecording as ListenBrainzFeedbackRecording, + ListenBrainzReleaseGroup as ListenBrainzReleaseGroup, +) + +__all__ = [ + "CoverArtRepositoryProtocol", + "JellyfinRepositoryProtocol", + "LastFmRepositoryProtocol", + "LidarrRepositoryProtocol", + "ListenBrainzRepositoryProtocol", + "MusicBrainzRepositoryProtocol", + "NavidromeRepositoryProtocol", + "WikidataRepositoryProtocol", + "YouTubeRepositoryProtocol", + "ListenBrainzArtist", + "ListenBrainzFeedbackRecording", + "ListenBrainzReleaseGroup", +] diff --git a/backend/repositories/protocols/coverart.py b/backend/repositories/protocols/coverart.py new file mode 100644 index 0000000..28bd782 --- /dev/null +++ b/backend/repositories/protocols/coverart.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Protocol + + +class CoverArtRepositoryProtocol(Protocol): + + cache_dir: Path + + async def get_cover_url( + self, + album_mbid: str, + size: str = "500" + ) -> str | None: + ... + + async def batch_prefetch_covers( + self, + album_mbids: list[str], + size: str = "250" + ) -> None: + ... + + async def delete_covers_for_album(self, album_mbid: str) -> int: + ... + + async def delete_covers_for_artist(self, artist_mbid: str) -> int: + ... diff --git a/backend/repositories/protocols/jellyfin.py b/backend/repositories/protocols/jellyfin.py new file mode 100644 index 0000000..fa462e6 --- /dev/null +++ b/backend/repositories/protocols/jellyfin.py @@ -0,0 +1,133 @@ +from typing import Any, Protocol + +from repositories.jellyfin_models import JellyfinItem, JellyfinUser, PlaybackUrlResult + + +class JellyfinRepositoryProtocol(Protocol): + + def is_configured(self) -> bool: + ... + + def configure(self, base_url: str, api_key: str, user_id: str = "") -> None: + ... + + async def validate_connection(self) -> tuple[bool, str]: + ... + + async def get_users(self) -> list[JellyfinUser]: + ... + + async def fetch_users_direct(self) -> list[JellyfinUser]: + ... + + async def get_current_user(self) -> JellyfinUser | None: + ... + + async def get_recently_played( + self, user_id: str | None = None, limit: int = 20, ttl_seconds: int = 300 + ) -> list[JellyfinItem]: + ... + + async def get_favorite_artists( + self, user_id: str | None = None, limit: int = 20 + ) -> list[JellyfinItem]: + ... + + async def get_favorite_albums( + self, user_id: str | None = None, limit: int = 20, ttl_seconds: int = 300 + ) -> list[JellyfinItem]: + ... + + async def get_most_played_artists( + self, user_id: str | None = None, limit: int = 20 + ) -> list[JellyfinItem]: + ... + + async def get_most_played_albums( + self, user_id: str | None = None, limit: int = 20 + ) -> list[JellyfinItem]: + ... + + async def get_recently_added( + self, user_id: str | None = None, limit: int = 20 + ) -> list[JellyfinItem]: + ... + + async def get_genres(self, user_id: str | None = None, ttl_seconds: int = 3600) -> list[str]: + ... + + async def get_artists_by_genre( + self, genre: str, user_id: str | None = None, limit: int = 50 + ) -> list[JellyfinItem]: + ... + + def get_image_url(self, item_id: str, image_tag: str | None = None) -> str | None: + ... + + def get_auth_headers(self) -> dict[str, str]: + ... + + async def get_albums( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "SortName", + sort_order: str = "Ascending", + genre: str | None = None, + ) -> tuple[list[JellyfinItem], int]: + ... + + async def get_album_tracks(self, album_id: str) -> list[JellyfinItem]: + ... + + async def get_album_detail(self, album_id: str) -> JellyfinItem | None: + ... + + async def get_album_by_mbid(self, musicbrainz_id: str) -> JellyfinItem | None: + ... + + async def get_artist_by_mbid(self, musicbrainz_id: str) -> JellyfinItem | None: + ... + + async def get_artists( + self, limit: int = 50, offset: int = 0 + ) -> list[JellyfinItem]: + ... + + async def build_mbid_index(self) -> dict[str, str]: + ... + + async def search_items( + self, + query: str, + item_types: str = "MusicAlbum,Audio,MusicArtist", + ) -> list[JellyfinItem]: + ... + + async def get_library_stats(self, ttl_seconds: int = 600) -> dict[str, Any]: + ... + + async def get_playback_url(self, item_id: str) -> PlaybackUrlResult: + ... + + async def get_playback_info(self, item_id: str) -> dict[str, Any]: + ... + + async def report_playback_start( + self, item_id: str, play_session_id: str, play_method: str = "Transcode" + ) -> None: + ... + + async def report_playback_progress( + self, + item_id: str, + play_session_id: str, + position_ticks: int, + is_paused: bool, + ) -> None: + ... + + async def report_playback_stopped( + self, item_id: str, play_session_id: str, position_ticks: int + ) -> None: + ... diff --git a/backend/repositories/protocols/lastfm.py b/backend/repositories/protocols/lastfm.py new file mode 100644 index 0000000..81228c9 --- /dev/null +++ b/backend/repositories/protocols/lastfm.py @@ -0,0 +1,132 @@ +from typing import Protocol + +from repositories.lastfm_models import ( + LastFmAlbum, + LastFmAlbumInfo, + LastFmArtist, + LastFmArtistInfo, + LastFmLovedTrack, + LastFmRecentTrack, + LastFmSession, + LastFmSimilarArtist, + LastFmToken, + LastFmTrack, +) + + +class LastFmRepositoryProtocol(Protocol): + + def configure(self, api_key: str, shared_secret: str, session_key: str = "") -> None: + ... + + @staticmethod + def reset_circuit_breaker() -> None: + ... + + async def get_token(self) -> LastFmToken: + ... + + async def get_session(self, token: str) -> LastFmSession: + ... + + async def validate_api_key(self) -> tuple[bool, str]: + ... + + async def validate_session(self) -> tuple[bool, str]: + ... + + async def update_now_playing( + self, + artist: str, + track: str, + album: str = "", + duration: int = 0, + mbid: str | None = None, + ) -> bool: + ... + + async def scrobble( + self, + artist: str, + track: str, + timestamp: int, + album: str = "", + duration: int = 0, + mbid: str | None = None, + ) -> bool: + ... + + async def get_user_top_artists( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmArtist]: + ... + + async def get_user_top_albums( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmAlbum]: + ... + + async def get_user_top_tracks( + self, username: str, period: str = "overall", limit: int = 50 + ) -> list[LastFmTrack]: + ... + + async def get_user_recent_tracks( + self, username: str, limit: int = 50 + ) -> list[LastFmRecentTrack]: + ... + + async def get_user_loved_tracks( + self, username: str, limit: int = 50 + ) -> list[LastFmLovedTrack]: + ... + + async def get_user_weekly_artist_chart( + self, username: str + ) -> list[LastFmArtist]: + ... + + async def get_user_weekly_album_chart( + self, username: str + ) -> list[LastFmAlbum]: + ... + + async def get_artist_top_tracks( + self, artist: str, mbid: str | None = None, limit: int = 10 + ) -> list[LastFmTrack]: + ... + + async def get_artist_top_albums( + self, artist: str, mbid: str | None = None, limit: int = 10 + ) -> list[LastFmAlbum]: + ... + + async def get_artist_info( + self, artist: str, mbid: str | None = None, username: str | None = None + ) -> LastFmArtistInfo | None: + ... + + async def get_album_info( + self, + artist: str, + album: str, + mbid: str | None = None, + username: str | None = None, + ) -> LastFmAlbumInfo | None: + ... + + async def get_similar_artists( + self, artist: str, mbid: str | None = None, limit: int = 30 + ) -> list[LastFmSimilarArtist]: + ... + + async def get_global_top_artists(self, limit: int = 50) -> list[LastFmArtist]: + ... + + async def get_global_top_tracks(self, limit: int = 50) -> list[LastFmTrack]: + ... + + async def get_tag_top_artists( + self, tag: str, limit: int = 50 + ) -> list[LastFmArtist]: + ... diff --git a/backend/repositories/protocols/lidarr.py b/backend/repositories/protocols/lidarr.py new file mode 100644 index 0000000..b859a0a --- /dev/null +++ b/backend/repositories/protocols/lidarr.py @@ -0,0 +1,89 @@ +from typing import Any, Protocol + +from models.library import LibraryAlbum +from models.request import QueueItem +from models.common import ServiceStatus + + +class LidarrRepositoryProtocol(Protocol): + + def is_configured(self) -> bool: + ... + + async def get_library_albums(self) -> list[LibraryAlbum]: + ... + + async def get_library_album_mbids(self) -> set[str]: + ... + + async def get_library_artist_mbids(self) -> set[str]: + ... + + async def add_album(self, album_mbid: str) -> dict[str, Any]: + ... + + async def get_queue(self) -> list[QueueItem]: + ... + + async def check_status(self) -> ServiceStatus: + ... + + async def get_artist_details(self, artist_mbid: str) -> dict[str, Any] | None: + ... + + async def get_album_details(self, album_mbid: str) -> dict[str, Any] | None: + ... + + async def get_album_tracks(self, album_id: int) -> list[dict[str, Any]]: + ... + + async def get_artist_albums(self, artist_mbid: str) -> list[dict[str, Any]]: + ... + + async def get_artist_mbids(self) -> set[str]: + ... + + async def get_library_mbids(self, include_release_ids: bool = True) -> set[str]: + ... + + async def get_requested_mbids(self) -> set[str]: + ... + + async def delete_album(self, album_id: int, delete_files: bool = False) -> bool: + ... + + async def delete_artist(self, artist_id: int, delete_files: bool = False) -> bool: + ... + + async def get_queue_details( + self, include_artist: bool = True, include_album: bool = True + ) -> list[dict[str, Any]]: + ... + + async def remove_queue_item( + self, queue_id: int, remove_from_client: bool = True + ) -> bool: + ... + + async def trigger_album_search(self, album_ids: list[int]) -> dict[str, Any] | None: + ... + + async def get_history_for_album( + self, + album_id: int, + include_album: bool = True, + include_artist: bool = True, + ) -> list[dict[str, Any]]: + ... + + async def get_track_file(self, track_file_id: int) -> dict[str, Any] | None: + ... + + async def get_track_files_by_album(self, album_id: int) -> list[dict[str, Any]]: + ... + + async def get_all_albums(self) -> list[dict[str, Any]]: + ... + + async def get_recently_imported(self, limit: int = 20) -> list[LibraryAlbum]: + ... diff --git a/backend/repositories/protocols/listenbrainz.py b/backend/repositories/protocols/listenbrainz.py new file mode 100644 index 0000000..996e117 --- /dev/null +++ b/backend/repositories/protocols/listenbrainz.py @@ -0,0 +1,104 @@ +from typing import Any, Protocol + +from repositories.listenbrainz_models import ( + ListenBrainzArtist, + ListenBrainzFeedbackRecording, + ListenBrainzRecording, + ListenBrainzReleaseGroup, + ListenBrainzSimilarArtist, +) + + +class ListenBrainzRepositoryProtocol(Protocol): + + def is_configured(self) -> bool: + ... + + async def get_user_loved_recordings( + self, + username: str | None = None, + count: int = 25, + offset: int = 0, + ) -> list[ListenBrainzFeedbackRecording]: + ... + + async def submit_now_playing( + self, + artist_name: str, + track_name: str, + release_name: str = "", + duration_ms: int = 0, + ) -> bool: + ... + + async def submit_single_listen( + self, + artist_name: str, + track_name: str, + listened_at: int, + release_name: str = "", + duration_ms: int = 0, + ) -> bool: + ... + + async def get_trending_artists( + self, + time_range: str = "this_week", + limit: int = 20, + offset: int = 0 + ) -> list[ListenBrainzArtist]: + ... + + async def get_popular_release_groups( + self, + time_range: str = "this_week", + limit: int = 20, + offset: int = 0 + ) -> list[ListenBrainzReleaseGroup]: + ... + + async def get_fresh_releases( + self, + limit: int = 20 + ) -> list[ListenBrainzReleaseGroup]: + ... + + async def get_similar_artists( + self, + artist_mbid: str, + max_similar: int = 15, + mode: str = "easy" + ) -> list[ListenBrainzSimilarArtist]: + ... + + async def get_artist_top_release_groups( + self, + artist_mbid: str, + count: int = 10 + ) -> list[ListenBrainzReleaseGroup]: + ... + + async def get_artist_top_recordings( + self, + artist_mbid: str, + count: int = 10 + ) -> list[ListenBrainzRecording]: + ... + + async def get_release_group_popularity_batch( + self, + release_group_mbids: list[str] + ) -> dict[str, int]: + ... + + async def get_recommendation_playlists( + self, + username: str | None = None, + ) -> list[dict[str, Any]]: + ... + + async def get_playlist_tracks( + self, + playlist_id: str, + ) -> Any: + ... diff --git a/backend/repositories/protocols/musicbrainz.py b/backend/repositories/protocols/musicbrainz.py new file mode 100644 index 0000000..22a9791 --- /dev/null +++ b/backend/repositories/protocols/musicbrainz.py @@ -0,0 +1,67 @@ +from typing import Any, Protocol + +from models.search import SearchResult +from models.artist import ArtistInfo +from models.album import AlbumInfo + + +class MusicBrainzRepositoryProtocol(Protocol): + + async def search_artists( + self, + query: str, + limit: int = 10, + included_types: set[str] | None = None + ) -> list[SearchResult]: + ... + + async def search_albums( + self, + query: str, + limit: int = 10, + included_types: set[str] | None = None, + included_secondary_types: set[str] | None = None, + included_statuses: set[str] | None = None + ) -> list[SearchResult]: + ... + + async def get_artist_detail( + self, + artist_mbid: str, + included_types: set[str] | None = None, + included_secondary_types: set[str] | None = None, + included_statuses: set[str] | None = None + ) -> ArtistInfo | None: + ... + + async def get_release_group( + self, + release_group_mbid: str + ) -> AlbumInfo | None: + ... + + async def get_release( + self, + release_mbid: str + ) -> Any | None: + ... + + async def get_release_group_id_from_release( + self, + release_mbid: str + ) -> str | None: + ... + + async def get_release_groups_by_artist( + self, + artist_mbid: str, + limit: int = 10 + ) -> list[dict[str, Any]]: + ... + + async def get_recording_position_on_release( + self, + release_id: str, + recording_mbid: str, + ) -> tuple[int, int] | None: + ... diff --git a/backend/repositories/protocols/navidrome.py b/backend/repositories/protocols/navidrome.py new file mode 100644 index 0000000..b6912cb --- /dev/null +++ b/backend/repositories/protocols/navidrome.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from repositories.navidrome_models import ( + SubsonicAlbum, + SubsonicArtist, + SubsonicGenre, + SubsonicPlaylist, + SubsonicSearchResult, + SubsonicSong, +) + +if TYPE_CHECKING: + from repositories.navidrome_models import StreamProxyResult + + +class NavidromeRepositoryProtocol(Protocol): + + def is_configured(self) -> bool: + ... + + def configure(self, url: str, username: str, password: str) -> None: + ... + + async def ping(self) -> bool: + ... + + async def get_album_list( + self, type: str, size: int = 20, offset: int = 0, genre: str | None = None + ) -> list[SubsonicAlbum]: + ... + + async def get_album(self, id: str) -> SubsonicAlbum: + ... + + async def get_artists(self) -> list[SubsonicArtist]: + ... + + async def get_artist(self, id: str) -> SubsonicArtist: + ... + + async def get_song(self, id: str) -> SubsonicSong: + ... + + async def search( + self, + query: str, + artist_count: int = 20, + album_count: int = 20, + song_count: int = 20, + ) -> SubsonicSearchResult: + ... + + async def get_starred(self) -> SubsonicSearchResult: + ... + + async def get_genres(self) -> list[SubsonicGenre]: + ... + + async def get_playlists(self) -> list[SubsonicPlaylist]: + ... + + async def get_playlist(self, id: str) -> SubsonicPlaylist: + ... + + async def get_random_songs( + self, size: int = 20, genre: str | None = None + ) -> list[SubsonicSong]: + ... + + async def scrobble(self, id: str, time_ms: int | None = None) -> bool: + ... + + async def validate_connection(self) -> tuple[bool, str]: + ... + + async def clear_cache(self) -> None: + ... + + def build_stream_url(self, song_id: str) -> str: + ... + + async def proxy_head_stream(self, song_id: str) -> StreamProxyResult: + ... + + async def proxy_get_stream( + self, song_id: str, range_header: str | None = None + ) -> StreamProxyResult: + ... + + async def now_playing(self, id: str) -> bool: + ... diff --git a/backend/repositories/protocols/wikidata.py b/backend/repositories/protocols/wikidata.py new file mode 100644 index 0000000..5fbd98a --- /dev/null +++ b/backend/repositories/protocols/wikidata.py @@ -0,0 +1,10 @@ +from typing import Protocol + + +class WikidataRepositoryProtocol(Protocol): + + async def get_artist_bio(self, artist_mbid: str) -> str | None: + ... + + async def get_artist_image(self, artist_mbid: str) -> str | None: + ... diff --git a/backend/repositories/protocols/youtube.py b/backend/repositories/protocols/youtube.py new file mode 100644 index 0000000..338160c --- /dev/null +++ b/backend/repositories/protocols/youtube.py @@ -0,0 +1,26 @@ +from typing import Protocol + +from models.youtube import YouTubeQuotaResponse + + +class YouTubeRepositoryProtocol(Protocol): + + def configure(self, api_key: str) -> None: + ... + + @property + def is_configured(self) -> bool: + ... + + @property + def quota_remaining(self) -> int: + ... + + async def search_video(self, artist: str, album: str) -> str | None: + ... + + async def search_track(self, artist: str, track_name: str) -> str | None: + ... + + def get_quota_status(self) -> YouTubeQuotaResponse: + ... diff --git a/backend/repositories/wikidata_repository.py b/backend/repositories/wikidata_repository.py new file mode 100644 index 0000000..55c4937 --- /dev/null +++ b/backend/repositories/wikidata_repository.py @@ -0,0 +1,272 @@ +import httpx +import logging +import msgspec +import re +from typing import TypeVar +from urllib.parse import quote +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.cache_keys import ( + wikipedia_extract_key, + wikidata_artist_image_key, +) +from infrastructure.resilience.retry import with_retry, CircuitBreaker +from infrastructure.degradation import try_get_degradation_context +from infrastructure.integration_result import IntegrationResult + +logger = logging.getLogger(__name__) + +_SOURCE = "wikidata" + + +def _record_degradation(msg: str) -> None: + ctx = try_get_degradation_context() + if ctx is not None: + ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg)) + +T = TypeVar("T") + + +class _WikidataSiteLink(msgspec.Struct): + title: str | None = None + + +class _WikidataValue(msgspec.Struct): + value: str | None = None + + +class _WikidataSnak(msgspec.Struct): + datavalue: _WikidataValue | None = None + + +class _WikidataClaim(msgspec.Struct): + mainsnak: _WikidataSnak | None = None + + +class _WikidataEntity(msgspec.Struct): + sitelinks: dict[str, _WikidataSiteLink] = {} + + +class _WikidataEntityResponse(msgspec.Struct): + entities: dict[str, _WikidataEntity] = {} + + +class _WikidataClaimsResponse(msgspec.Struct): + claims: dict[str, list[_WikidataClaim]] = {} + + +class _WikipediaPage(msgspec.Struct): + pageid: int | None = None + extract: str | None = None + + +class _WikipediaQuery(msgspec.Struct): + pages: dict[str, _WikipediaPage] = {} + + +class _WikipediaQueryResponse(msgspec.Struct): + query: _WikipediaQuery | None = None + + +class _CommonsImageInfo(msgspec.Struct): + url: str | None = None + + +class _CommonsPage(msgspec.Struct): + imageinfo: list[_CommonsImageInfo] = [] + + +class _CommonsQuery(msgspec.Struct): + pages: dict[str, _CommonsPage] = {} + + +class _CommonsQueryResponse(msgspec.Struct): + query: _CommonsQuery | None = None + + +def _decode_json_response(response: httpx.Response, decode_type: type[T]) -> T: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=decode_type) + return msgspec.convert(response.json(), type=decode_type) + +_wikidata_circuit_breaker = CircuitBreaker( + failure_threshold=5, + success_threshold=2, + timeout=60.0, + name="wikidata" +) + + +class WikidataRepository: + def __init__(self, http_client: httpx.AsyncClient, cache: CacheInterface): + self._client = http_client + self._cache = cache + + @staticmethod + def _extract_wikidata_id(url: str) -> str | None: + match = re.search(r'/wiki/(Q\d+)', url) + return match.group(1) if match else None + + @staticmethod + def _extract_wikipedia_title(url: str) -> str | None: + match = re.search(r'/wiki/(.+)$', url) + return match.group(1) if match else None + + @with_retry( + max_attempts=3, + base_delay=0.5, + max_delay=3.0, + circuit_breaker=_wikidata_circuit_breaker, + retriable_exceptions=(httpx.HTTPError,) + ) + async def _get_wikipedia_title_from_wikidata( + self, + wikidata_id: str, + lang: str = "en" + ) -> str | None: + try: + api_url = f"https://www.wikidata.org/wiki/Special:EntityData/{wikidata_id}.json" + response = await self._client.get(api_url) + + if response.status_code != 200: + return None + + data = _decode_json_response(response, _WikidataEntityResponse) + entity = data.entities.get(wikidata_id) + if entity is None: + return None + wiki_data = entity.sitelinks.get(f"{lang}wiki") + return wiki_data.title if wiki_data else None + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get Wikipedia title for {wikidata_id}: {e}") + _record_degradation(f"Failed to get Wikipedia title for {wikidata_id}: {e}") + return None + + @with_retry( + max_attempts=3, + base_delay=0.5, + max_delay=3.0, + circuit_breaker=_wikidata_circuit_breaker, + retriable_exceptions=(httpx.HTTPError,) + ) + async def _fetch_wikipedia_extract(self, page_title: str, lang: str = "en") -> str | None: + try: + api_url = ( + f"https://{lang}.wikipedia.org/w/api.php" + f"?action=query&titles={quote(page_title)}" + f"&prop=extracts&exintro=1&explaintext=1&format=json" + ) + + response = await self._client.get(api_url) + if response.status_code != 200: + return None + + data = _decode_json_response(response, _WikipediaQueryResponse) + pages = data.query.pages if data.query else {} + + for page_data in pages.values(): + if (page_data.pageid or -1) < 0: + return None + + if extract := page_data.extract: + return extract + + return None + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch Wikipedia extract: {e}") + _record_degradation(f"Failed to fetch Wikipedia extract: {e}") + return None + + async def get_wikipedia_extract(self, wikipedia_url: str, lang: str = "en") -> str | None: + cache_key = wikipedia_extract_key(wikipedia_url) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + if wikidata_id := self._extract_wikidata_id(wikipedia_url): + page_title = await self._get_wikipedia_title_from_wikidata(wikidata_id, lang) + if not page_title: + return None + + elif page_title := self._extract_wikipedia_title(wikipedia_url): + pass + + else: + return None + + extract = await self._fetch_wikipedia_extract(page_title, lang) + + if extract: + await self._cache.set(cache_key, extract, ttl_seconds=604800) + + return extract + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get Wikipedia extract from {wikipedia_url}: {e}") + _record_degradation(f"Failed to get Wikipedia extract: {e}") + return None + + def get_wikidata_id_from_url(self, wikidata_url: str) -> str | None: + return self._extract_wikidata_id(wikidata_url) + + async def get_artist_image_from_wikidata(self, wikidata_id: str) -> str | None: + cache_key = wikidata_artist_image_key(wikidata_id) + + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + + try: + api_url = ( + f"https://www.wikidata.org/w/api.php" + f"?action=wbgetclaims&entity={wikidata_id}&property=P18&format=json" + ) + response = await self._client.get(api_url) + + if response.status_code != 200: + return None + + data = _decode_json_response(response, _WikidataClaimsResponse) + image_claims = data.claims.get("P18", []) + if not image_claims: + return None + + first_claim = image_claims[0] + image_filename = ( + first_claim.mainsnak.datavalue.value + if first_claim.mainsnak and first_claim.mainsnak.datavalue + else None + ) + if not image_filename: + return None + + commons_url = ( + f"https://commons.wikimedia.org/w/api.php" + f"?action=query&titles=File:{quote(image_filename)}" + f"&prop=imageinfo&iiprop=url&format=json" + ) + + response = await self._client.get(commons_url) + if response.status_code != 200: + return None + + commons_data = _decode_json_response(response, _CommonsQueryResponse) + pages = commons_data.query.pages if commons_data.query else {} + + for page_data in pages.values(): + if page_data.imageinfo: + image_url = page_data.imageinfo[0].url + if image_url: + await self._cache.set(cache_key, image_url, ttl_seconds=86400) + return image_url + + return None + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get image for Wikidata {wikidata_id}: {e}") + _record_degradation(f"Failed to get Wikidata artist image: {e}") + return None diff --git a/backend/repositories/youtube.py b/backend/repositories/youtube.py new file mode 100644 index 0000000..236063f --- /dev/null +++ b/backend/repositories/youtube.py @@ -0,0 +1,242 @@ +import logging +from datetime import datetime, timezone +from pathlib import Path +from collections import OrderedDict + +import httpx +import msgspec +from models.youtube import YouTubeQuotaResponse + +logger = logging.getLogger(__name__) + +YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search" +DEFAULT_DAILY_QUOTA_LIMIT = 80 +SEARCH_COST = 100 +PREVIEW_CACHE_MAX = 100 +QUOTA_FILE = Path("/app/cache/youtube_quota.json") + + +class YouTubeQuotaState(msgspec.Struct): + date: str = "" + count: int = 0 + + +class _YouTubeSearchId(msgspec.Struct): + videoId: str | None = None + + +class _YouTubeSearchItem(msgspec.Struct): + id: _YouTubeSearchId | None = None + + +class _YouTubeSearchResponse(msgspec.Struct): + items: list[_YouTubeSearchItem] = [] + + +def _decode_json_response(response: httpx.Response, decode_type: type[_YouTubeSearchResponse]) -> _YouTubeSearchResponse: + content = getattr(response, "content", None) + if isinstance(content, (bytes, bytearray, memoryview)): + return msgspec.json.decode(content, type=decode_type) + return msgspec.convert(response.json(), type=decode_type) + + +class YouTubeRepository: + def __init__( + self, + http_client: httpx.AsyncClient, + api_key: str = "", + daily_quota_limit: int = DEFAULT_DAILY_QUOTA_LIMIT, + ): + self._http_client = http_client + self._api_key = api_key + self._daily_quota_limit = daily_quota_limit + self._cache: OrderedDict[str, str | None] = OrderedDict() + self._daily_count = 0 + self._quota_date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + self._load_quota() + + def _load_quota(self) -> None: + try: + if QUOTA_FILE.exists(): + data = msgspec.json.decode(QUOTA_FILE.read_bytes(), type=YouTubeQuotaState) + saved_date = data.date + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if saved_date == today: + self._daily_count = data.count + self._quota_date = saved_date + else: + self._daily_count = 0 + self._quota_date = today + self._save_quota() + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to load YouTube quota state: {e}") + + def _save_quota(self) -> None: + try: + QUOTA_FILE.parent.mkdir(parents=True, exist_ok=True) + QUOTA_FILE.write_bytes(msgspec.json.encode(YouTubeQuotaState(date=self._quota_date, count=self._daily_count))) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to save YouTube quota state: {e}") + + def configure(self, api_key: str) -> None: + self._api_key = api_key + + @property + def is_configured(self) -> bool: + return bool(self._api_key) + + def _check_and_reset_quota(self) -> None: + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if today != self._quota_date: + self._daily_count = 0 + self._quota_date = today + self._save_quota() + + @property + def quota_remaining(self) -> int: + self._check_and_reset_quota() + return max(0, self._daily_quota_limit - self._daily_count) + + def get_quota_status(self) -> YouTubeQuotaResponse: + self._check_and_reset_quota() + return YouTubeQuotaResponse( + used=self._daily_count, + limit=self._daily_quota_limit, + remaining=max(0, self._daily_quota_limit - self._daily_count), + date=self._quota_date, + ) + + def _cache_put(self, key: str, value: str | None) -> None: + if key in self._cache: + self._cache.move_to_end(key) + else: + if len(self._cache) >= PREVIEW_CACHE_MAX: + self._cache.popitem(last=False) + self._cache[key] = value + + def is_cached(self, artist: str, album: str) -> bool: + cache_key = f"{artist.lower()}|{album.lower()}" + return cache_key in self._cache + + def are_cached(self, pairs: list[tuple[str, str]]) -> dict[str, bool]: + result: dict[str, bool] = {} + for artist, track in pairs: + cache_key = f"{artist.lower()}|{track.lower()}" + result[cache_key] = cache_key in self._cache + return result + + async def search_video(self, artist: str, album: str) -> str | None: + if not self._api_key: + return None + + cache_key = f"{artist.lower()}|{album.lower()}" + if cache_key in self._cache: + return self._cache[cache_key] + + self._check_and_reset_quota() + if self._daily_count >= self._daily_quota_limit: + logger.warning("YouTube API daily quota exceeded") + return None + + query = f"{artist} {album} full album" + + try: + response = await self._http_client.get( + YOUTUBE_SEARCH_URL, + params={ + "part": "id", + "type": "video", + "maxResults": 1, + "q": query, + "key": self._api_key, + }, + timeout=10.0, + ) + self._daily_count += 1 + self._save_quota() + + if response.status_code == 403: + logger.error("YouTube API key invalid or quota exceeded upstream") + return None + + response.raise_for_status() + data = _decode_json_response(response, _YouTubeSearchResponse) + + if data.items: + video_id = data.items[0].id.videoId if data.items[0].id else None + self._cache_put(cache_key, video_id) + return video_id + + self._cache_put(cache_key, None) + return None + except Exception as e: # noqa: BLE001 + logger.error(f"YouTube search failed for '{query}': {e}") + return None + + async def search_track(self, artist: str, track_name: str) -> str | None: + if not self._api_key: + return None + + cache_key = f"{artist.lower()}|{track_name.lower()}" + if cache_key in self._cache: + return self._cache[cache_key] + + self._check_and_reset_quota() + if self._daily_count >= self._daily_quota_limit: + logger.warning("YouTube API daily quota exceeded") + return None + + query = f"{artist} {track_name}" + + try: + response = await self._http_client.get( + YOUTUBE_SEARCH_URL, + params={ + "part": "id", + "type": "video", + "maxResults": 1, + "q": query, + "key": self._api_key, + }, + timeout=10.0, + ) + self._daily_count += 1 + self._save_quota() + + if response.status_code == 403: + logger.error("YouTube API key invalid or quota exceeded upstream") + return None + + response.raise_for_status() + data = _decode_json_response(response, _YouTubeSearchResponse) + + if data.items: + video_id = data.items[0].id.videoId if data.items[0].id else None + self._cache_put(cache_key, video_id) + return video_id + + self._cache_put(cache_key, None) + return None + except Exception as e: # noqa: BLE001 + logger.error(f"YouTube track search failed for '{query}': {e}") + return None + + async def verify_api_key(self, api_key: str) -> tuple[bool, str]: + try: + response = await self._http_client.get( + "https://www.googleapis.com/youtube/v3/videos", + params={ + "part": "id", + "id": "dQw4w9WgXcQ", + "key": api_key, + }, + timeout=10.0, + ) + if response.status_code == 200: + return True, "YouTube API key is valid" + elif response.status_code == 403: + return False, "API key is invalid or YouTube Data API is not enabled" + else: + return False, f"Unexpected response: {response.status_code}" + except Exception as e: # noqa: BLE001 + return False, f"Connection error: {e}" diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..a99f5f6 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt +mypy==1.18.2 +ruff==0.6.9 +types-PyYAML==6.0.12.20250915 +watchfiles==1.1.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..dd927b2 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +aiofiles==24.1.0 +fastapi==0.118.3 +h2>=4.1,<5 +httpx[h2]==0.28.1 +msgspec==0.20.0 +python-multipart==0.0.20 +pydantic==2.12.0 +pydantic-settings==2.3.0 +uvicorn[standard]==0.37.0 diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/album_discovery_service.py b/backend/services/album_discovery_service.py new file mode 100644 index 0000000..e93d1d3 --- /dev/null +++ b/backend/services/album_discovery_service.py @@ -0,0 +1,143 @@ +import asyncio +import logging +from api.v1.schemas.discovery import ( + DiscoveryAlbum, + SimilarAlbumsResponse, + MoreByArtistResponse, +) +from repositories.protocols import ListenBrainzRepositoryProtocol, MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol +from infrastructure.persistence import LibraryDB + +logger = logging.getLogger(__name__) + + +class AlbumDiscoveryService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + library_db: LibraryDB, + lidarr_repo: LidarrRepositoryProtocol, + ): + self._lb_repo = listenbrainz_repo + self._mb_repo = musicbrainz_repo + self._library_db = library_db + self._lidarr_repo = lidarr_repo + + async def get_similar_albums( + self, + album_mbid: str, + artist_mbid: str, + count: int = 10 + ) -> SimilarAlbumsResponse: + if not self._lb_repo.is_configured(): + return SimilarAlbumsResponse(configured=False) + + try: + similar_artists = await self._lb_repo.get_similar_artists(artist_mbid, max_similar=5) + if not similar_artists: + return SimilarAlbumsResponse(albums=[]) + + try: + library_album_mbids, requested_album_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(), + self._lidarr_repo.get_requested_mbids() + ) + except Exception: # noqa: BLE001 + library_album_mbids = set() + requested_album_mbids = set() + + tasks = [ + self._lb_repo.get_artist_top_release_groups(a.artist_mbid, count=3) + for a in similar_artists[:5] + ] + results = await asyncio.gather(*tasks, return_exceptions=True) + + albums: list[DiscoveryAlbum] = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + continue + artist = similar_artists[i] + for rg in result: + if rg.release_group_mbid and rg.release_group_mbid != album_mbid: + mbid_lower = rg.release_group_mbid.lower() + albums.append(DiscoveryAlbum( + musicbrainz_id=rg.release_group_mbid, + title=rg.release_group_name, + artist_name=artist.artist_name, + artist_id=artist.artist_mbid, + in_library=mbid_lower in library_album_mbids, + requested=mbid_lower in requested_album_mbids, + )) + if len(albums) >= count: + break + if len(albums) >= count: + break + + return SimilarAlbumsResponse(albums=albums[:count]) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get similar albums for {album_mbid}: {e}") + return SimilarAlbumsResponse(albums=[]) + + async def get_more_by_artist( + self, + artist_mbid: str, + exclude_album_mbid: str, + count: int = 10 + ) -> MoreByArtistResponse: + try: + release_groups = await self._mb_repo.get_release_groups_by_artist( + artist_mbid, + limit=count + 5 + ) + if not release_groups: + return MoreByArtistResponse(albums=[], artist_name="") + + try: + library_album_mbids, requested_album_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(), + self._lidarr_repo.get_requested_mbids() + ) + except Exception: # noqa: BLE001 + library_album_mbids = set() + requested_album_mbids = set() + + albums: list[DiscoveryAlbum] = [] + artist_name = "" + + for rg in release_groups: + rg_mbid = rg.get("id", "") + if rg_mbid == exclude_album_mbid: + continue + + if not artist_name: + artist_credit = rg.get("artist-credit", []) + if artist_credit: + artist_name = artist_credit[0].get("artist", {}).get("name", "") + + year = None + first_release = rg.get("first-release-date", "") + if first_release and len(first_release) >= 4: + try: + year = int(first_release[:4]) + except ValueError: + pass + + mbid_lower = rg_mbid.lower() + albums.append(DiscoveryAlbum( + musicbrainz_id=rg_mbid, + title=rg.get("title", "Unknown"), + artist_name=artist_name, + artist_id=artist_mbid, + year=year, + in_library=mbid_lower in library_album_mbids, + requested=mbid_lower in requested_album_mbids, + )) + + if len(albums) >= count: + break + + return MoreByArtistResponse(albums=albums, artist_name=artist_name) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get more albums by artist {artist_mbid}: {e}") + return MoreByArtistResponse(albums=[], artist_name="") diff --git a/backend/services/album_enrichment_service.py b/backend/services/album_enrichment_service.py new file mode 100644 index 0000000..aa47e41 --- /dev/null +++ b/backend/services/album_enrichment_service.py @@ -0,0 +1,56 @@ +import logging +from typing import Optional + +from api.v1.schemas.album import LastFmAlbumEnrichment +from api.v1.schemas.common import LastFmTagSchema +from infrastructure.validators import clean_lastfm_bio +from repositories.protocols import LastFmRepositoryProtocol +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + + +class AlbumEnrichmentService: + def __init__( + self, + lastfm_repo: LastFmRepositoryProtocol, + preferences_service: PreferencesService, + ): + self._lastfm_repo = lastfm_repo + self._preferences_service = preferences_service + + async def get_lastfm_enrichment( + self, + artist_name: str, + album_name: str, + album_mbid: Optional[str] = None, + ) -> Optional[LastFmAlbumEnrichment]: + if not self._preferences_service.is_lastfm_enabled(): + return None + + try: + info = await self._lastfm_repo.get_album_info( + artist=artist_name, album=album_name, mbid=album_mbid + ) + if info is None: + return None + + tags = [ + LastFmTagSchema(name=t.name, url=t.url or None) + for t in (info.tags or []) + ] + + return LastFmAlbumEnrichment( + summary=clean_lastfm_bio(info.summary) or None, + tags=tags, + listeners=info.listeners, + playcount=info.playcount, + url=info.url or None, + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "Failed to fetch Last.fm enrichment for album %s: %s", + album_name, + e, + ) + return None diff --git a/backend/services/album_service.py b/backend/services/album_service.py new file mode 100644 index 0000000..1aa8a13 --- /dev/null +++ b/backend/services/album_service.py @@ -0,0 +1,597 @@ +import logging +import asyncio +import time +from typing import Optional, TYPE_CHECKING +import msgspec +from api.v1.schemas.album import AlbumInfo, AlbumBasicInfo, AlbumTracksInfo, Track +from repositories.protocols import LidarrRepositoryProtocol, MusicBrainzRepositoryProtocol +from services.preferences_service import PreferencesService +from services.album_utils import parse_year, find_primary_release, get_ranked_releases, extract_artist_info, extract_tracks, extract_label, build_album_basic_info, lidarr_to_basic_info, mb_to_basic_info +from infrastructure.persistence import LibraryDB +from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.validators import validate_mbid +from core.exceptions import ResourceNotFoundError +from services.audiodb_image_service import AudioDBImageService +from repositories.audiodb_models import AudioDBAlbumImages + +if TYPE_CHECKING: + from services.audiodb_browse_queue import AudioDBBrowseQueue + +logger = logging.getLogger(__name__) + + +class AlbumService: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + mb_repo: MusicBrainzRepositoryProtocol, + library_db: LibraryDB, + memory_cache: CacheInterface, + disk_cache: DiskMetadataCache, + preferences_service: PreferencesService, + audiodb_image_service: AudioDBImageService | None = None, + audiodb_browse_queue: 'AudioDBBrowseQueue | None' = None, + ): + self._lidarr_repo = lidarr_repo + self._mb_repo = mb_repo + self._library_db = library_db + self._cache = memory_cache + self._disk_cache = disk_cache + self._preferences_service = preferences_service + self._audiodb_image_service = audiodb_image_service + self._audiodb_browse_queue = audiodb_browse_queue + self._revalidation_timestamps: dict[str, float] = {} + self._album_in_flight: dict[str, asyncio.Future[AlbumInfo]] = {} + + async def _get_audiodb_album_thumb(self, release_group_id: str, artist_name: str | None = None, album_name: str | None = None, *, allow_fetch: bool = False) -> str | None: + if self._audiodb_image_service is None: + return None + try: + if allow_fetch: + images = await self._audiodb_image_service.fetch_and_cache_album_images( + release_group_id, artist_name, album_name, is_monitored=False, + ) + else: + images = await self._audiodb_image_service.get_cached_album_images(release_group_id) + if images and not images.is_negative: + return images.album_thumb_url + if not allow_fetch and images is None and self._audiodb_browse_queue: + settings = self._preferences_service.get_advanced_settings() + if settings.audiodb_enabled: + await self._audiodb_browse_queue.enqueue( + "album", release_group_id, + name=album_name, artist_name=artist_name, + ) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get AudioDB album thumb for %s: %s", release_group_id[:8], e) + return None + + async def _apply_audiodb_album_images( + self, + album_info: AlbumInfo, + release_group_mbid: str, + artist_name: str | None, + album_name: str | None, + *, + allow_fetch: bool = False, + is_monitored: bool = False, + ) -> AlbumInfo: + if self._audiodb_image_service is None: + return album_info + try: + images: AudioDBAlbumImages | None + if allow_fetch: + images = await self._audiodb_image_service.fetch_and_cache_album_images( + release_group_mbid, artist_name, album_name, is_monitored=is_monitored, + ) + else: + images = await self._audiodb_image_service.get_cached_album_images(release_group_mbid) + if images is None or images.is_negative: + if not allow_fetch and images is None and self._audiodb_browse_queue: + settings = self._preferences_service.get_advanced_settings() + if settings.audiodb_enabled: + await self._audiodb_browse_queue.enqueue( + "album", release_group_mbid, + name=album_name, artist_name=artist_name, + ) + return album_info + album_info.album_thumb_url = images.album_thumb_url + album_info.album_back_url = images.album_back_url + album_info.album_cdart_url = images.album_cdart_url + album_info.album_spine_url = images.album_spine_url + album_info.album_3d_case_url = images.album_3d_case_url + album_info.album_3d_flat_url = images.album_3d_flat_url + album_info.album_3d_face_url = images.album_3d_face_url + album_info.album_3d_thumb_url = images.album_3d_thumb_url + except Exception as e: # noqa: BLE001 + logger.warning("Failed to apply AudioDB images for album %s: %s", release_group_mbid[:8], e) + return album_info + + async def is_album_cached(self, release_group_id: str) -> bool: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + return await self._cache.get(cache_key) is not None + + async def _get_queued_mbids(self) -> set[str]: + try: + queue_items = await self._lidarr_repo.get_queue() + return {item.musicbrainz_id.lower() for item in queue_items if item.musicbrainz_id} + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to fetch queue: {e}") + return set() + + async def _get_cached_album_info(self, release_group_id: str, cache_key: str) -> Optional[AlbumInfo]: + cached_info = await self._cache.get(cache_key) + if cached_info: + logger.info(f"Cache HIT (RAM): Album {release_group_id[:8]}... - instant load") + return await self._revalidate_library_status(release_group_id, cached_info) + + logger.debug(f"Cache MISS (RAM): Album {release_group_id[:8]}...") + + disk_data = await self._disk_cache.get_album(release_group_id) + if disk_data: + logger.info(f"Cache HIT (Disk): Album {release_group_id[:8]}... - loading from persistent cache") + album_info = msgspec.convert(disk_data, AlbumInfo, strict=False) + album_info = await self._revalidate_library_status(release_group_id, album_info) + advanced_settings = self._preferences_service.get_advanced_settings() + ttl = advanced_settings.cache_ttl_album_library if album_info.in_library else advanced_settings.cache_ttl_album_non_library + await self._cache.set(cache_key, album_info, ttl_seconds=ttl) + return album_info + + logger.debug(f"Cache MISS (Disk): Album {release_group_id[:8]}...") + return None + + async def _revalidate_library_status(self, release_group_id: str, album_info: AlbumInfo) -> AlbumInfo: + _REVALIDATION_COOLDOWN = 60 + if not self._lidarr_repo.is_configured(): + return album_info + now = time.monotonic() + last = self._revalidation_timestamps.get(release_group_id, 0.0) + if now - last < _REVALIDATION_COOLDOWN: + return album_info + + lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) + if lidarr_album is None: + return album_info + + self._revalidation_timestamps[release_group_id] = time.monotonic() + current_in_library = self._check_lidarr_in_library(lidarr_album) + if current_in_library != album_info.in_library: + logger.info( + f"Library status changed for album {release_group_id[:8]}...: " + f"{album_info.in_library} -> {current_in_library}, updating caches" + ) + album_info.in_library = current_in_library + await self._save_album_to_cache(release_group_id, album_info) + return album_info + + async def _save_album_to_cache(self, release_group_id: str, album_info: AlbumInfo) -> None: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + advanced_settings = self._preferences_service.get_advanced_settings() + ttl = advanced_settings.cache_ttl_album_library if album_info.in_library else advanced_settings.cache_ttl_album_non_library + await self._cache.set(cache_key, album_info, ttl_seconds=ttl) + await self._disk_cache.set_album(release_group_id, album_info, is_monitored=album_info.in_library, ttl_seconds=ttl if not album_info.in_library else None) + logger.info(f"Cached {'library' if album_info.in_library else 'non-library'} album {release_group_id[:8]}... for {ttl // 3600}h") + + def _check_lidarr_in_library(self, lidarr_album: dict | None) -> bool: + if lidarr_album and lidarr_album.get("monitored", False): + statistics = lidarr_album.get("statistics", {}) + return statistics.get("trackFileCount", 0) > 0 + return False + + async def warm_full_album_cache(self, release_group_id: str) -> None: + """Fire-and-forget: populate the full album_info cache if missing.""" + try: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + if await self._get_cached_album_info(release_group_id, cache_key): + return + await self.get_album_info(release_group_id) + except Exception: # noqa: BLE001 + logger.debug(f"Background album cache warm failed for {release_group_id[:8]}") + + async def get_album_info(self, release_group_id: str, monitored_mbids: set[str] = None) -> AlbumInfo: + try: + release_group_id = validate_mbid(release_group_id, "album") + except ValueError as e: + logger.error(f"Invalid album MBID: {e}") + raise + try: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + cached = await self._get_cached_album_info(release_group_id, cache_key) + if cached: + cached = await self._apply_audiodb_album_images( + cached, release_group_id, cached.artist_name, cached.title, + allow_fetch=True, is_monitored=cached.in_library, + ) + return cached + + if release_group_id in self._album_in_flight: + return await asyncio.shield(self._album_in_flight[release_group_id]) + + loop = asyncio.get_running_loop() + future: asyncio.Future[AlbumInfo] = loop.create_future() + self._album_in_flight[release_group_id] = future + try: + album_info = await self._do_get_album_info(release_group_id, cache_key, monitored_mbids) + if not future.done(): + future.set_result(album_info) + return album_info + except BaseException as exc: + if not future.done(): + future.set_exception(exc) + raise + finally: + self._album_in_flight.pop(release_group_id, None) + except ValueError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"API call failed for album {release_group_id}: {e}") + raise ResourceNotFoundError(f"Failed to get album info: {e}") + + async def _do_get_album_info( + self, release_group_id: str, cache_key: str, monitored_mbids: set[str] | None + ) -> AlbumInfo: + lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None + in_library = self._check_lidarr_in_library(lidarr_album) + if in_library and lidarr_album: + logger.info(f"Using Lidarr as primary source for album {release_group_id[:8]}") + album_info = await self._build_album_from_lidarr(release_group_id, lidarr_album) + else: + logger.info(f"Using MusicBrainz as primary source for album {release_group_id[:8]}") + album_info = await self._build_album_from_musicbrainz(release_group_id, monitored_mbids) + album_info = await self._apply_audiodb_album_images( + album_info, release_group_id, album_info.artist_name, album_info.title, + allow_fetch=True, is_monitored=album_info.in_library, + ) + await self._save_album_to_cache(release_group_id, album_info) + return album_info + + async def get_album_basic_info(self, release_group_id: str) -> AlbumBasicInfo: + try: + release_group_id = validate_mbid(release_group_id, "album") + except ValueError as e: + logger.error(f"Invalid album MBID: {e}") + raise + + try: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + + try: + if self._lidarr_repo.is_configured(): + requested_mbids = await self._lidarr_repo.get_requested_mbids() + else: + requested_mbids = set() + except Exception: # noqa: BLE001 + logger.warning("Lidarr unavailable, proceeding without requested data") + requested_mbids = set() + is_requested = release_group_id.lower() in requested_mbids + + cached_album_info = await self._get_cached_album_info(release_group_id, cache_key) + if cached_album_info: + album_thumb = cached_album_info.album_thumb_url + if not album_thumb: + album_thumb = await self._get_audiodb_album_thumb( + release_group_id, cached_album_info.artist_name, cached_album_info.title, + allow_fetch=False, + ) + return AlbumBasicInfo( + title=cached_album_info.title, + musicbrainz_id=cached_album_info.musicbrainz_id, + artist_name=cached_album_info.artist_name, + artist_id=cached_album_info.artist_id, + release_date=cached_album_info.release_date, + year=cached_album_info.year, + type=cached_album_info.type, + disambiguation=cached_album_info.disambiguation, + in_library=cached_album_info.in_library, + requested=is_requested and not cached_album_info.in_library, + cover_url=cached_album_info.cover_url, + album_thumb_url=album_thumb, + ) + + lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None + in_library = self._check_lidarr_in_library(lidarr_album) + if lidarr_album and lidarr_album.get("monitored", False): + logger.info(f"[BASIC] Using Lidarr for album {release_group_id[:8]}") + basic = AlbumBasicInfo(**lidarr_to_basic_info(lidarr_album, release_group_id, in_library)) + if not basic.album_thumb_url: + basic.album_thumb_url = await self._get_audiodb_album_thumb( + release_group_id, basic.artist_name, basic.title, + allow_fetch=False, + ) + return basic + logger.info(f"[BASIC] Using MusicBrainz for album {release_group_id[:8]}") + release_group = await self._fetch_release_group(release_group_id) + if lidarr_album is None: + cached_album = await self._library_db.get_album_by_mbid(release_group_id) + in_library = cached_album is not None + basic = AlbumBasicInfo(**mb_to_basic_info(release_group, release_group_id, in_library, is_requested)) + basic.album_thumb_url = await self._get_audiodb_album_thumb( + release_group_id, basic.artist_name, basic.title, + allow_fetch=False, + ) + return basic + + except ValueError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get basic album info for {release_group_id}: {e}") + raise ResourceNotFoundError(f"Failed to get album info: {e}") + + async def get_album_tracks_info(self, release_group_id: str) -> AlbumTracksInfo: + try: + release_group_id = validate_mbid(release_group_id, "album") + except ValueError as e: + logger.error(f"Invalid album MBID: {e}") + raise + + try: + cache_key = f"{ALBUM_INFO_PREFIX}{release_group_id}" + cached_album_info = await self._get_cached_album_info(release_group_id, cache_key) + if cached_album_info: + return AlbumTracksInfo( + tracks=cached_album_info.tracks, + total_tracks=cached_album_info.total_tracks, + total_length=cached_album_info.total_length, + label=cached_album_info.label, + barcode=cached_album_info.barcode, + country=cached_album_info.country, + ) + + lidarr_album = await self._lidarr_repo.get_album_details(release_group_id) if self._lidarr_repo.is_configured() else None + in_library = lidarr_album is not None and lidarr_album.get("monitored", False) + + if in_library and lidarr_album: + logger.info(f"[TRACKS] Using Lidarr for album {release_group_id[:8]}") + album_id = lidarr_album.get("id") + tracks = [] + total_length = 0 + + if album_id: + lidarr_tracks = await self._lidarr_repo.get_album_tracks(album_id) + for t in lidarr_tracks: + duration_ms = t.get("duration_ms", 0) + if duration_ms: + total_length += duration_ms + tracks.append(Track( + position=int(t.get("track_number") or t.get("position", 0)), + disc_number=int(t.get("disc_number", 1) or 1), + title=t.get("title", "Unknown"), + length=duration_ms if duration_ms else None, + recording_id=None, + )) + + return AlbumTracksInfo( + tracks=tracks, + total_tracks=len(tracks), + total_length=total_length if total_length > 0 else None, + label=None, + barcode=None, + country=None, + ) + + logger.info(f"[TRACKS] Using MusicBrainz for album {release_group_id[:8]}") + release_group = await self._fetch_release_group(release_group_id) + ranked_releases = get_ranked_releases(release_group) + + if not ranked_releases: + return AlbumTracksInfo(tracks=[], total_tracks=0) + + tracks: list[Track] = [] + total_length = 0 + release_data = None + + candidate_ids = [r.get("id") for r in ranked_releases[:3] if r.get("id")] + if candidate_ids: + release_results = await asyncio.gather( + *(self._mb_repo.get_release_by_id(rid, includes=["recordings", "labels"]) for rid in candidate_ids), + return_exceptions=True, + ) + failures = [r for r in release_results if isinstance(r, Exception)] + if failures: + logger.warning(f"Album {release_group_id[:8]}: {len(failures)}/{len(candidate_ids)} release fetches failed") + for result in release_results: + if isinstance(result, Exception) or not result: + continue + found_tracks, found_length = extract_tracks(result) + if found_tracks: + tracks = found_tracks + total_length = found_length + release_data = result + break + + if not release_data: + return AlbumTracksInfo(tracks=[], total_tracks=0) + + label = extract_label(release_data) + + return AlbumTracksInfo( + tracks=tracks, + total_tracks=len(tracks), + total_length=total_length if total_length > 0 else None, + label=label, + barcode=release_data.get("barcode"), + country=release_data.get("country"), + ) + + except ValueError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to get album tracks for {release_group_id}: {e}") + raise ResourceNotFoundError(f"Failed to get album tracks: {e}") + + async def _fetch_release_group(self, release_group_id: str) -> dict: + rg_result = await self._mb_repo.get_release_group_by_id( + release_group_id, + includes=["artists", "releases", "tags"] + ) + + if not rg_result: + raise ResourceNotFoundError(f"Release group {release_group_id} not found") + + return rg_result + + async def _check_in_library(self, release_group_id: str, monitored_mbids: set[str] = None) -> bool: + if monitored_mbids is not None: + return release_group_id.lower() in monitored_mbids + + library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True) + return release_group_id.lower() in library_mbids + + def _build_basic_info( + self, + release_group: dict, + release_group_id: str, + artist_name: str, + artist_id: str, + in_library: bool + ) -> AlbumInfo: + return AlbumInfo(**build_album_basic_info(release_group, release_group_id, artist_name, artist_id, in_library)) + + async def _enrich_with_release_details( + self, + album_info: AlbumInfo, + primary_release: dict + ) -> None: + try: + release_id = primary_release.get("id") + release_data = await self._mb_repo.get_release_by_id( + release_id, + includes=["recordings", "labels"] + ) + + if not release_data: + logger.warning(f"Release {release_id} not found") + return + + tracks, total_length = extract_tracks(release_data) + album_info.tracks = tracks + album_info.total_tracks = len(tracks) + album_info.total_length = total_length if total_length > 0 else None + + album_info.label = extract_label(release_data) + + album_info.barcode = release_data.get("barcode") + album_info.country = release_data.get("country") + + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to enrich with release details: {e}") + + async def _build_album_from_lidarr( + self, + release_group_id: str, + lidarr_album: dict + ) -> AlbumInfo: + album_id = lidarr_album.get("id") + + tracks = [] + total_length = 0 + if album_id: + lidarr_tracks = await self._lidarr_repo.get_album_tracks(album_id) + for t in lidarr_tracks: + duration_ms = t.get("duration_ms", 0) + if duration_ms: + total_length += duration_ms + tracks.append(Track( + position=int(t.get("track_number") or t.get("position", 0)), + disc_number=int(t.get("disc_number", 1) or 1), + title=t.get("title", "Unknown"), + length=duration_ms if duration_ms else None, + recording_id=None, + )) + + label = None + barcode = None + country = None + + if not tracks: + logger.debug(f"No tracks from Lidarr for album {release_group_id[:8]}, falling back to MusicBrainz") + try: + release_group = await self._fetch_release_group(release_group_id) + ranked_releases = get_ranked_releases(release_group) + candidate_ids = [r.get("id") for r in ranked_releases[:3] if r.get("id")] + if candidate_ids: + release_results = await asyncio.gather( + *(self._mb_repo.get_release_by_id(rid, includes=["recordings", "labels"]) for rid in candidate_ids), + return_exceptions=True, + ) + failures = [r for r in release_results if isinstance(r, Exception)] + if failures: + logger.warning(f"Album {release_group_id[:8]} MB fallback: {len(failures)}/{len(candidate_ids)} release fetches failed") + for result in release_results: + if isinstance(result, Exception) or not result: + continue + found_tracks, found_length = extract_tracks(result) + if found_tracks: + tracks = found_tracks + total_length = found_length + label = extract_label(result) + barcode = result.get("barcode") + country = result.get("country") + break + except Exception as e: # noqa: BLE001 + logger.warning(f"MusicBrainz fallback for tracks failed: {e}") + + year = None + if release_date := lidarr_album.get("release_date"): + try: + year = int(release_date.split("-")[0]) + except (ValueError, IndexError): + pass + + cover_url = prefer_release_group_cover_url( + release_group_id, + lidarr_album.get("cover_url"), + size=500, + ) + + return AlbumInfo( + title=lidarr_album.get("title", "Unknown Album"), + musicbrainz_id=release_group_id, + artist_name=lidarr_album.get("artist_name", "Unknown Artist"), + artist_id=lidarr_album.get("artist_mbid", ""), + release_date=lidarr_album.get("release_date"), + year=year, + type=lidarr_album.get("album_type"), + label=label, + barcode=barcode, + country=country, + disambiguation=lidarr_album.get("disambiguation"), + tracks=tracks, + total_tracks=len(tracks), + total_length=total_length if total_length > 0 else None, + in_library=True, + cover_url=cover_url, + ) + + async def _build_album_from_musicbrainz( + self, + release_group_id: str, + monitored_mbids: set[str] = None + ) -> AlbumInfo: + cached_album = await self._library_db.get_album_by_mbid(release_group_id) + in_library = cached_album is not None + + if in_library: + logger.info(f"Cache HIT (library DB): Album {release_group_id[:8]}... is in library") + else: + logger.debug(f"Cache MISS (library DB): Album {release_group_id[:8]}... not in library") + + logger.info(f"API CALL (MusicBrainz): Fetching album {release_group_id[:8]}...") + release_group = await self._fetch_release_group(release_group_id) + primary_release = find_primary_release(release_group) + artist_name, artist_id = extract_artist_info(release_group) + + if not in_library: + in_library = await self._check_in_library(release_group_id, monitored_mbids) + + basic_info = self._build_basic_info( + release_group, release_group_id, artist_name, artist_id, in_library + ) + + if primary_release: + await self._enrich_with_release_details(basic_info, primary_release) + + return basic_info diff --git a/backend/services/album_utils.py b/backend/services/album_utils.py new file mode 100644 index 0000000..8e67896 --- /dev/null +++ b/backend/services/album_utils.py @@ -0,0 +1,150 @@ +from typing import Optional +from api.v1.schemas.album import Track + + +def parse_year(date_str: Optional[str]) -> Optional[int]: + if not date_str: + return None + year = date_str.split("-", 1)[0] + return int(year) if year.isdigit() else None + + +def find_primary_release(release_group: dict) -> Optional[dict]: + ranked = get_ranked_releases(release_group) + return ranked[0] if ranked else None + + +def get_ranked_releases(release_group: dict) -> list[dict]: + """Return official releases sorted so digital/mainstream formats come first.""" + releases = release_group.get("releases") or release_group.get("release-list", []) + official = [r for r in releases if r.get("status") == "Official"] + if not official: + official = list(releases) + + def _release_sort_key(r: dict) -> tuple[int, str]: + country = (r.get("country") or "").upper() + packaging = (r.get("packaging") or "").lower() + physical_keywords = {"vinyl", "cassette", "gatefold"} + if country == "XW": + rank = 0 + elif any(kw in packaging for kw in physical_keywords): + rank = 2 + else: + rank = 1 + return (rank, r.get("id", "")) + + official.sort(key=_release_sort_key) + return official + + +def extract_artist_info(release_group: dict) -> tuple[str, str]: + artist_credit = release_group.get("artist-credit", []) + artist_name = "Unknown Artist" + artist_id = "" + if artist_credit and isinstance(artist_credit, list): + first_artist = artist_credit[0] + if isinstance(first_artist, dict): + artist_obj = first_artist.get("artist", {}) + artist_name = first_artist.get("name") or artist_obj.get("name", "Unknown Artist") + artist_id = artist_obj.get("id", "") + return artist_name, artist_id + + +def extract_tracks(release_data: dict) -> tuple[list[Track], int]: + tracks = [] + total_length = 0 + medium_list = release_data.get("media") or release_data.get("medium-list", []) + for medium in medium_list: + try: + disc_number = int(medium.get("position") or medium.get("number") or 1) + except (TypeError, ValueError): + disc_number = 1 + track_list = medium.get("tracks") or medium.get("track-list", []) + for track in track_list: + recording = track.get("recording", {}) + length_ms = track.get("length") or recording.get("length") + if length_ms: + try: + total_length += int(length_ms) + except (ValueError, TypeError): + pass + tracks.append( + Track( + position=int(track.get("position") or track.get("number", 0)), + disc_number=disc_number, + title=recording.get("title") or track.get("title", "Unknown"), + length=int(length_ms) if length_ms else None, + recording_id=recording.get("id"), + ) + ) + return tracks, total_length + + +def extract_label(release_data: dict) -> Optional[str]: + label_info_list = release_data.get("label-info") or release_data.get("label-info-list", []) + if label_info_list: + label_obj = label_info_list[0].get("label") + if label_obj: + return label_obj.get("name") + return None + + +def build_album_basic_info( + release_group: dict, + release_group_id: str, + artist_name: str, + artist_id: str, + in_library: bool +) -> dict: + return { + "title": release_group.get("title", "Unknown Album"), + "musicbrainz_id": release_group_id, + "artist_name": artist_name, + "artist_id": artist_id, + "release_date": release_group.get("first-release-date"), + "year": parse_year(release_group.get("first-release-date")), + "type": release_group.get("primary-type"), + "disambiguation": release_group.get("disambiguation"), + "tracks": [], + "total_tracks": 0, + "in_library": in_library, + } + + +def lidarr_to_basic_info(lidarr_album: dict, release_group_id: str, in_library: bool) -> dict: + year = None + if release_date := lidarr_album.get("release_date"): + try: + year = int(release_date.split("-")[0]) + except (ValueError, IndexError): + pass + return { + "title": lidarr_album.get("title", "Unknown Album"), + "musicbrainz_id": release_group_id, + "artist_name": lidarr_album.get("artist_name", "Unknown Artist"), + "artist_id": lidarr_album.get("artist_mbid", ""), + "release_date": lidarr_album.get("release_date"), + "year": year, + "type": lidarr_album.get("album_type"), + "disambiguation": lidarr_album.get("disambiguation"), + "in_library": in_library, + "requested": not in_library, + "cover_url": lidarr_album.get("cover_url"), + } + + +def mb_to_basic_info(release_group: dict, release_group_id: str, in_library: bool, is_requested: bool) -> dict: + artist_name, artist_id = extract_artist_info(release_group) + return { + "title": release_group.get("title", "Unknown Album"), + "musicbrainz_id": release_group_id, + "artist_name": artist_name, + "artist_id": artist_id, + "release_date": release_group.get("first-release-date"), + "year": parse_year(release_group.get("first-release-date")), + "type": release_group.get("primary-type"), + "disambiguation": release_group.get("disambiguation"), + "in_library": in_library, + "requested": is_requested and not in_library, + "cover_url": None, + } diff --git a/backend/services/artist_discovery_service.py b/backend/services/artist_discovery_service.py new file mode 100644 index 0000000..b05d3c4 --- /dev/null +++ b/backend/services/artist_discovery_service.py @@ -0,0 +1,635 @@ +import asyncio +import logging +from typing import Any, Literal, Optional + +from api.v1.schemas.discovery import ( + SimilarArtist, + SimilarArtistsResponse, + TopSong, + TopSongsResponse, + TopAlbum, + TopAlbumsResponse, +) +from repositories.protocols import ListenBrainzRepositoryProtocol, LastFmRepositoryProtocol, MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.persistence import LibraryDB +from infrastructure.resilience.retry import CircuitOpenError +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +DISCOVERY_CACHE_TTL_LIBRARY = 21600 +DISCOVERY_CACHE_TTL_NON_LIBRARY = 3600 +DISCOVERY_EMPTY_CACHE_TTL = 600 +CIRCUIT_OPEN_CACHE_TTL = 30 +DEFAULT_SIMILAR_COUNT = 15 +DEFAULT_TOP_SONGS_COUNT = 10 +DEFAULT_TOP_ALBUMS_COUNT = 10 + + +class ArtistDiscoveryService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + library_db: LibraryDB, + lidarr_repo: LidarrRepositoryProtocol, + memory_cache: CacheInterface, + lastfm_repo: Optional[LastFmRepositoryProtocol] = None, + preferences_service: Optional[PreferencesService] = None, + ): + self._lb_repo = listenbrainz_repo + self._mb_repo = musicbrainz_repo + self._library_db = library_db + self._lidarr_repo = lidarr_repo + self._cache = memory_cache + self._lastfm_repo = lastfm_repo + self._preferences_service = preferences_service + + def _resolve_source( + self, source: Literal["listenbrainz", "lastfm"] | None + ) -> Literal["listenbrainz", "lastfm"]: + if source in ("listenbrainz", "lastfm"): + resolved: Literal["listenbrainz", "lastfm"] = source + elif self._preferences_service: + preferred = self._preferences_service.get_primary_music_source().source + resolved = preferred if preferred in ("listenbrainz", "lastfm") else "listenbrainz" + else: + resolved = "listenbrainz" + return resolved + + def _get_discovery_ttl(self, in_library: bool) -> int: + if self._preferences_service: + try: + advanced_settings = self._preferences_service.get_advanced_settings() + return ( + advanced_settings.cache_ttl_artist_discovery_library + if in_library + else advanced_settings.cache_ttl_artist_discovery_non_library + ) + except AttributeError: + logger.debug("Artist discovery advanced settings unavailable", exc_info=True) + return DISCOVERY_CACHE_TTL_LIBRARY if in_library else DISCOVERY_CACHE_TTL_NON_LIBRARY + + def _get_empty_discovery_ttl(self) -> int: + return DISCOVERY_EMPTY_CACHE_TTL + + def _build_cache_key( + self, + category: Literal["similar", "top_songs", "top_albums"], + artist_mbid: str, + count: int, + source: str, + ) -> str: + return f"artist_discovery:{category}:{artist_mbid}:{count}:{source}" + + async def get_similar_artists( + self, + artist_mbid: str, + count: int = 15, + source: Literal["listenbrainz", "lastfm"] | None = None, + ) -> SimilarArtistsResponse: + effective_source = self._resolve_source(source) + cache_key = self._build_cache_key("similar", artist_mbid, count, effective_source) + cached = await self._cache.get(cache_key) + if cached: + return cached + + if effective_source == "lastfm": + try: + result = await self._get_similar_artists_lastfm(artist_mbid, count) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get Last.fm similar artists for %s: %s", artist_mbid[:8], e) + result = SimilarArtistsResponse(similar_artists=[], source="lastfm") + elif not self._lb_repo.is_configured(): + return SimilarArtistsResponse(configured=False) + else: + try: + similar = await self._lb_repo.get_similar_artists(artist_mbid, max_similar=count) + library_artist_mbids = await self._library_db.get_all_artist_mbids() + + artists = [ + SimilarArtist( + musicbrainz_id=a.artist_mbid, + name=a.artist_name, + listen_count=a.listen_count, + in_library=a.artist_mbid in library_artist_mbids, + ) + for a in similar[:count] + ] + result = SimilarArtistsResponse(similar_artists=artists) + except CircuitOpenError: + logger.warning("Circuit open for similar artists %s, using short TTL", artist_mbid[:8]) + result = SimilarArtistsResponse(similar_artists=[]) + await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL) + return result + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get similar artists for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e) + result = SimilarArtistsResponse(similar_artists=[]) + + in_library = await self._is_library_artist(artist_mbid) + ttl = ( + self._get_discovery_ttl(in_library) + if result.similar_artists + else self._get_empty_discovery_ttl() + ) + await self._cache.set(cache_key, result, ttl_seconds=ttl) + return result + + async def get_top_songs( + self, + artist_mbid: str, + count: int = 10, + source: Literal["listenbrainz", "lastfm"] | None = None, + ) -> TopSongsResponse: + effective_source = self._resolve_source(source) + cache_key = self._build_cache_key("top_songs", artist_mbid, count, effective_source) + cached = await self._cache.get(cache_key) + if cached: + return cached + + if effective_source == "lastfm": + try: + result = await self._get_top_songs_lastfm(artist_mbid, count) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get Last.fm top songs for %s: %s", artist_mbid[:8], e) + result = TopSongsResponse(songs=[], source="lastfm") + elif not self._lb_repo.is_configured(): + return TopSongsResponse(configured=False) + else: + try: + recordings = await self._lb_repo.get_artist_top_recordings(artist_mbid, count=count) + + release_ids = [r.release_mbid for r in recordings if r.release_mbid] + logger.info(f"Top songs for {artist_mbid}: {len(recordings)} recordings, {len(release_ids)} with release_mbid") + + rg_map = await self._resolve_release_groups(release_ids) + logger.info(f"Resolved {len(rg_map)} release groups from {len(release_ids)} releases") + + songs = [] + for r in recordings[:count]: + disc_number = None + track_number = None + if r.release_mbid and r.recording_mbid: + pos = await self._mb_repo.get_recording_position_on_release( + r.release_mbid, r.recording_mbid + ) + if pos: + disc_number, track_number = pos + + songs.append(TopSong( + recording_mbid=r.recording_mbid, + title=r.track_name, + artist_name=r.artist_name, + release_group_mbid=rg_map.get(r.release_mbid) if r.release_mbid else None, + original_release_mbid=r.release_mbid, + release_name=r.release_name, + listen_count=r.listen_count, + disc_number=disc_number, + track_number=track_number, + )) + result = TopSongsResponse(songs=songs) + except CircuitOpenError: + logger.warning("Circuit open for top songs %s, using short TTL", artist_mbid[:8]) + result = TopSongsResponse(songs=[]) + await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL) + return result + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get top songs for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e) + result = TopSongsResponse(songs=[]) + + in_library = await self._is_library_artist(artist_mbid) + ttl = ( + self._get_discovery_ttl(in_library) + if result.songs + else self._get_empty_discovery_ttl() + ) + await self._cache.set(cache_key, result, ttl_seconds=ttl) + return result + + async def get_top_albums( + self, + artist_mbid: str, + count: int = 10, + source: Literal["listenbrainz", "lastfm"] | None = None, + ) -> TopAlbumsResponse: + effective_source = self._resolve_source(source) + cache_key = self._build_cache_key("top_albums", artist_mbid, count, effective_source) + cached = await self._cache.get(cache_key) + if cached: + return cached + + if effective_source == "lastfm": + try: + result = await self._get_top_albums_lastfm(artist_mbid, count) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get Last.fm top albums for %s: %s", artist_mbid[:8], e) + result = TopAlbumsResponse(albums=[], source="lastfm") + elif not self._lb_repo.is_configured(): + return TopAlbumsResponse(configured=False) + else: + try: + release_groups = await self._lb_repo.get_artist_top_release_groups(artist_mbid, count=count) + if not release_groups: + logger.info("ListenBrainz returned no release groups for %s", artist_mbid[:8]) + fallback_albums = await self._get_top_albums_from_recordings_fallback( + artist_mbid, count + ) + result = TopAlbumsResponse(albums=fallback_albums) + else: + try: + library_album_mbids, requested_album_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(), + self._lidarr_repo.get_requested_mbids(), + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "Failed to load Lidarr album MBIDs for %s: %s(%s)", + artist_mbid[:8], + type(e).__name__, + e, + ) + library_album_mbids, requested_album_mbids = set(), set() + + library_album_mbids = { + mbid.lower() for mbid in library_album_mbids if isinstance(mbid, str) + } + requested_album_mbids = { + mbid.lower() for mbid in requested_album_mbids if isinstance(mbid, str) + } + + albums = [ + TopAlbum( + release_group_mbid=rg.release_group_mbid, + title=rg.release_group_name, + artist_name=rg.artist_name, + listen_count=rg.listen_count, + in_library=rg.release_group_mbid.lower() in library_album_mbids if rg.release_group_mbid else False, + requested=rg.release_group_mbid.lower() in requested_album_mbids if rg.release_group_mbid else False, + ) + for rg in release_groups + ] + result = TopAlbumsResponse(albums=albums) + except CircuitOpenError: + logger.warning("Circuit open for top albums %s, using short TTL", artist_mbid[:8]) + result = TopAlbumsResponse(albums=[]) + await self._cache.set(cache_key, result, ttl_seconds=CIRCUIT_OPEN_CACHE_TTL) + return result + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get top albums for %s: %s(%s)", artist_mbid[:8], type(e).__name__, e) + try: + fallback_albums = await self._get_top_albums_from_recordings_fallback( + artist_mbid, count + ) + result = TopAlbumsResponse(albums=fallback_albums) + except Exception as fallback_error: # noqa: BLE001 + logger.warning( + "Top albums fallback from recordings failed for %s: %s(%s)", + artist_mbid[:8], + type(fallback_error).__name__, + fallback_error, + ) + result = TopAlbumsResponse(albums=[]) + + in_library = await self._is_library_artist(artist_mbid) + empty_ttl = ( + 60 + if effective_source == "listenbrainz" + else self._get_empty_discovery_ttl() + ) + ttl = ( + self._get_discovery_ttl(in_library) + if result.albums + else empty_ttl + ) + await self._cache.set(cache_key, result, ttl_seconds=ttl) + return result + + async def _get_top_albums_from_recordings_fallback( + self, + artist_mbid: str, + count: int, + ) -> list[TopAlbum]: + recordings = await self._lb_repo.get_artist_top_recordings( + artist_mbid, + count=max(count * 8, 80), + ) + if not recordings: + return [] + + try: + library_album_mbids, requested_album_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(), + self._lidarr_repo.get_requested_mbids(), + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "Fallback Lidarr album MBID load failed for %s: %s(%s)", + artist_mbid[:8], + type(e).__name__, + e, + ) + library_album_mbids, requested_album_mbids = set(), set() + + library_album_mbids = { + mbid.lower() for mbid in library_album_mbids if isinstance(mbid, str) + } + requested_album_mbids = { + mbid.lower() for mbid in requested_album_mbids if isinstance(mbid, str) + } + + release_ids = [r.release_mbid for r in recordings if r.release_mbid] + rg_map = await self._resolve_release_groups(release_ids) if release_ids else {} + + aggregated: dict[str, dict[str, str | int | None]] = {} + for recording in recordings: + release_title = (recording.release_name or "").strip() + raw_release_mbid = ( + recording.release_mbid.strip().lower() + if recording.release_mbid and recording.release_mbid.strip() + else None + ) + resolved_release_group_mbid = ( + rg_map.get(raw_release_mbid, raw_release_mbid) if raw_release_mbid else None + ) + + key = resolved_release_group_mbid or (f"name:{release_title.lower()}" if release_title else None) + if not key: + continue + + if key not in aggregated: + aggregated[key] = { + "title": release_title or "Unknown", + "artist_name": recording.artist_name, + "listen_count": 0, + "release_group_mbid": resolved_release_group_mbid, + } + + aggregated[key]["listen_count"] = int(aggregated[key]["listen_count"]) + int( + recording.listen_count + ) + + sorted_albums = sorted( + aggregated.values(), + key=lambda album: int(album["listen_count"]), + reverse=True, + )[:count] + + return [ + TopAlbum( + release_group_mbid=album["release_group_mbid"] if isinstance(album["release_group_mbid"], str) else None, + title=str(album["title"]), + artist_name=str(album["artist_name"]), + listen_count=int(album["listen_count"]), + in_library=( + isinstance(album["release_group_mbid"], str) + and album["release_group_mbid"] in library_album_mbids + ), + requested=( + isinstance(album["release_group_mbid"], str) + and album["release_group_mbid"] in requested_album_mbids + ), + ) + for album in sorted_albums + ] + + async def _is_library_artist(self, artist_mbid: str) -> bool: + try: + library_artist_mbids = await self._library_db.get_all_artist_mbids() + return artist_mbid in library_artist_mbids + except Exception: # noqa: BLE001 + return False + + async def precache_artist_discovery( + self, + artist_mbids: list[str], + delay: float = 0.5, + status_service: Any = None, + mbid_to_name: dict[str, str] | None = None, + ) -> int: + sources: list[Literal["listenbrainz", "lastfm"]] = [] + if self._lb_repo.is_configured(): + sources.append("listenbrainz") + if ( + self._lastfm_repo + and self._preferences_service + and self._preferences_service.is_lastfm_enabled() + ): + sources.append("lastfm") + if not sources: + logger.debug("Skipping discovery pre-cache: no configured source") + return 0 + + cached_count = 0 + source_fetches = 0 + for i, mbid in enumerate(artist_mbids): + try: + for source in sources: + similar_key = self._build_cache_key( + "similar", mbid, DEFAULT_SIMILAR_COUNT, source + ) + songs_key = self._build_cache_key( + "top_songs", mbid, DEFAULT_TOP_SONGS_COUNT, source + ) + albums_key = self._build_cache_key( + "top_albums", mbid, DEFAULT_TOP_ALBUMS_COUNT, source + ) + + has_all = ( + await self._cache.get(similar_key) is not None + and await self._cache.get(songs_key) is not None + and await self._cache.get(albums_key) is not None + ) + if has_all: + continue + + results = await asyncio.gather( + self.get_similar_artists( + mbid, count=DEFAULT_SIMILAR_COUNT, source=source + ), + self.get_top_songs( + mbid, count=DEFAULT_TOP_SONGS_COUNT, source=source + ), + self.get_top_albums( + mbid, count=DEFAULT_TOP_ALBUMS_COUNT, source=source + ), + return_exceptions=True, + ) + errors = [r for r in results if isinstance(r, Exception)] + if errors: + logger.debug("Discovery precache errors for %s: %s", mbid[:8], errors) + source_fetches += 1 + + cached_count += 1 + + except Exception as e: # noqa: BLE001 + logger.warning("Failed to precache discovery for %s: %s", mbid[:8], e) + finally: + if status_service: + artist_name = (mbid_to_name or {}).get(mbid, mbid[:8]) + await status_service.update_progress(i + 1, current_item=artist_name) + + if (i + 1) % 10 == 0: + logger.info("Discovery precache progress: %d/%d artists", i + 1, len(artist_mbids)) + + if delay > 0 and i < len(artist_mbids) - 1: + await asyncio.sleep(delay) + + logger.info( + "Discovery precache complete: %d/%d artists refreshed (%d source fetches)", + cached_count, + len(artist_mbids), + source_fetches, + ) + return cached_count + + async def _resolve_release_groups(self, release_ids: list[str]) -> dict[str, str]: + if not release_ids: + return {} + + unique_ids = list(dict.fromkeys(release_ids)) + logger.info(f"Resolving {len(unique_ids)} unique release IDs to release groups (from {len(release_ids)} total)") + tasks = [self._mb_repo.get_release_group_id_from_release(rid) for rid in unique_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + + rg_map = {} + errors = 0 + for rid, rg_id in zip(unique_ids, results): + if isinstance(rg_id, Exception): + errors += 1 + logger.warning(f"Resolution exception for {rid}: {rg_id}") + elif isinstance(rg_id, str) and rg_id: + rg_map[rid] = rg_id + else: + errors += 1 + logger.warning(f"Resolution returned None/empty for {rid}") + + logger.info(f"Release group resolution: {len(rg_map)} succeeded, {errors} failed") + return rg_map + + async def _get_similar_artists_lastfm( + self, artist_mbid: str, count: int + ) -> SimilarArtistsResponse: + if ( + not self._lastfm_repo + or not self._preferences_service + or not self._preferences_service.is_lastfm_enabled() + ): + return SimilarArtistsResponse( + similar_artists=[], source="lastfm", configured=False + ) + + try: + similar = await self._lastfm_repo.get_similar_artists( + artist="", mbid=artist_mbid, limit=count + ) + library_artist_mbids = await self._library_db.get_all_artist_mbids() + + artists = [ + SimilarArtist( + musicbrainz_id=a.mbid or "", + name=a.name, + listen_count=0, + in_library=bool(a.mbid and a.mbid in library_artist_mbids), + ) + for a in similar[:count] + if a.mbid + ] + return SimilarArtistsResponse( + similar_artists=artists, source="lastfm" + ) + except Exception as e: + logger.warning( + "Last.fm similar artists API error for %s: %s", artist_mbid[:8], e + ) + raise + + async def _get_top_songs_lastfm( + self, artist_mbid: str, count: int + ) -> TopSongsResponse: + if ( + not self._lastfm_repo + or not self._preferences_service + or not self._preferences_service.is_lastfm_enabled() + ): + return TopSongsResponse(songs=[], source="lastfm", configured=False) + + try: + tracks = await self._lastfm_repo.get_artist_top_tracks( + artist="", mbid=artist_mbid, limit=count + ) + trimmed = tracks[:count] + + songs = [ + TopSong( + recording_mbid=t.mbid, + title=t.name, + artist_name=t.artist_name, + release_group_mbid=None, + original_release_mbid=None, + release_name=None, + listen_count=t.playcount, + ) + for t in trimmed + ] + return TopSongsResponse(songs=songs, source="lastfm") + except Exception as e: + logger.warning( + "Last.fm top songs API error for %s: %s", artist_mbid[:8], e + ) + raise + + async def _get_top_albums_lastfm( + self, artist_mbid: str, count: int + ) -> TopAlbumsResponse: + if ( + not self._lastfm_repo + or not self._preferences_service + or not self._preferences_service.is_lastfm_enabled() + ): + return TopAlbumsResponse(albums=[], source="lastfm", configured=False) + + try: + lfm_albums = await self._lastfm_repo.get_artist_top_albums( + artist="", mbid=artist_mbid, limit=count + ) + + library_album_mbids, requested_album_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(), + self._lidarr_repo.get_requested_mbids(), + ) + + trimmed = lfm_albums[:count] + mbids_from_lastfm = [ + a.mbid.strip().lower() for a in trimmed if a.mbid and a.mbid.strip() + ] + rg_map = await self._resolve_release_groups(mbids_from_lastfm) if mbids_from_lastfm else {} + + albums = [] + for a in trimmed: + raw_mbid = a.mbid.strip().lower() if a.mbid and a.mbid.strip() else None + resolved_mbid = rg_map.get(raw_mbid, raw_mbid) if raw_mbid else None + albums.append( + TopAlbum( + release_group_mbid=resolved_mbid, + title=a.name, + artist_name=a.artist_name, + listen_count=a.playcount, + in_library=( + resolved_mbid in library_album_mbids + if resolved_mbid + else False + ), + requested=( + resolved_mbid in requested_album_mbids + if resolved_mbid + else False + ), + ) + ) + return TopAlbumsResponse(albums=albums, source="lastfm") + except Exception as e: + logger.warning( + "Last.fm top albums API error for %s: %s", artist_mbid[:8], e + ) + raise diff --git a/backend/services/artist_enrichment_service.py b/backend/services/artist_enrichment_service.py new file mode 100644 index 0000000..8ade758 --- /dev/null +++ b/backend/services/artist_enrichment_service.py @@ -0,0 +1,67 @@ +import logging +from typing import Optional + +from api.v1.schemas.artist import ( + LastFmArtistEnrichment, + LastFmSimilarArtistSchema, +) +from api.v1.schemas.common import LastFmTagSchema +from infrastructure.validators import clean_lastfm_bio +from repositories.protocols import LastFmRepositoryProtocol +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + + +class ArtistEnrichmentService: + def __init__( + self, + lastfm_repo: LastFmRepositoryProtocol, + preferences_service: PreferencesService, + ): + self._lastfm_repo = lastfm_repo + self._preferences_service = preferences_service + + async def get_lastfm_enrichment( + self, artist_mbid: str, artist_name: str + ) -> Optional[LastFmArtistEnrichment]: + if not self._preferences_service.is_lastfm_enabled(): + return None + + try: + info = await self._lastfm_repo.get_artist_info( + artist=artist_name, mbid=artist_mbid + ) + if info is None: + return None + + tags = [ + LastFmTagSchema(name=t.name, url=t.url or None) + for t in (info.tags or []) + ] + similar = [ + LastFmSimilarArtistSchema( + name=s.name, + mbid=s.mbid, + match=s.match, + url=s.url or None, + ) + for s in (info.similar or []) + ] + + return LastFmArtistEnrichment( + bio=clean_lastfm_bio(info.bio_summary) or None, + summary=None, + tags=tags, + listeners=info.listeners, + playcount=info.playcount, + similar_artists=similar, + url=info.url or None, + ) + except Exception as e: # noqa: BLE001 + logger.warning( + "Failed to fetch Last.fm enrichment for artist %s: %s", + artist_mbid[:8], + e, + ) + return None diff --git a/backend/services/artist_service.py b/backend/services/artist_service.py new file mode 100644 index 0000000..456e304 --- /dev/null +++ b/backend/services/artist_service.py @@ -0,0 +1,641 @@ +import asyncio +import copy +import logging +import msgspec +from typing import Any, Optional, TYPE_CHECKING +from api.v1.schemas.artist import ArtistInfo, ArtistExtendedInfo, ArtistReleases, ExternalLink, ReleaseItem +from repositories.protocols import MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol, WikidataRepositoryProtocol +from services.preferences_service import PreferencesService +from services.artist_utils import ( + detect_platform, + extract_tags, + extract_aliases, + extract_life_span, + extract_external_links, + categorize_release_groups, + categorize_lidarr_albums, + extract_wiki_info, + build_base_artist_info, +) +from infrastructure.cache.cache_keys import ARTIST_INFO_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.validators import validate_mbid +from core.exceptions import ResourceNotFoundError +from services.audiodb_image_service import AudioDBImageService +from repositories.audiodb_models import AudioDBArtistImages + +if TYPE_CHECKING: + from infrastructure.persistence import LibraryDB + +logger = logging.getLogger(__name__) + + +class ArtistService: + def __init__( + self, + mb_repo: MusicBrainzRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + wikidata_repo: WikidataRepositoryProtocol, + preferences_service: PreferencesService, + memory_cache: CacheInterface, + disk_cache: DiskMetadataCache, + audiodb_image_service: AudioDBImageService | None = None, + audiodb_browse_queue: Any = None, + library_db: 'LibraryDB | None' = None, + ): + self._mb_repo = mb_repo + self._lidarr_repo = lidarr_repo + self._wikidata_repo = wikidata_repo + self._preferences_service = preferences_service + self._cache = memory_cache + self._disk_cache = disk_cache + self._audiodb_image_service = audiodb_image_service + self._audiodb_browse_queue = audiodb_browse_queue + self._library_db = library_db + self._artist_in_flight: dict[str, asyncio.Future[ArtistInfo]] = {} + self._artist_basic_in_flight: dict[str, asyncio.Future[ArtistInfo]] = {} + + async def _get_library_cache_mbids(self) -> set[str]: + if self._library_db is None: + return set() + try: + raw = await self._library_db.get_all_album_mbids() + return {m.lower() for m in raw if m} + except Exception as e: # noqa: BLE001 + logger.warning("Failed to read library cache MBIDs: %s", e) + return set() + + async def _revalidate_library_status(self, artist_info: ArtistInfo) -> ArtistInfo: + """Re-evaluate in_library flags on a cached artist response using fresh LibraryDB data.""" + cache_mbids = await self._get_library_cache_mbids() + try: + library_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True) + except Exception: # noqa: BLE001 + library_mbids = set() + all_mbids = library_mbids | cache_mbids + if not all_mbids: + return artist_info + + result = copy.deepcopy(artist_info) + changed = False + for release_list in (result.albums, result.singles, result.eps): + if not release_list: + continue + for release in release_list: + if isinstance(release, dict): + rid = (release.get("id") or "").lower() + else: + rid = (release.id or "").lower() + if not rid: + continue + new_in_library = rid in all_mbids + old_in_library = release.get("in_library", False) if isinstance(release, dict) else release.in_library + if new_in_library != old_in_library: + if isinstance(release, dict): + release["in_library"] = new_in_library + if new_in_library and release.get("requested"): + release["requested"] = False + else: + release.in_library = new_in_library + if new_in_library and release.requested: + release.requested = False + changed = True + + artist_mbids = await self._get_library_artist_mbids() + new_artist_in_library = result.musicbrainz_id and result.musicbrainz_id.lower() in artist_mbids + if new_artist_in_library != result.in_library: + result.in_library = new_artist_in_library + changed = True + + if changed: + logger.info("Revalidated library status for cached artist %s", (result.musicbrainz_id or "")[:8]) + return result + + async def _get_library_artist_mbids(self) -> set[str]: + if self._library_db is None: + return set() + try: + raw = await self._library_db.get_all_artist_mbids() + return {m.lower() for m in raw if m} + except Exception as e: # noqa: BLE001 + logger.warning("Failed to read library artist cache MBIDs: %s", e) + return set() + + async def _apply_audiodb_artist_images( + self, + artist_info: ArtistInfo, + mbid: str, + name: str | None, + *, + allow_fetch: bool = False, + is_monitored: bool = False, + ) -> ArtistInfo: + if self._audiodb_image_service is None: + return artist_info + try: + images: AudioDBArtistImages | None + if allow_fetch: + images = await self._audiodb_image_service.fetch_and_cache_artist_images( + mbid, name, is_monitored=is_monitored, + ) + else: + images = await self._audiodb_image_service.get_cached_artist_images(mbid) + if images is None or images.is_negative: + if not allow_fetch and images is None and self._audiodb_browse_queue: + settings = self._preferences_service.get_advanced_settings() + if settings.audiodb_enabled: + await self._audiodb_browse_queue.enqueue("artist", mbid, name=name) + return artist_info + if not artist_info.fanart_url and images.fanart_url: + artist_info.fanart_url = images.fanart_url + if not artist_info.banner_url and images.banner_url: + artist_info.banner_url = images.banner_url + if images.thumb_url: + artist_info.thumb_url = images.thumb_url + if images.fanart_url_2: + artist_info.fanart_url_2 = images.fanart_url_2 + if images.fanart_url_3: + artist_info.fanart_url_3 = images.fanart_url_3 + if images.fanart_url_4: + artist_info.fanart_url_4 = images.fanart_url_4 + if images.wide_thumb_url: + artist_info.wide_thumb_url = images.wide_thumb_url + if images.logo_url: + artist_info.logo_url = images.logo_url + if images.clearart_url: + artist_info.clearart_url = images.clearart_url + if images.cutout_url: + artist_info.cutout_url = images.cutout_url + except Exception as e: # noqa: BLE001 + logger.warning("Failed to apply AudioDB images for artist %s: %s", mbid[:8], e) + return artist_info + + async def get_artist_info( + self, + artist_id: str, + library_artist_mbids: set[str] = None, + library_album_mbids: dict[str, Any] = None + ) -> ArtistInfo: + try: + artist_id = validate_mbid(artist_id, "artist") + except ValueError as e: + logger.error(f"Invalid artist MBID: {e}") + raise + try: + cached = await self._get_cached_artist(artist_id) + if cached: + cached = await self._revalidate_library_status(cached) + cached = await self._apply_audiodb_artist_images( + cached, artist_id, cached.name, + allow_fetch=False, is_monitored=cached.in_library, + ) + return cached + + if artist_id in self._artist_in_flight: + return await asyncio.shield(self._artist_in_flight[artist_id]) + + loop = asyncio.get_running_loop() + future: asyncio.Future[ArtistInfo] = loop.create_future() + self._artist_in_flight[artist_id] = future + try: + artist_info = await self._do_get_artist_info(artist_id, library_artist_mbids, library_album_mbids) + if not future.done(): + future.set_result(artist_info) + return artist_info + except BaseException as exc: + if not future.done(): + future.set_exception(exc) + raise + finally: + self._artist_in_flight.pop(artist_id, None) + except ValueError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"API call failed for artist {artist_id}: {e}") + raise ResourceNotFoundError(f"Failed to get artist info: {e}") + + async def _do_get_artist_info( + self, artist_id: str, + library_artist_mbids: set[str] | None, + library_album_mbids: dict[str, Any] | None, + ) -> ArtistInfo: + lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) if self._lidarr_repo.is_configured() else None + in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False) + if in_library and lidarr_artist: + logger.info(f"Using Lidarr as primary source for artist {artist_id[:8]}") + artist_info = await self._build_artist_from_lidarr(artist_id, lidarr_artist, library_album_mbids) + else: + logger.info(f"Using MusicBrainz as primary source for artist {artist_id[:8]}") + artist_info = await self._build_artist_from_musicbrainz(artist_id, library_artist_mbids, library_album_mbids) + artist_info = await self._apply_audiodb_artist_images( + artist_info, artist_id, artist_info.name, + allow_fetch=False, is_monitored=artist_info.in_library, + ) + await self._save_artist_to_cache(artist_id, artist_info) + return artist_info + + async def _build_artist_from_lidarr( + self, + artist_id: str, + lidarr_artist: dict[str, Any], + library_album_mbids: dict[str, Any] = None + ) -> ArtistInfo: + description = lidarr_artist.get("overview") + image = lidarr_artist.get("poster_url") + fanart_url = lidarr_artist.get("fanart_url") + banner_url = lidarr_artist.get("banner_url") + + genres = lidarr_artist.get("genres", []) + + external_links = [] + for link in lidarr_artist.get("links", []): + link_name = link.get("name", "") + link_url = link.get("url", "") + if link_url: + label, category = detect_platform(link_url, link_name.lower()) + external_links.append(ExternalLink(type=link_name.lower(), url=link_url, label=label, category=category)) + + need_musicbrainz = not description or not genres or not external_links + + parallel_tasks: list[Any] = [] + task_names: list[str] = [] + + if library_album_mbids is None: + parallel_tasks.append(self._lidarr_repo.get_library_mbids(include_release_ids=True)) + task_names.append("library_mbids") + parallel_tasks.append(self._get_library_cache_mbids()) + task_names.append("cache_mbids") + parallel_tasks.append(self._lidarr_repo.get_artist_albums(artist_id)) + task_names.append("lidarr_albums") + if need_musicbrainz: + parallel_tasks.append(self._mb_repo.get_artist_by_id(artist_id)) + task_names.append("mb_artist") + + results = await asyncio.gather(*parallel_tasks, return_exceptions=True) + result_map = dict(zip(task_names, results)) + + if library_album_mbids is None: + lib_result = result_map.get("library_mbids") + library_album_mbids = lib_result if not isinstance(lib_result, Exception) and lib_result else {} + cache_result = result_map.get("cache_mbids") + cache_mbids = cache_result if not isinstance(cache_result, Exception) and cache_result else {} + library_album_mbids = library_album_mbids | cache_mbids + + albums_result = result_map.get("lidarr_albums") + lidarr_albums = albums_result if not isinstance(albums_result, Exception) and albums_result else [] + albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, library_album_mbids) + + aliases = [] + life_span = None + artist_type = lidarr_artist.get("artist_type") + disambiguation = lidarr_artist.get("disambiguation") + country = None + release_group_count = len(lidarr_albums) + + if need_musicbrainz: + logger.debug(f"Fetching supplementary data from MusicBrainz for artist {artist_id[:8]}") + try: + mb_artist = result_map.get("mb_artist") + if isinstance(mb_artist, Exception): + raise mb_artist + if mb_artist: + if not description: + mb_description, _ = await self._fetch_wikidata_info(mb_artist) + description = mb_description + + if not genres: + genres = extract_tags(mb_artist) + + if not external_links: + external_links = self._build_external_links(mb_artist) + + aliases = extract_aliases(mb_artist) + life_span = extract_life_span(mb_artist) + country = mb_artist.get("country") + + if not artist_type: + artist_type = mb_artist.get("type") + if not disambiguation: + disambiguation = mb_artist.get("disambiguation") + + release_group_count = mb_artist.get("release-group-count", release_group_count) + except Exception as e: # noqa: BLE001 + logger.warning(f"MusicBrainz fallback failed for artist {artist_id[:8]}: {e}") + + return ArtistInfo( + name=lidarr_artist.get("name", "Unknown Artist"), + musicbrainz_id=artist_id, + disambiguation=disambiguation, + type=artist_type, + country=country, + life_span=life_span, + description=description, + image=image, + fanart_url=fanart_url, + banner_url=banner_url, + tags=genres, + aliases=aliases, + external_links=external_links, + in_library=True, + albums=albums, + singles=singles, + eps=eps, + release_group_count=release_group_count, + ) + + def _categorize_lidarr_albums( + self, + lidarr_albums: list[dict[str, Any]], + library_album_mbids: set[str] + ) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]: + prefs = self._preferences_service.get_preferences() + included_primary_types = set(t.lower() for t in prefs.primary_types) + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + return categorize_lidarr_albums(lidarr_albums, included_primary_types, included_secondary_types, library_album_mbids) + + async def _build_artist_from_musicbrainz( + self, + artist_id: str, + library_artist_mbids: set[str] = None, + library_album_mbids: dict[str, Any] = None, + include_extended: bool = True + ) -> ArtistInfo: + mb_artist, library_mbids, album_mbids, requested_mbids = await self._fetch_artist_data( + artist_id, library_artist_mbids, library_album_mbids + ) + in_library = artist_id.lower() in library_mbids + albums, singles, eps = await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids) + description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None) + info = build_base_artist_info( + mb_artist, artist_id, in_library, + extract_tags(mb_artist), extract_aliases(mb_artist), extract_life_span(mb_artist), + self._build_external_links(mb_artist), albums, singles, eps, description, image + ) + return ArtistInfo(**info) + + async def get_artist_info_basic(self, artist_id: str) -> ArtistInfo: + artist_id = validate_mbid(artist_id, "artist") + cached = await self._get_cached_artist(artist_id) + if cached: + cached = await self._apply_audiodb_artist_images( + cached, artist_id, cached.name, allow_fetch=False, + ) + await self._refresh_library_flags(cached) + return cached + + if artist_id in self._artist_basic_in_flight: + return await asyncio.shield(self._artist_basic_in_flight[artist_id]) + + loop = asyncio.get_running_loop() + future: asyncio.Future[ArtistInfo] = loop.create_future() + self._artist_basic_in_flight[artist_id] = future + try: + logger.debug(f"Cache MISS (Disk): Artist {artist_id[:8]}... - fetching from API") + artist_info = await self._build_artist_from_musicbrainz(artist_id, include_extended=False) + artist_info = await self._apply_audiodb_artist_images( + artist_info, artist_id, artist_info.name, allow_fetch=False, + ) + await self._save_artist_to_cache(artist_id, artist_info) + if not future.done(): + future.set_result(artist_info) + return artist_info + except BaseException as exc: + if not future.done(): + future.set_exception(exc) + raise + finally: + self._artist_basic_in_flight.pop(artist_id, None) + + async def _refresh_library_flags(self, artist_info: ArtistInfo) -> None: + if not self._lidarr_repo.is_configured(): + return + try: + library_mbids, requested_mbids, artist_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(include_release_ids=False), + self._lidarr_repo.get_requested_mbids(), + self._lidarr_repo.get_artist_mbids(), + ) + for release_list in (artist_info.albums, artist_info.singles, artist_info.eps): + for rg in release_list: + rg_id = (rg.id or "").lower() + if not rg_id: + continue + rg.in_library = rg_id in library_mbids + rg.requested = rg_id in requested_mbids and not rg.in_library + artist_info.in_library = artist_info.musicbrainz_id.lower() in artist_mbids + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to refresh library flags: {e}") + + async def _get_cached_artist(self, artist_id: str) -> Optional[ArtistInfo]: + cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}" + cached_info = await self._cache.get(cache_key) + if cached_info: + logger.debug(f"Cache HIT (RAM): Artist {artist_id[:8]}...") + return cached_info + logger.debug(f"Cache MISS (RAM): Artist {artist_id[:8]}...") + disk_data = await self._disk_cache.get_artist(artist_id) + if disk_data: + try: + artist_info = msgspec.convert(disk_data, ArtistInfo, strict=False) + except (msgspec.ValidationError, TypeError, ValueError) as e: + logger.warning(f"Corrupt disk cache for artist {artist_id[:8]}, clearing: {e}") + await self._disk_cache.delete_artist(artist_id) + return None + logger.debug(f"Cache HIT (Disk): Artist {artist_id[:8]}...") + ttl = self._get_artist_ttl(artist_info.in_library) + await self._cache.set(cache_key, artist_info, ttl_seconds=ttl) + return artist_info + return None + + async def _save_artist_to_cache(self, artist_id: str, artist_info: ArtistInfo) -> None: + cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}" + ttl = self._get_artist_ttl(artist_info.in_library) + await self._cache.set(cache_key, artist_info, ttl_seconds=ttl) + await self._disk_cache.set_artist( + artist_id, artist_info, + is_monitored=artist_info.in_library, + ttl_seconds=ttl if not artist_info.in_library else None + ) + + def _get_artist_ttl(self, in_library: bool) -> int: + advanced_settings = self._preferences_service.get_advanced_settings() + return advanced_settings.cache_ttl_artist_library if in_library else advanced_settings.cache_ttl_artist_non_library + + async def get_artist_extended_info(self, artist_id: str) -> ArtistExtendedInfo: + try: + artist_id = validate_mbid(artist_id, "artist") + cache_key = f"{ARTIST_INFO_PREFIX}{artist_id}" + cached_info = await self._cache.get(cache_key) + if cached_info and cached_info.description is not None: + logger.debug(f"Extended info cache HIT: Artist {artist_id[:8]}...") + return ArtistExtendedInfo(description=cached_info.description, image=cached_info.image) + mb_artist = await self._mb_repo.get_artist_by_id(artist_id) + if not mb_artist: + raise ResourceNotFoundError("Artist not found") + description, image = await self._fetch_wikidata_info(mb_artist) + if cached_info: + cached_info.description = description + cached_info.image = image + await self._save_artist_to_cache(artist_id, cached_info) + return ArtistExtendedInfo(description=description, image=image) + except Exception as e: # noqa: BLE001 + logger.error(f"Error fetching extended artist info for {artist_id}: {e}") + return ArtistExtendedInfo(description=None, image=None) + + async def get_artist_releases( + self, + artist_id: str, + offset: int = 0, + limit: int = 50 + ) -> ArtistReleases: + try: + lidarr_artist = await self._lidarr_repo.get_artist_details(artist_id) + in_library = lidarr_artist is not None and lidarr_artist.get("monitored", False) + + album_mbids, requested_mbids, cache_mbids = await asyncio.gather( + self._lidarr_repo.get_library_mbids(include_release_ids=True), + self._lidarr_repo.get_requested_mbids(), + self._get_library_cache_mbids(), + ) + album_mbids = album_mbids | cache_mbids + + prefs = self._preferences_service.get_preferences() + included_primary_types = set(t.lower() for t in prefs.primary_types) + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + + if in_library and offset == 0: + logger.debug(f"Using Lidarr for artist releases {artist_id[:8]}") + lidarr_albums = await self._lidarr_repo.get_artist_albums(artist_id) + albums, singles, eps = self._categorize_lidarr_albums(lidarr_albums, album_mbids) + + total_count = len(albums) + len(singles) + len(eps) + + return ArtistReleases( + albums=albums, + singles=singles, + eps=eps, + total_count=total_count, + has_more=False + ) + + logger.debug(f"Using MusicBrainz for artist releases {artist_id[:8]}") + release_groups, total_count = await self._mb_repo.get_artist_release_groups( + artist_id, offset, limit + ) + + temp_artist = {"release-group-list": release_groups} + + albums, singles, eps = categorize_release_groups( + temp_artist, + album_mbids, + included_primary_types, + included_secondary_types, + requested_mbids + ) + + has_more = (offset + len(release_groups)) < total_count + + return ArtistReleases( + albums=albums, + singles=singles, + eps=eps, + total_count=total_count, + has_more=has_more + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Error fetching releases for artist {artist_id} at offset {offset}: {e}") + return ArtistReleases(albums=[], singles=[], eps=[], total_count=0, has_more=False) + + async def _fetch_artist_data( + self, + artist_id: str, + library_artist_mbids: set[str] = None, + library_album_mbids: dict[str, Any] = None + ) -> tuple[dict, set[str], set[str], set[str]]: + if library_artist_mbids is not None and library_album_mbids is not None: + mb_artist = await self._mb_repo.get_artist_by_id(artist_id) + library_mbids = library_artist_mbids + album_mbids = library_album_mbids + requested_result = await asyncio.gather( + self._lidarr_repo.get_requested_mbids(), + return_exceptions=True, + ) + requested_mbids = requested_result[0] if not isinstance(requested_result[0], BaseException) else set() + if isinstance(requested_result[0], BaseException): + logger.warning(f"Lidarr unavailable, proceeding without requested data: {requested_result[0]}") + else: + mb_artist, *lidarr_results = await asyncio.gather( + self._mb_repo.get_artist_by_id(artist_id), + self._lidarr_repo.get_artist_mbids(), + self._lidarr_repo.get_library_mbids(include_release_ids=True), + self._lidarr_repo.get_requested_mbids(), + return_exceptions=True, + ) + if isinstance(mb_artist, BaseException): + logger.error(f"Error fetching artist data for {artist_id}: {mb_artist}") + raise ResourceNotFoundError(f"Failed to fetch artist: {mb_artist}") + lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results) + if lidarr_failed: + logger.warning(f"Lidarr unavailable for artist {artist_id}, proceeding with MusicBrainz data only") + library_mbids = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else set() + album_mbids = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else set() + requested_mbids = lidarr_results[2] if not isinstance(lidarr_results[2], BaseException) else set() + + # Supplement with LibraryDB so monitored albums (even with trackFileCount=0) + # are recognised as "in library", consistent with the Library page. + cache_mbids = await self._get_library_cache_mbids() + album_mbids = album_mbids | cache_mbids + + if not mb_artist: + raise ResourceNotFoundError("Artist not found") + + return mb_artist, library_mbids, album_mbids, requested_mbids + + def _build_external_links(self, mb_artist: dict[str, Any]) -> list[ExternalLink]: + external_links_data = extract_external_links(mb_artist) + return [ + ExternalLink(type=link["type"], url=link["url"], label=link["label"]) + for link in external_links_data + ] + + async def _get_categorized_releases( + self, + mb_artist: dict[str, Any], + album_mbids: set[str], + requested_mbids: set[str] = None + ) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]: + prefs = self._preferences_service.get_preferences() + included_primary_types = set(t.lower() for t in prefs.primary_types) + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + return categorize_release_groups( + mb_artist, + album_mbids, + included_primary_types, + included_secondary_types, + requested_mbids or set() + ) + + async def _fetch_wikidata_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], Optional[str]]: + wikidata_id, wiki_urls = self._extract_wiki_info(mb_artist) + + tasks = [] + if wiki_urls: + tasks.append(self._wikidata_repo.get_wikipedia_extract(wiki_urls[0])) + else: + tasks.append(asyncio.create_task(asyncio.sleep(0))) + + if wikidata_id: + tasks.append(self._wikidata_repo.get_artist_image_from_wikidata(wikidata_id)) + else: + tasks.append(asyncio.create_task(asyncio.sleep(0))) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + description = results[0] if len(results) > 0 and not isinstance(results[0], Exception) and results[0] else None + image = results[1] if len(results) > 1 and not isinstance(results[1], Exception) and results[1] else None + + return description, image + + def _extract_wiki_info(self, mb_artist: dict[str, Any]) -> tuple[Optional[str], list[str]]: + return extract_wiki_info(mb_artist, self._wikidata_repo.get_wikidata_id_from_url) diff --git a/backend/services/artist_utils.py b/backend/services/artist_utils.py new file mode 100644 index 0000000..88e22f0 --- /dev/null +++ b/backend/services/artist_utils.py @@ -0,0 +1,256 @@ +from typing import Any, Optional, Callable + +from api.v1.schemas.artist import LifeSpan, ReleaseItem + +_PLATFORM_PATTERNS: dict[str, tuple[str, str]] = { + "instagram.com": ("Instagram", "social"), + "twitter.com": ("Twitter", "social"), + "x.com": ("Twitter", "social"), + "facebook.com": ("Facebook", "social"), + "youtube.com": ("YouTube", "music"), + "youtu.be": ("YouTube", "music"), + "spotify.com": ("Spotify", "music"), + "deezer.com": ("Deezer", "music"), + "apple.com/music": ("Apple Music", "music"), + "music.apple.com": ("Apple Music", "music"), + "tidal.com": ("Tidal", "music"), + "amazon.com": ("Amazon", "music"), + "bandcamp.com": ("Bandcamp", "music"), + "soundcloud.com": ("SoundCloud", "music"), + "last.fm": ("Last.fm", "info"), + "lastfm.": ("Last.fm", "info"), + "wikipedia.org": ("Wikipedia", "info"), +} + +_LINK_TYPE_LABELS: dict[str, tuple[str, str]] = { + "official homepage": ("Official Website", "info"), + "wikipedia": ("Wikipedia", "info"), + "last.fm": ("Last.fm", "info"), + "bandcamp": ("Bandcamp", "music"), + "youtube": ("YouTube", "music"), + "soundcloud": ("SoundCloud", "music"), + "instagram": ("Instagram", "social"), + "twitter": ("Twitter", "social"), + "facebook": ("Facebook", "social"), +} + +_ALLOWED_LABELS = { + "Spotify", "Apple Music", "YouTube", "Bandcamp", "SoundCloud", + "Deezer", "Tidal", "Amazon", + "Instagram", "Twitter", "Facebook", + "Official Website", "Wikipedia", "Last.fm", +} + + +def detect_platform(url: str, rel_type: str) -> tuple[str, str]: + """Return (label, category) for a URL + relation type.""" + url_lower = url.lower() + for pattern, result in _PLATFORM_PATTERNS.items(): + if pattern in url_lower: + return result + return _LINK_TYPE_LABELS.get(rel_type, (rel_type.title(), "other")) + + +def extract_tags(mb_artist: dict[str, Any], limit: int = 10) -> list[str]: + tags = [] + if mb_tags := mb_artist.get("tags", []): + tags = [tag.get("name") for tag in mb_tags if tag.get("name")][:limit] + return tags + + +def extract_aliases(mb_artist: dict[str, Any], limit: int = 10) -> list[str]: + aliases = [] + if mb_aliases := mb_artist.get("aliases", []): + aliases = [ + alias.get("name") + for alias in mb_aliases + if alias.get("name") + ][:limit] + return aliases + + +def extract_life_span(mb_artist: dict[str, Any]) -> LifeSpan | None: + if life_span := mb_artist.get("life-span"): + ended = life_span.get("ended") + return LifeSpan( + begin=life_span.get("begin"), + end=life_span.get("end"), + ended=str(ended).lower() if ended is not None else None, + ) + return None + + +def extract_external_links(mb_artist: dict[str, Any]) -> list[dict[str, str]]: + external_links: list[dict[str, str]] = [] + seen_labels: set[str] = set() + if url_rels := mb_artist.get("relations", []): + for url_rel in url_rels: + rel_type = url_rel.get("type", "") + url_obj = url_rel.get("url", {}) + target_url = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + if not target_url: + continue + label, category = detect_platform(target_url, rel_type) + if label not in _ALLOWED_LABELS or label in seen_labels: + continue + external_links.append( + {"type": rel_type, "url": target_url, "label": label, "category": category} + ) + seen_labels.add(label) + return external_links + + +def categorize_release_groups( + mb_artist: dict[str, Any], + album_mbids: set[str], + included_primary_types: Optional[set[str]] = None, + included_secondary_types: Optional[set[str]] = None, + requested_mbids: Optional[set[str]] = None, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: + if included_primary_types is None: + included_primary_types = {"album", "single", "ep", "broadcast", "other"} + if requested_mbids is None: + requested_mbids = set() + albums: list[ReleaseItem] = [] + singles: list[ReleaseItem] = [] + eps: list[ReleaseItem] = [] + if rg_list := mb_artist.get("release-group-list", []): + for rg in rg_list: + rg_id = rg.get("id") + primary_type = (rg.get("primary-type") or "").lower() + if primary_type not in included_primary_types: + continue + if included_secondary_types is not None: + secondary_types = set(map(str.lower, rg.get("secondary-types", []) or [])) + if not secondary_types: + if "studio" not in included_secondary_types: + continue + elif not secondary_types.intersection(included_secondary_types): + continue + rg_id_lower = rg_id.lower() if rg_id else "" + in_library = rg_id_lower in album_mbids if rg_id else False + requested = rg_id_lower in requested_mbids if rg_id and not in_library else False + rg_data = ReleaseItem( + id=rg_id, + title=rg.get("title"), + type=rg.get("primary-type"), + first_release_date=rg.get("first-release-date"), + in_library=in_library, + requested=requested, + ) + if date := rg_data.first_release_date: + try: + rg_data.year = int(date.split("-")[0]) + except (ValueError, AttributeError): + pass + if primary_type == "album": + albums.append(rg_data) + elif primary_type == "single": + singles.append(rg_data) + elif primary_type == "ep": + eps.append(rg_data) + for lst in [albums, singles, eps]: + lst.sort(key=lambda x: (x.year is None, -(x.year or 0))) + return albums, singles, eps + + +def categorize_lidarr_albums( + lidarr_albums: list[dict[str, Any]], + included_primary_types: set[str], + included_secondary_types: set[str], + library_cache_mbids: set[str] | None = None, +) -> tuple[list[ReleaseItem], list[ReleaseItem], list[ReleaseItem]]: + albums: list[ReleaseItem] = [] + singles: list[ReleaseItem] = [] + eps: list[ReleaseItem] = [] + _cache_mbids = library_cache_mbids or set() + for album in lidarr_albums: + album_type = (album.get("album_type") or "").lower() + secondary_types = set(map(str.lower, album.get("secondary_types", []) or [])) + if album_type not in included_primary_types: + continue + if included_secondary_types: + if not secondary_types: + if "studio" not in included_secondary_types: + continue + elif not secondary_types.intersection(included_secondary_types): + continue + mbid = album.get("mbid", "") + mbid_lower = mbid.lower() if mbid else "" + track_file_count = album.get("track_file_count", 0) + monitored = album.get("monitored", False) + in_library = track_file_count > 0 or (mbid_lower in _cache_mbids) + requested = monitored and not in_library + album_data = ReleaseItem( + id=mbid, + title=album.get("title"), + type=album.get("album_type"), + first_release_date=album.get("release_date"), + year=album.get("year"), + in_library=in_library, + requested=requested, + ) + if album_type == "album": + albums.append(album_data) + elif album_type == "single": + singles.append(album_data) + elif album_type == "ep": + eps.append(album_data) + for lst in [albums, singles, eps]: + lst.sort(key=lambda x: (x.year is None, -(x.year or 0))) + return albums, singles, eps + + +def extract_wiki_info( + mb_artist: dict[str, Any], + get_wikidata_id_fn: Callable[[str], Optional[str]] +) -> tuple[Optional[str], list[str]]: + wikidata_id = None + wiki_urls = [] + if url_rels := mb_artist.get("relations", []): + for url_rel in url_rels: + url_type = url_rel.get("type") + url_obj = url_rel.get("url", {}) + wiki_url = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + if not wiki_url: + continue + if url_type == "wikidata" and not wikidata_id: + wikidata_id = get_wikidata_id_fn(wiki_url) + if url_type in ("wikipedia", "wikidata"): + wiki_urls.append(wiki_url) + return wikidata_id, wiki_urls + + +def build_base_artist_info( + mb_artist: dict[str, Any], + artist_id: str, + in_library: bool, + tags: list[str], + aliases: list[str], + life_span: LifeSpan | None, + external_links: list, + albums: list[ReleaseItem], + singles: list[ReleaseItem], + eps: list[ReleaseItem], + description: Optional[str] = None, + image: Optional[str] = None, + release_group_count: Optional[int] = None, +) -> dict[str, Any]: + return { + "name": mb_artist.get("name", "Unknown Artist"), + "musicbrainz_id": artist_id, + "disambiguation": mb_artist.get("disambiguation"), + "type": mb_artist.get("type"), + "country": mb_artist.get("country"), + "life_span": life_span, + "description": description, + "image": image, + "tags": tags, + "aliases": aliases, + "external_links": external_links, + "in_library": in_library, + "albums": albums, + "singles": singles, + "eps": eps, + "release_group_count": release_group_count or mb_artist.get("release-group-count", 0), + } diff --git a/backend/services/audiodb_browse_queue.py b/backend/services/audiodb_browse_queue.py new file mode 100644 index 0000000..101ebfb --- /dev/null +++ b/backend/services/audiodb_browse_queue.py @@ -0,0 +1,127 @@ +import asyncio +import logging +import time +from typing import TYPE_CHECKING + +import msgspec + +if TYPE_CHECKING: + from services.audiodb_image_service import AudioDBImageService + from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +_BROWSE_QUEUE_MAX_SIZE = 500 +_BROWSE_QUEUE_INTER_ITEM_DELAY = 3.0 +_BROWSE_QUEUE_DEDUP_TTL = 3600.0 +_BROWSE_QUEUE_LOG_INTERVAL = 100 + + +class BrowseQueueItem(msgspec.Struct): + entity_type: str + mbid: str + name: str | None = None + artist_name: str | None = None + + +class AudioDBBrowseQueue: + def __init__(self) -> None: + self._queue: asyncio.Queue[BrowseQueueItem] = asyncio.Queue( + maxsize=_BROWSE_QUEUE_MAX_SIZE, + ) + self._recent: dict[str, float] = {} + self._consumer_task: asyncio.Task | None = None + + async def enqueue( + self, + entity_type: str, + mbid: str, + name: str | None = None, + artist_name: str | None = None, + ) -> None: + now = time.monotonic() + self._evict_expired(now) + + if mbid in self._recent: + logger.debug("audiodb.browse_queue action=dedup mbid=%s", mbid[:8]) + return + + if self._queue.full(): + logger.debug("audiodb.browse_queue action=full mbid=%s", mbid[:8]) + return + + item = BrowseQueueItem( + entity_type=entity_type, + mbid=mbid, + name=name, + artist_name=artist_name, + ) + self._queue.put_nowait(item) + self._recent[mbid] = now + + def _evict_expired(self, now: float) -> None: + cutoff = now - _BROWSE_QUEUE_DEDUP_TTL + expired = [k for k, ts in self._recent.items() if ts < cutoff] + for k in expired: + del self._recent[k] + + def start_consumer( + self, + audiodb_image_service: 'AudioDBImageService', + preferences_service: 'PreferencesService', + ) -> asyncio.Task: + self._consumer_task = asyncio.create_task( + self._process_queue(audiodb_image_service, preferences_service) + ) + from core.task_registry import TaskRegistry + TaskRegistry.get_instance().register("audiodb-browse-consumer", self._consumer_task) + return self._consumer_task + + async def _process_queue( + self, + svc: 'AudioDBImageService', + preferences_service: 'PreferencesService', + ) -> None: + processed = 0 + try: + while True: + item = await self._queue.get() + try: + settings = preferences_service.get_advanced_settings() + if not settings.audiodb_enabled: + continue + + if item.entity_type == "artist": + await svc.fetch_and_cache_artist_images( + item.mbid, item.name, is_monitored=False, + ) + elif item.entity_type == "album": + await svc.fetch_and_cache_album_images( + item.mbid, artist_name=item.artist_name, + album_name=item.name, is_monitored=False, + ) + + processed += 1 + logger.debug( + "audiodb.browse_queue action=processed entity_type=%s mbid=%s", + item.entity_type, item.mbid[:8], + ) + if processed % _BROWSE_QUEUE_LOG_INTERVAL == 0: + logger.info( + "audiodb.browse_queue processed=%d queue_depth=%d", + processed, self._queue.qsize(), + ) + except Exception as e: + logger.error( + "audiodb.browse_queue action=item_error entity_type=%s mbid=%s error=%s", + item.entity_type, + item.mbid[:8], + e, + exc_info=True, + ) + finally: + self._queue.task_done() + + await asyncio.sleep(_BROWSE_QUEUE_INTER_ITEM_DELAY) + except asyncio.CancelledError: + logger.info("AudioDB browse queue consumer cancelled (processed=%d)", processed) diff --git a/backend/services/audiodb_image_service.py b/backend/services/audiodb_image_service.py new file mode 100644 index 0000000..e1a73b3 --- /dev/null +++ b/backend/services/audiodb_image_service.py @@ -0,0 +1,312 @@ +import logging +from typing import Any + +import msgspec + +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.cache.memory_cache import CacheInterface +from repositories.audiodb_models import ( + AudioDBArtistImages, + AudioDBAlbumImages, +) +from repositories.audiodb_repository import AudioDBRepository +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +MEMORY_TTL_SECONDS = 300 + + +class AudioDBImageService: + + def __init__( + self, + audiodb_repo: AudioDBRepository, + disk_cache: DiskMetadataCache, + preferences_service: PreferencesService, + memory_cache: CacheInterface | None = None, + ): + self._repo = audiodb_repo + self._disk_cache = disk_cache + self._preferences_service = preferences_service + self._memory_cache = memory_cache + + def _get_settings(self): + return self._preferences_service.get_advanced_settings() + + def _mem_key(self, entity_type: str, mbid: str) -> str: + return f"audiodb_{entity_type}:{mbid}" + + async def _mem_get(self, entity_type: str, mbid: str) -> Any | None: + if self._memory_cache is None: + return None + return await self._memory_cache.get(self._mem_key(entity_type, mbid)) + + async def _mem_set(self, entity_type: str, mbid: str, value: Any) -> None: + if self._memory_cache is None: + return + await self._memory_cache.set(self._mem_key(entity_type, mbid), value, ttl_seconds=MEMORY_TTL_SECONDS) + + @staticmethod + def _resolve_ttl(is_monitored: bool, ttl_library: int, ttl_found: int) -> int: + return ttl_library if is_monitored else ttl_found + + async def get_cached_artist_images(self, mbid: str) -> AudioDBArtistImages | None: + if not self._get_settings().audiodb_enabled: + return None + if not mbid or not mbid.strip(): + return None + + mem_hit = await self._mem_get("artist", mbid) + if isinstance(mem_hit, AudioDBArtistImages): + return mem_hit + + raw = await self._disk_cache.get_audiodb_artist(mbid) + if raw is None: + logger.debug("audiodb.cache action=miss entity_type=artist mbid=%s lookup_source=mbid", mbid) + return None + + try: + images = msgspec.convert(raw, type=AudioDBArtistImages) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError): + logger.warning("audiodb.cache action=corrupt entity_type=artist mbid=%s lookup_source=mbid", mbid) + await self._disk_cache.delete_entity("audiodb_artist", mbid) + return None + + logger.debug( + "audiodb.cache action=hit entity_type=artist mbid=%s lookup_source=%s is_negative=%s", + mbid, images.lookup_source, images.is_negative, + ) + await self._mem_set("artist", mbid, images) + return images + + async def get_cached_album_images(self, mbid: str) -> AudioDBAlbumImages | None: + if not self._get_settings().audiodb_enabled: + return None + if not mbid or not mbid.strip(): + return None + + mem_hit = await self._mem_get("album", mbid) + if isinstance(mem_hit, AudioDBAlbumImages): + return mem_hit + + raw = await self._disk_cache.get_audiodb_album(mbid) + if raw is None: + logger.debug("audiodb.cache action=miss entity_type=album mbid=%s lookup_source=mbid", mbid) + return None + + try: + images = msgspec.convert(raw, type=AudioDBAlbumImages) + except (msgspec.ValidationError, msgspec.DecodeError, TypeError, KeyError): + logger.warning("audiodb.cache action=corrupt entity_type=album mbid=%s lookup_source=mbid", mbid) + await self._disk_cache.delete_entity("audiodb_album", mbid) + return None + + logger.debug( + "audiodb.cache action=hit entity_type=album mbid=%s lookup_source=%s is_negative=%s", + mbid, images.lookup_source, images.is_negative, + ) + await self._mem_set("album", mbid, images) + return images + + async def fetch_and_cache_artist_images( + self, + mbid: str, + name: str | None = None, + is_monitored: bool = False, + ) -> AudioDBArtistImages | None: + settings = self._get_settings() + if not settings.audiodb_enabled: + return None + if not mbid or not mbid.strip(): + return None + + cached = await self.get_cached_artist_images(mbid) + if cached is not None: + if not cached.is_negative: + return cached + if cached.lookup_source == "name": + return cached + should_fallback = is_monitored or settings.audiodb_name_search_fallback + if not name or not name.strip() or not should_fallback: + return cached + + ttl_found = settings.cache_ttl_audiodb_found + ttl_not_found = settings.cache_ttl_audiodb_not_found + ttl_library = settings.cache_ttl_audiodb_library + + cached_negative_mbid = cached is not None and cached.is_negative and cached.lookup_source == "mbid" + + if not cached_negative_mbid: + try: + resp = await self._repo.get_artist_by_mbid(mbid) + except Exception: # noqa: BLE001 + logger.warning("audiodb.cache action=fetch_error entity_type=artist mbid=%s lookup_source=mbid", mbid, exc_info=True) + return None + + if resp is not None: + images = AudioDBArtistImages.from_response(resp, lookup_source="mbid") + ttl = self._resolve_ttl(is_monitored, ttl_library, ttl_found) + await self._disk_cache.set_audiodb_artist( + mbid, images, is_monitored=is_monitored, ttl_seconds=ttl, + ) + await self._mem_set("artist", mbid, images) + logger.debug( + "audiodb.cache action=write entity_type=artist mbid=%s lookup_source=mbid is_negative=false ttl=%d", + mbid, ttl, + ) + return images + + negative = AudioDBArtistImages.negative(lookup_source="mbid") + await self._disk_cache.set_audiodb_artist( + mbid, negative, is_monitored=False, ttl_seconds=ttl_not_found, + ) + await self._mem_set("artist", mbid, negative) + logger.debug( + "audiodb.cache action=write entity_type=artist mbid=%s lookup_source=mbid is_negative=true ttl=%d", + mbid, ttl_not_found, + ) + else: + negative = cached + + if name and name.strip() and (is_monitored or settings.audiodb_name_search_fallback): + try: + name_resp = await self._repo.search_artist_by_name(name.strip()) + except Exception: # noqa: BLE001 + logger.warning("audiodb.cache action=fetch_error entity_type=artist mbid=%s lookup_source=name name=%s", mbid, name, exc_info=True) + return negative + + if name_resp is not None: + images = AudioDBArtistImages.from_response(name_resp, lookup_source="name") + ttl = self._resolve_ttl(is_monitored, ttl_library, ttl_found) + await self._disk_cache.set_audiodb_artist( + mbid, images, is_monitored=is_monitored, ttl_seconds=ttl, + ) + await self._mem_set("artist", mbid, images) + logger.debug( + "audiodb.cache action=write entity_type=artist mbid=%s is_negative=false lookup_source=name ttl=%d", + mbid, ttl, + ) + return images + + negative_name = AudioDBArtistImages.negative(lookup_source="name") + await self._disk_cache.set_audiodb_artist( + mbid, negative_name, is_monitored=False, ttl_seconds=ttl_not_found, + ) + await self._mem_set("artist", mbid, negative_name) + logger.debug( + "audiodb.cache action=write entity_type=artist mbid=%s is_negative=true lookup_source=name ttl=%d", + mbid, ttl_not_found, + ) + return negative_name + + return negative + + async def fetch_and_cache_album_images( + self, + mbid: str, + artist_name: str | None = None, + album_name: str | None = None, + is_monitored: bool = False, + ) -> AudioDBAlbumImages | None: + settings = self._get_settings() + if not settings.audiodb_enabled: + return None + if not mbid or not mbid.strip(): + return None + + cached = await self.get_cached_album_images(mbid) + if cached is not None: + if not cached.is_negative: + return cached + if cached.lookup_source == "name": + return cached + can_name_search = ( + artist_name and artist_name.strip() + and album_name and album_name.strip() + and (is_monitored or settings.audiodb_name_search_fallback) + ) + if not can_name_search: + return cached + + ttl_found = settings.cache_ttl_audiodb_found + ttl_not_found = settings.cache_ttl_audiodb_not_found + ttl_library = settings.cache_ttl_audiodb_library + + cached_negative_mbid = cached is not None and cached.is_negative and cached.lookup_source == "mbid" + + if not cached_negative_mbid: + try: + resp = await self._repo.get_album_by_mbid(mbid) + except Exception: # noqa: BLE001 + logger.warning("audiodb.cache action=fetch_error entity_type=album mbid=%s lookup_source=mbid", mbid, exc_info=True) + return None + + if resp is not None: + images = AudioDBAlbumImages.from_response(resp, lookup_source="mbid") + ttl = self._resolve_ttl(is_monitored, ttl_library, ttl_found) + await self._disk_cache.set_audiodb_album( + mbid, images, is_monitored=is_monitored, ttl_seconds=ttl, + ) + await self._mem_set("album", mbid, images) + logger.debug( + "audiodb.cache action=write entity_type=album mbid=%s lookup_source=mbid is_negative=false ttl=%d", + mbid, ttl, + ) + return images + + negative = AudioDBAlbumImages.negative(lookup_source="mbid") + await self._disk_cache.set_audiodb_album( + mbid, negative, is_monitored=False, ttl_seconds=ttl_not_found, + ) + await self._mem_set("album", mbid, negative) + logger.debug( + "audiodb.cache action=write entity_type=album mbid=%s lookup_source=mbid is_negative=true ttl=%d", + mbid, ttl_not_found, + ) + else: + negative = cached + + can_name_search = ( + artist_name and artist_name.strip() + and album_name and album_name.strip() + and (is_monitored or settings.audiodb_name_search_fallback) + ) + if can_name_search: + try: + name_resp = await self._repo.search_album_by_name( + artist_name.strip(), album_name.strip() + ) + except Exception: # noqa: BLE001 + logger.warning( + "audiodb.cache action=fetch_error entity_type=album mbid=%s lookup_source=name artist=%s album=%s", + mbid, artist_name, album_name, + exc_info=True, + ) + return negative + + if name_resp is not None: + images = AudioDBAlbumImages.from_response(name_resp, lookup_source="name") + ttl = self._resolve_ttl(is_monitored, ttl_library, ttl_found) + await self._disk_cache.set_audiodb_album( + mbid, images, is_monitored=is_monitored, ttl_seconds=ttl, + ) + await self._mem_set("album", mbid, images) + logger.debug( + "audiodb.cache action=write entity_type=album mbid=%s is_negative=false lookup_source=name ttl=%d", + mbid, ttl, + ) + return images + + negative_name = AudioDBAlbumImages.negative(lookup_source="name") + await self._disk_cache.set_audiodb_album( + mbid, negative_name, is_monitored=False, ttl_seconds=ttl_not_found, + ) + await self._mem_set("album", mbid, negative_name) + logger.debug( + "audiodb.cache action=write entity_type=album mbid=%s is_negative=true lookup_source=name ttl=%d", + mbid, ttl_not_found, + ) + return negative_name + + return negative diff --git a/backend/services/cache_service.py b/backend/services/cache_service.py new file mode 100644 index 0000000..c41fb6a --- /dev/null +++ b/backend/services/cache_service.py @@ -0,0 +1,307 @@ +import asyncio +import logging +import shutil +import subprocess +import time +from pathlib import Path + +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.cache_keys import AUDIODB_PREFIX +from infrastructure.persistence import LibraryDB +from infrastructure.cache.disk_cache import DiskMetadataCache +from api.v1.schemas.cache import CacheStats, CacheClearResponse + +logger = logging.getLogger(__name__) + +CACHE_DIR = Path("/app/cache/covers") + + +class CacheService: + + def __init__(self, cache: CacheInterface, library_db: LibraryDB, disk_cache: DiskMetadataCache): + self._cache = cache + self._library_db = library_db + self._disk_cache = disk_cache + self._cached_stats: CacheStats | None = None + self._stats_cache_time: float = 0.0 + self._stats_cache_ttl: float = 30.0 + self._stats_lock = asyncio.Lock() + + def _clear_genre_disk_cache(self) -> int: + try: + from core.dependencies import get_home_service + return get_home_service().clear_genre_disk_cache() + except Exception: # noqa: BLE001 + logger.debug("Genre disk cache cleanup skipped (home service unavailable)") + return 0 + + async def get_stats(self) -> CacheStats: + async with self._stats_lock: + now = time.time() + if self._cached_stats and (now - self._stats_cache_time) < self._stats_cache_ttl: + return self._cached_stats + + memory_entries = self._cache.size() + memory_bytes = self._cache.estimate_memory_bytes() + memory_mb = memory_bytes / (1024 * 1024) + + metadata_stats = self._disk_cache.get_stats() + metadata_count = metadata_stats['total_count'] + metadata_albums = metadata_stats['album_count'] + metadata_artists = metadata_stats['artist_count'] + + disk_count = 0 + disk_bytes = 0 + + if CACHE_DIR.exists(): + du_available = shutil.which('du') is not None + if du_available: + try: + result = await asyncio.to_thread( + subprocess.run, + ['du', '-sb', str(CACHE_DIR)], + capture_output=True, + text=True, + timeout=5.0, + ) + if result.returncode == 0: + disk_bytes = int(result.stdout.split()[0]) + result = await asyncio.to_thread( + subprocess.run, + ['find', str(CACHE_DIR), '-type', 'f'], + capture_output=True, + text=True, + timeout=5.0, + ) + if result.returncode == 0: + lines = result.stdout.strip() + disk_count = len(lines.split('\n')) if lines else 0 + logger.debug(f"Disk cache stats calculated via subprocess: {disk_count} files, {disk_bytes} bytes") + else: + du_available = False + except (subprocess.TimeoutExpired, subprocess.SubprocessError, ValueError) as e: + logger.warning(f"Subprocess disk stats failed, falling back to Python: {e}") + du_available = False + + if not du_available: + def _python_scan() -> tuple[int, int]: + count = 0 + total = 0 + for file_path in CACHE_DIR.rglob("*"): + if file_path.is_file(): + count += 1 + total += file_path.stat().st_size + return count, total + disk_count, disk_bytes = await asyncio.to_thread(_python_scan) + + disk_mb = disk_bytes / (1024 * 1024) + + lib_stats = await self._library_db.get_stats() + lib_bytes = lib_stats['db_size_bytes'] + lib_mb = lib_bytes / (1024 * 1024) + + total_bytes = memory_bytes + disk_bytes + lib_bytes + total_mb = total_bytes / (1024 * 1024) + + stats = CacheStats( + memory_entries=memory_entries, + memory_size_bytes=memory_bytes, + memory_size_mb=round(memory_mb, 2), + disk_metadata_count=metadata_count, + disk_metadata_albums=metadata_albums, + disk_metadata_artists=metadata_artists, + disk_cover_count=disk_count, + disk_cover_size_bytes=disk_bytes, + disk_cover_size_mb=round(disk_mb, 2), + library_db_artist_count=lib_stats['artist_count'], + library_db_album_count=lib_stats['album_count'], + library_db_size_bytes=lib_bytes, + library_db_size_mb=round(lib_mb, 2), + library_db_last_sync=lib_stats.get('last_sync'), + total_size_bytes=total_bytes, + total_size_mb=round(total_mb, 2), + disk_audiodb_artist_count=metadata_stats.get('audiodb_artist_count', 0), + disk_audiodb_album_count=metadata_stats.get('audiodb_album_count', 0), + ) + + self._cached_stats = stats + self._stats_cache_time = now + + return stats + + async def clear_memory_cache(self) -> CacheClearResponse: + try: + entries_before = self._cache.size() + await self._cache.clear() + + self._cached_stats = None + + logger.info(f"Cleared {entries_before} memory cache entries") + + return CacheClearResponse( + success=True, + message=f"Successfully cleared {entries_before} memory cache entries", + cleared_memory_entries=entries_before, + cleared_disk_files=0 + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear memory cache: {e}") + return CacheClearResponse( + success=False, + message=f"Failed to clear memory cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0 + ) + + async def clear_disk_cache(self) -> CacheClearResponse: + try: + metadata_stats = self._disk_cache.get_stats() + metadata_count = metadata_stats['total_count'] + await self._disk_cache.clear_all() + + files_cleared = 0 + if CACHE_DIR.exists(): + for file_path in CACHE_DIR.rglob("*"): + if file_path.is_file(): + file_path.unlink() + files_cleared += 1 + + logger.info(f"Cleared {metadata_count} metadata files and {files_cleared} cover image files from disk") + + files_cleared += self._clear_genre_disk_cache() + self._cached_stats = None + + return CacheClearResponse( + success=True, + message=f"Successfully cleared {metadata_count} metadata files and {files_cleared} cover images from disk", + cleared_memory_entries=0, + cleared_disk_files=files_cleared + metadata_count + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear disk cache: {e}") + return CacheClearResponse( + success=False, + message=f"Failed to clear disk cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0 + ) + + async def clear_all_cache(self) -> CacheClearResponse: + try: + memory_entries = self._cache.size() + await self._cache.clear() + + metadata_stats = self._disk_cache.get_stats() + metadata_count = metadata_stats['total_count'] + await self._disk_cache.clear_all() + + disk_files = 0 + if CACHE_DIR.exists(): + for file_path in CACHE_DIR.rglob("*"): + if file_path.is_file(): + file_path.unlink() + disk_files += 1 + + disk_files += self._clear_genre_disk_cache() + self._cached_stats = None + + logger.info(f"Cleared all cache: {memory_entries} memory entries, {metadata_count} metadata files, {disk_files} cover files (library DB preserved)") + + return CacheClearResponse( + success=True, + message=f"Successfully cleared {memory_entries} memory entries, {metadata_count} metadata files, and {disk_files} cover files (library database preserved)", + cleared_memory_entries=memory_entries, + cleared_disk_files=disk_files + metadata_count + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear all cache: {e}") + return CacheClearResponse( + success=False, + message=f"Couldn't clear the cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0 + ) + + async def clear_covers_cache(self) -> CacheClearResponse: + try: + files_cleared = 0 + if CACHE_DIR.exists(): + for file_path in CACHE_DIR.rglob("*"): + if file_path.is_file(): + file_path.unlink() + files_cleared += 1 + + self._cached_stats = None + + logger.info(f"Cleared {files_cleared} cover image files from disk") + + return CacheClearResponse( + success=True, + message=f"Successfully cleared {files_cleared} cover images", + cleared_memory_entries=0, + cleared_disk_files=files_cleared + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear covers cache: {e}") + return CacheClearResponse( + success=False, + message=f"Failed to clear covers cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0 + ) + + async def clear_library_cache(self) -> CacheClearResponse: + try: + lib_stats = await self._library_db.get_stats() + artists_before = lib_stats['artist_count'] + albums_before = lib_stats['album_count'] + + await self._library_db.clear() + + self._cached_stats = None + + logger.info(f"Cleared library cache: {artists_before} artists, {albums_before} albums") + + return CacheClearResponse( + success=True, + message=f"Successfully cleared library database: {artists_before} artists, {albums_before} albums", + cleared_memory_entries=0, + cleared_disk_files=0, + cleared_library_artists=artists_before, + cleared_library_albums=albums_before + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear library cache: {e}") + return CacheClearResponse( + success=False, + message=f"Failed to clear library cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0 + ) + + async def clear_audiodb(self) -> CacheClearResponse: + try: + stats_before = self._disk_cache.get_stats() + count_before = stats_before.get('audiodb_artist_count', 0) + stats_before.get('audiodb_album_count', 0) + + await self._disk_cache.clear_audiodb() + memory_cleared = await self._cache.clear_prefix(AUDIODB_PREFIX) + + self._cached_stats = None + + logger.info(f"Cleared AudioDB cache: {count_before} disk entries, {memory_cleared} memory entries") + + return CacheClearResponse( + success=True, + message=f"Successfully cleared {count_before} AudioDB cache entries and {memory_cleared} memory entries", + cleared_memory_entries=memory_cleared, + cleared_disk_files=count_before, + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear AudioDB cache: {e}") + return CacheClearResponse( + success=False, + message=f"Failed to clear AudioDB cache: {str(e)}", + cleared_memory_entries=0, + cleared_disk_files=0, + ) diff --git a/backend/services/cache_status_service.py b/backend/services/cache_status_service.py new file mode 100644 index 0000000..e43d539 --- /dev/null +++ b/backend/services/cache_status_service.py @@ -0,0 +1,373 @@ +import asyncio +import logging +import threading +import time +from typing import Optional, TYPE_CHECKING + +import msgspec + +if TYPE_CHECKING: + from infrastructure.persistence import SyncStateStore + +logger = logging.getLogger(__name__) + + +class CacheSyncProgress(msgspec.Struct): + is_syncing: bool + phase: Optional[str] + total_items: int + processed_items: int + current_item: Optional[str] + started_at: Optional[float] + error_message: Optional[str] = None + total_artists: int = 0 + processed_artists: int = 0 + total_albums: int = 0 + processed_albums: int = 0 + + @property + def progress_percent(self) -> int: + if self.total_items == 0: + return 0 + return int((self.processed_items / self.total_items) * 100) + + +class CacheStatusService: + + _instance: Optional['CacheStatusService'] = None + _creation_lock = threading.Lock() + + def __new__(cls, sync_state_store: Optional['SyncStateStore'] = None): + with cls._creation_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialize(sync_state_store) + elif sync_state_store is not None and cls._instance._sync_state_store is None: + cls._instance._sync_state_store = sync_state_store + return cls._instance + + def _initialize(self, sync_state_store: Optional['SyncStateStore'] = None): + self._sync_state_store = sync_state_store + self._progress = CacheSyncProgress( + is_syncing=False, + phase=None, + total_items=0, + processed_items=0, + current_item=None, + started_at=None, + error_message=None, + total_artists=0, + processed_artists=0, + total_albums=0, + processed_albums=0 + ) + self._cancel_event = asyncio.Event() + self._current_task: Optional[asyncio.Task] = None + self._state_lock = asyncio.Lock() + self._sse_subscribers: set[asyncio.Queue] = set() + self._sse_lock = threading.Lock() + self._last_persist_time: float = 0.0 + self._last_broadcast_time: float = 0.0 + self._persist_item_counter: int = 0 + + def set_sync_state_store(self, sync_state_store: 'SyncStateStore'): + self._sync_state_store = sync_state_store + + def subscribe_sse(self) -> asyncio.Queue: + queue: asyncio.Queue = asyncio.Queue(maxsize=100) + with self._sse_lock: + self._sse_subscribers.add(queue) + return queue + + def unsubscribe_sse(self, queue: asyncio.Queue) -> None: + with self._sse_lock: + self._sse_subscribers.discard(queue) + + async def broadcast_progress(self) -> None: + progress = self.get_progress() + data = msgspec.json.encode({ + 'is_syncing': progress.is_syncing, + 'phase': progress.phase, + 'total_items': progress.total_items, + 'processed_items': progress.processed_items, + 'progress_percent': progress.progress_percent, + 'current_item': progress.current_item, + 'started_at': progress.started_at, + 'error_message': progress.error_message, + 'total_artists': progress.total_artists, + 'processed_artists': progress.processed_artists, + 'total_albums': progress.total_albums, + 'processed_albums': progress.processed_albums + }).decode("utf-8") + with self._sse_lock: + dead_queues = [] + for queue in self._sse_subscribers: + try: + queue.put_nowait(data) + except asyncio.QueueFull: + try: + while not queue.empty(): + queue.get_nowait() + queue.put_nowait(data) + except Exception: # noqa: BLE001 + dead_queues.append(queue) + for q in dead_queues: + self._sse_subscribers.discard(q) + + async def start_sync(self, phase: str, total_items: int, total_artists: int = 0, total_albums: int = 0): + async with self._state_lock: + self._cancel_event.clear() + self._last_persist_time = 0.0 + self._last_broadcast_time = 0.0 + self._persist_item_counter = 0 + started_at = time.time() + self._progress = CacheSyncProgress( + is_syncing=True, + phase=phase, + total_items=total_items, + processed_items=0, + current_item=None, + started_at=started_at, + error_message=None, + total_artists=total_artists, + processed_artists=0, + total_albums=total_albums, + processed_albums=0 + ) + logger.info(f"Cache sync started: {phase} ({total_items} items)") + + if self._sync_state_store: + try: + await self._sync_state_store.save_sync_state( + status='running', + phase=phase, + total_artists=total_artists, + total_albums=total_albums, + started_at=started_at + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to persist sync state: {e}") + + await self.broadcast_progress() + + _BROADCAST_THROTTLE_SECONDS = 0.3 + + async def update_progress( + self, + processed: int, + current_item: Optional[str] = None, + processed_artists: Optional[int] = None, + processed_albums: Optional[int] = None + ): + async with self._state_lock: + if processed >= self._progress.processed_items: + self._progress.processed_items = processed + self._progress.current_item = current_item + if processed_artists is not None: + self._progress.processed_artists = processed_artists + if processed_albums is not None: + self._progress.processed_albums = processed_albums + + now = time.time() + is_final = processed >= self._progress.total_items + if is_final or (now - self._last_broadcast_time) >= self._BROADCAST_THROTTLE_SECONDS: + self._last_broadcast_time = now + await self.broadcast_progress() + + async def update_phase(self, phase: str, total_items: int): + async with self._state_lock: + self._progress.phase = phase + self._progress.total_items = total_items + self._progress.processed_items = 0 + self._progress.current_item = None + + if self._sync_state_store: + try: + await self._sync_state_store.save_sync_state( + status='running', + phase=phase, + total_artists=self._progress.total_artists, + processed_artists=self._progress.processed_artists, + total_albums=self._progress.total_albums if phase == 'albums' else total_items, + processed_albums=self._progress.processed_albums, + started_at=self._progress.started_at + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to persist phase update: {e}") + + await self.broadcast_progress() + + async def skip_phase(self, phase: str): + """Broadcast a phase with 0 items so the frontend sees it as skipped.""" + async with self._state_lock: + self._progress.phase = phase + self._progress.total_items = 0 + self._progress.processed_items = 0 + self._progress.current_item = None + await self.broadcast_progress() + logger.info(f"Phase skipped (already cached): {phase}") + await asyncio.sleep(0.5) + + _PERSIST_INTERVAL_SECONDS = 5.0 + _PERSIST_ITEM_INTERVAL = 10 + + async def persist_progress(self, force: bool = False): + if not self._progress.is_syncing: + return + if self.is_cancelled(): + return + + self._persist_item_counter += 1 + now = time.time() + elapsed = now - self._last_persist_time + + if not force and elapsed < self._PERSIST_INTERVAL_SECONDS and self._persist_item_counter < self._PERSIST_ITEM_INTERVAL: + return + + self._persist_item_counter = 0 + self._last_persist_time = now + + if self._sync_state_store: + try: + await self._sync_state_store.save_sync_state( + status='running', + phase=self._progress.phase, + total_artists=self._progress.total_artists, + processed_artists=self._progress.processed_artists, + total_albums=self._progress.total_albums, + processed_albums=self._progress.processed_albums, + current_item=self._progress.current_item, + started_at=self._progress.started_at + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to persist progress: {e}") + + async def complete_sync(self, error_message: Optional[str] = None): + async with self._state_lock: + status = 'failed' if error_message else 'completed' + logger.info(f"Cache sync {status}: {self._progress.phase}") + + if self._sync_state_store: + try: + await self._sync_state_store.save_sync_state( + status=status, + phase=self._progress.phase, + total_artists=self._progress.total_artists, + processed_artists=self._progress.processed_artists, + total_albums=self._progress.total_albums, + processed_albums=self._progress.processed_albums, + error_message=error_message, + started_at=self._progress.started_at + ) + await self._sync_state_store.clear_sync_state() + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to persist completion: {e}") + + self._progress = CacheSyncProgress( + is_syncing=False, + phase=None, + total_items=0, + processed_items=0, + current_item=None, + started_at=None, + error_message=error_message, + total_artists=0, + processed_artists=0, + total_albums=0, + processed_albums=0 + ) + + await self.broadcast_progress() + + def get_progress(self) -> CacheSyncProgress: + return self._progress + + def is_syncing(self) -> bool: + return self._progress.is_syncing + + async def cancel_current_sync(self): + async with self._state_lock: + if self._progress.is_syncing: + logger.warning(f"Cancelling in-progress sync: phase={self._progress.phase}, progress={self._progress.processed_items}/{self._progress.total_items}") + self._cancel_event.set() + + if self._sync_state_store: + try: + await self._sync_state_store.save_sync_state( + status='cancelled', + phase=self._progress.phase, + total_artists=self._progress.total_artists, + processed_artists=self._progress.processed_artists, + total_albums=self._progress.total_albums, + processed_albums=self._progress.processed_albums, + started_at=self._progress.started_at + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to persist cancellation: {e}") + + self._progress = CacheSyncProgress( + is_syncing=False, + phase=None, + total_items=0, + processed_items=0, + current_item=None, + started_at=None, + error_message=None, + total_artists=0, + processed_artists=0, + total_albums=0, + processed_albums=0 + ) + + await self.broadcast_progress() + + def is_cancelled(self) -> bool: + return self._cancel_event.is_set() + + def set_current_task(self, task: Optional[asyncio.Task]): + self._current_task = task + + async def wait_for_completion(self): + task = self._current_task + if task and not task.done(): + try: + await asyncio.wait_for(task, timeout=5.0) + except asyncio.TimeoutError: + logger.warning("Sync task did not complete within timeout, forcing cancellation") + if not task.done(): + task.cancel() + except Exception as e: # noqa: BLE001 + logger.error(f"Error waiting for sync completion: {e}") + + def can_start_sync(self) -> bool: + return not self._progress.is_syncing + + async def restore_from_persistence(self) -> Optional[dict]: + if not self._sync_state_store: + return None + + try: + state = await self._sync_state_store.get_sync_state() + if state and state.get('status') == 'running': + logger.info(f"Found interrupted sync: phase={state.get('phase')}, " + f"artists={state.get('processed_artists')}/{state.get('total_artists')}, " + f"albums={state.get('processed_albums')}/{state.get('total_albums')}") + + self._progress = CacheSyncProgress( + is_syncing=True, + phase=state.get('phase'), + total_items=state.get('total_albums') if state.get('phase') == 'albums' else state.get('total_artists'), + processed_items=state.get('processed_albums') if state.get('phase') == 'albums' else state.get('processed_artists'), + current_item=state.get('current_item'), + started_at=state.get('started_at'), + error_message=None, + total_artists=state.get('total_artists', 0), + processed_artists=state.get('processed_artists', 0), + total_albums=state.get('total_albums', 0), + processed_albums=state.get('processed_albums', 0) + ) + return state + return None + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to restore from persistence: {e}") + return None diff --git a/backend/services/discover/__init__.py b/backend/services/discover/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/discover/enrichment_service.py b/backend/services/discover/enrichment_service.py new file mode 100644 index 0000000..84ef643 --- /dev/null +++ b/backend/services/discover/enrichment_service.py @@ -0,0 +1,237 @@ +import asyncio +import logging +from typing import Any +from urllib.parse import quote_plus + +from api.v1.schemas.discover import DiscoverQueueEnrichment +from infrastructure.cache.cache_keys import DISCOVER_QUEUE_ENRICH_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.validators import clean_lastfm_bio +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from services.discover.integration_helpers import IntegrationHelpers + +logger = logging.getLogger(__name__) + + +class QueueEnrichmentService: + def __init__( + self, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + preferences_service: Any, + integration: IntegrationHelpers, + memory_cache: CacheInterface | None = None, + wikidata_repo: Any = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + ) -> None: + self._mb_repo = musicbrainz_repo + self._lb_repo = listenbrainz_repo + self._preferences = preferences_service + self._integration = integration + self._memory_cache = memory_cache + self._wikidata_repo = wikidata_repo + self._lfm_repo = lastfm_repo + self._enrich_in_flight: dict[str, asyncio.Future[DiscoverQueueEnrichment]] = {} + + async def enrich_queue_item(self, release_group_mbid: str) -> DiscoverQueueEnrichment: + cache_key = f"{DISCOVER_QUEUE_ENRICH_PREFIX}{release_group_mbid}" + if self._memory_cache: + cached = await self._memory_cache.get(cache_key) + if cached is not None and isinstance(cached, DiscoverQueueEnrichment): + return cached + + if release_group_mbid in self._enrich_in_flight: + return await asyncio.shield(self._enrich_in_flight[release_group_mbid]) + + loop = asyncio.get_running_loop() + future: asyncio.Future[DiscoverQueueEnrichment] = loop.create_future() + self._enrich_in_flight[release_group_mbid] = future + try: + result = await self._do_enrich_queue_item(release_group_mbid, cache_key) + if not future.done(): + future.set_result(result) + return result + except BaseException as exc: + if not future.done(): + future.set_exception(exc) + raise + finally: + self._enrich_in_flight.pop(release_group_mbid, None) + + async def _do_enrich_queue_item( + self, release_group_mbid: str, cache_key: str + ) -> DiscoverQueueEnrichment: + + enrichment = DiscoverQueueEnrichment() + + rg_data = await self._mb_repo.get_release_group_by_id( + release_group_mbid, + includes=["artist-credits", "releases", "tags", "url-rels"], + ) + + artist_mbid = "" + youtube_url = None + + if rg_data: + tags_raw = rg_data.get("tags", []) + enrichment.tags = [t.get("name", "") for t in tags_raw if t.get("name")][:10] + + youtube_raw = self._mb_repo.extract_youtube_url_from_relations(rg_data) + if youtube_raw: + youtube_url = self._mb_repo.youtube_url_to_embed(youtube_raw) + + ac_list = rg_data.get("artist-credit", []) + for ac in ac_list: + a = ac.get("artist", {}) if isinstance(ac, dict) else {} + if a.get("id"): + artist_mbid = a["id"] + break + enrichment.artist_mbid = artist_mbid or None + + releases = rg_data.get("releases") or rg_data.get("release-list", []) + if releases: + first_release = releases[0] + enrichment.release_date = first_release.get("date") + + if not youtube_url: + release_data = await self._mb_repo.get_release_by_id( + first_release["id"], + includes=["recordings", "url-rels"], + ) + if release_data: + yt_raw = self._mb_repo.extract_youtube_url_from_relations(release_data) + if yt_raw: + youtube_url = self._mb_repo.youtube_url_to_embed(yt_raw) + + if not youtube_url: + tracks = release_data.get("media") or release_data.get("medium-list", []) + rec_ids: list[str] = [] + for medium in tracks: + for track in medium.get("tracks") or medium.get("track-list", []): + rec_id = track.get("recording", {}).get("id") + if rec_id: + rec_ids.append(rec_id) + if len(rec_ids) >= 3: + break + if len(rec_ids) >= 3: + break + if rec_ids: + rec_results = await asyncio.gather( + *[ + self._mb_repo.get_recording_by_id(rid, includes=["url-rels"]) + for rid in rec_ids + ], + return_exceptions=True, + ) + for rec_data in rec_results: + if isinstance(rec_data, Exception) or not rec_data: + continue + yt_raw = self._mb_repo.extract_youtube_url_from_relations(rec_data) + if yt_raw: + youtube_url = self._mb_repo.youtube_url_to_embed(yt_raw) + break + + enrichment.youtube_url = youtube_url + + if not youtube_url: + yt_settings = self._preferences.get_youtube_connection() + enrichment.youtube_search_available = yt_settings.enabled and yt_settings.api_enabled and yt_settings.has_valid_api_key() + + album_name = rg_data.get("title", "") if rg_data else "" + artist_name_for_search = "" + if rg_data: + ac_list = rg_data.get("artist-credit", []) + for ac in ac_list: + a = ac.get("artist", {}) if isinstance(ac, dict) else {} + if a.get("name"): + artist_name_for_search = a["name"] + break + enrichment.youtube_search_url = ( + f"https://www.youtube.com/results?search_query={quote_plus(f'{artist_name_for_search} {album_name}')}" + ) + + async def _get_artist_and_bio(): + if not artist_mbid: + return + try: + mb_artist = await self._mb_repo.get_artist_by_id(artist_mbid) + if mb_artist: + enrichment.country = mb_artist.get("country") or mb_artist.get("area", {}).get("name", "") + if self._wikidata_repo: + url_rels = mb_artist.get("relations", []) + wiki_url = None + for rel in url_rels: + if rel.get("type") in ("wikipedia", "wikidata"): + url_obj = rel.get("url", {}) + wiki_url = url_obj.get("resource", "") if isinstance(url_obj, dict) else "" + break + if wiki_url: + bio = await self._wikidata_repo.get_wikipedia_extract(wiki_url) + if bio: + enrichment.artist_description = bio + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get artist MB data: {e}") + + async def _get_listen_count(): + try: + counts = await self._lb_repo.get_release_group_popularity_batch( + [release_group_mbid] + ) + if counts: + enrichment.listen_count = counts.get(release_group_mbid) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get listen count: {e}") + + async def _apply_lastfm_fallback(): + if not self._lfm_repo or not self._integration.is_lastfm_enabled(): + return + if not album_name or not artist_name_for_search: + return + + try: + album_info = await self._lfm_repo.get_album_info( + artist=artist_name_for_search, + album=album_name, + ) + if album_info: + if not enrichment.tags and album_info.tags: + enrichment.tags = [tag.name for tag in album_info.tags if tag.name][:10] + if not enrichment.artist_description and album_info.summary: + cleaned_summary = clean_lastfm_bio(album_info.summary) + if cleaned_summary: + enrichment.artist_description = cleaned_summary + except Exception as e: # noqa: BLE001 + logger.debug("Failed Last.fm album fallback for discover queue: %s", e) + + if enrichment.artist_description and enrichment.tags: + return + + try: + artist_info = await self._lfm_repo.get_artist_info( + artist=artist_name_for_search, + mbid=artist_mbid or None, + ) + if not artist_info: + return + if not enrichment.artist_mbid and artist_info.mbid: + enrichment.artist_mbid = artist_info.mbid + if not enrichment.tags and artist_info.tags: + enrichment.tags = [tag.name for tag in artist_info.tags if tag.name][:10] + if not enrichment.artist_description and artist_info.bio_summary: + cleaned_bio = clean_lastfm_bio(artist_info.bio_summary) + if cleaned_bio: + enrichment.artist_description = cleaned_bio + except Exception as e: # noqa: BLE001 + logger.debug("Failed Last.fm artist fallback for discover queue: %s", e) + + await asyncio.gather(_get_artist_and_bio(), _get_listen_count(), _apply_lastfm_fallback()) + + if self._memory_cache: + enrich_ttl = self._integration.get_queue_settings().enrich_ttl + await self._memory_cache.set(cache_key, enrichment, enrich_ttl) + + return enrichment diff --git a/backend/services/discover/facade.py b/backend/services/discover/facade.py new file mode 100644 index 0000000..44ec73f --- /dev/null +++ b/backend/services/discover/facade.py @@ -0,0 +1,136 @@ +"""Thin facade preserving the original DiscoverService public API. + +All business logic lives in sub-services under ``services.discover.*``. +This class assembles them and delegates every public method call. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from api.v1.schemas.discover import ( + DiscoverQueueEnrichment, + DiscoverQueueResponse, + DiscoverIgnoredRelease, +) +from api.v1.schemas.home import DiscoverPreview +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.persistence import LibraryDB, MBIDStore +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + JellyfinRepositoryProtocol, + LidarrRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from services.discover.enrichment_service import QueueEnrichmentService +from services.discover.homepage_service import DiscoverHomepageService +from services.discover.integration_helpers import IntegrationHelpers +from services.discover.mbid_resolution_service import MbidResolutionService +from services.discover.queue_service import DiscoverQueueService +from services.preferences_service import PreferencesService + + +class DiscoverService: + """Drop-in replacement for the original monolith. + + Constructor signature is identical to the old class so that + ``dependencies.py`` needs only an import-path change. + """ + + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + jellyfin_repo: JellyfinRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + preferences_service: PreferencesService, + memory_cache: CacheInterface | None = None, + library_db: LibraryDB | None = None, + mbid_store: MBIDStore | None = None, + wikidata_repo: Any = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + audiodb_image_service: Any = None, + ): + self._integration = IntegrationHelpers(preferences_service) + + self._mbid_resolution = MbidResolutionService( + musicbrainz_repo=musicbrainz_repo, + lidarr_repo=lidarr_repo, + listenbrainz_repo=listenbrainz_repo, + library_db=library_db, + mbid_store=mbid_store, + ) + + self._enrichment = QueueEnrichmentService( + musicbrainz_repo=musicbrainz_repo, + listenbrainz_repo=listenbrainz_repo, + preferences_service=preferences_service, + integration=self._integration, + memory_cache=memory_cache, + wikidata_repo=wikidata_repo, + lastfm_repo=lastfm_repo, + ) + + self._queue = DiscoverQueueService( + listenbrainz_repo=listenbrainz_repo, + jellyfin_repo=jellyfin_repo, + musicbrainz_repo=musicbrainz_repo, + integration=self._integration, + mbid_resolution=self._mbid_resolution, + library_db=library_db, + mbid_store=mbid_store, + lastfm_repo=lastfm_repo, + ) + + self._homepage = DiscoverHomepageService( + listenbrainz_repo=listenbrainz_repo, + jellyfin_repo=jellyfin_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=musicbrainz_repo, + integration=self._integration, + mbid_resolution=self._mbid_resolution, + memory_cache=memory_cache, + lastfm_repo=lastfm_repo, + audiodb_image_service=audiodb_image_service, + ) + + + async def get_discover_data(self, source: str | None = None): + return await self._homepage.get_discover_data(source) + + async def get_discover_preview(self) -> DiscoverPreview | None: + return await self._homepage.get_discover_preview() + + async def refresh_discover_data(self) -> None: + return await self._homepage.refresh_discover_data() + + async def warm_cache(self, source: str | None = None) -> None: + return await self._homepage.warm_cache(source) + + async def build_discover_data(self, source: str | None = None): + return await self._homepage.build_discover_data(source) + + + async def build_queue(self, count: int | None = None, source: str | None = None) -> DiscoverQueueResponse: + return await self._queue.build_queue(count, source) + + async def validate_queue_mbids(self, mbids: list[str]) -> list[str]: + return await self._queue.validate_queue_mbids(mbids) + + async def ignore_release( + self, release_group_mbid: str, artist_mbid: str, release_name: str, artist_name: str + ) -> None: + return await self._queue.ignore_release(release_group_mbid, artist_mbid, release_name, artist_name) + + async def get_ignored_releases(self) -> list[DiscoverIgnoredRelease]: + return await self._queue.get_ignored_releases() + + + async def enrich_queue_item(self, release_group_mbid: str) -> DiscoverQueueEnrichment: + return await self._enrichment.enrich_queue_item(release_group_mbid) + + + def resolve_source(self, source: str | None) -> str: + return self._integration.resolve_source(source) diff --git a/backend/services/discover/homepage_service.py b/backend/services/discover/homepage_service.py new file mode 100644 index 0000000..da36e71 --- /dev/null +++ b/backend/services/discover/homepage_service.py @@ -0,0 +1,1102 @@ +import asyncio +import logging +from datetime import datetime, timezone +from typing import Any + +from api.v1.schemas.discover import ( + DiscoverResponse, + BecauseYouListenTo, + DiscoverIntegrationStatus, +) +from api.v1.schemas.home import ( + HomeSection, + HomeArtist, + HomeAlbum, + HomeGenre, + ServicePrompt, + DiscoverPreview, +) +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cover_urls import prefer_artist_cover_url +from infrastructure.serialization import clone_with_updates +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + JellyfinRepositoryProtocol, + LidarrRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from repositories.listenbrainz_models import ListenBrainzArtist +from services.home_transformers import HomeDataTransformers +from services.discover.integration_helpers import IntegrationHelpers +from services.discover.mbid_resolution_service import MbidResolutionService +from services.weekly_exploration_service import WeeklyExplorationService + +logger = logging.getLogger(__name__) + +DISCOVER_CACHE_TTL = 43200 # 12 hours +REDISCOVER_PLAY_THRESHOLD = 5 +REDISCOVER_MONTHS_AGO = 3 +MISSING_ESSENTIALS_MIN_ALBUMS = 3 +MISSING_ESSENTIALS_MAX_PER_ARTIST = 3 +VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377" + + +class DiscoverHomepageService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + jellyfin_repo: JellyfinRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + integration: IntegrationHelpers, + mbid_resolution: MbidResolutionService, + memory_cache: CacheInterface | None = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + audiodb_image_service: Any = None, + ) -> None: + self._lb_repo = listenbrainz_repo + self._jf_repo = jellyfin_repo + self._lidarr_repo = lidarr_repo + self._mb_repo = musicbrainz_repo + self._integration = integration + self._mbid = mbid_resolution + self._memory_cache = memory_cache + self._lfm_repo = lastfm_repo + self._audiodb_image_service = audiodb_image_service + self._transformers = HomeDataTransformers(jellyfin_repo) + self._weekly_exploration = WeeklyExplorationService(listenbrainz_repo, musicbrainz_repo) + self._building = False + + async def get_discover_data(self, source: str | None = None) -> DiscoverResponse: + resolved_source = self._integration.resolve_source(source) + if self._memory_cache: + cache_key = self._integration.get_discover_cache_key(source) + cached = await self._memory_cache.get(cache_key) + if cached is not None: + if isinstance(cached, DiscoverResponse): + return clone_with_updates(cached, {"refreshing": self._building}) + if not self._building: + from core.task_registry import TaskRegistry + registry = TaskRegistry.get_instance() + if not registry.is_running("discover-homepage-warm"): + task = asyncio.create_task(self.warm_cache(source=resolved_source)) + try: + registry.register("discover-homepage-warm", task) + except RuntimeError: + pass + return DiscoverResponse( + integration_status=self._integration.get_integration_status(), + service_prompts=self._build_service_prompts(), + refreshing=True, + ) + + async def get_discover_preview(self) -> DiscoverPreview | None: + if not self._memory_cache: + return None + resolved = self._integration.resolve_source(None) + cache_key = self._integration.get_discover_cache_key(resolved) + cached = await self._memory_cache.get(cache_key) + if not cached or not isinstance(cached, DiscoverResponse): + return None + if not cached.because_you_listen_to: + return None + first = cached.because_you_listen_to[0] + preview_items = [ + item for item in first.section.items[:5] + if isinstance(item, HomeArtist) + ] + return DiscoverPreview( + seed_artist=first.seed_artist, + seed_artist_mbid=first.seed_artist_mbid, + items=preview_items, + ) + + async def refresh_discover_data(self) -> None: + if self._building: + return + from core.task_registry import TaskRegistry + registry = TaskRegistry.get_instance() + if not registry.is_running("discover-homepage-warm"): + task = asyncio.create_task(self.warm_cache()) + try: + registry.register("discover-homepage-warm", task) + except RuntimeError: + pass + + async def warm_cache(self, source: str | None = None) -> None: + if self._building: + return + self._building = True + try: + resolved = self._integration.resolve_source(source) + response = await self.build_discover_data(source=resolved) + if self._memory_cache and self._has_meaningful_content(response): + cache_key = self._integration.get_discover_cache_key(resolved) + await self._memory_cache.set(cache_key, response, DISCOVER_CACHE_TTL) + logger.info("Discover data built and cached for source=%s", resolved) + elif not self._has_meaningful_content(response): + logger.warning("Discover build produced no meaningful content, keeping existing cache") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to build discover data: {e}") + finally: + self._building = False + + def _has_meaningful_content(self, response: DiscoverResponse) -> bool: + return bool( + response.because_you_listen_to + or response.fresh_releases + or response.globally_trending + or response.artists_you_might_like + or response.popular_in_your_genres + or response.missing_essentials + or response.rediscover + or response.lastfm_weekly_artist_chart + or response.lastfm_weekly_album_chart + or response.lastfm_recent_scrobbles + or response.weekly_exploration + ) + + async def build_discover_data(self, source: str | None = None) -> DiscoverResponse: + resolved_source = self._integration.resolve_source(source) + lb_enabled = self._integration.is_listenbrainz_enabled() + jf_enabled = self._integration.is_jellyfin_enabled() + lidarr_configured = self._integration.is_lidarr_configured() + lfm_enabled = self._integration.is_lastfm_enabled() + username = self._integration.get_listenbrainz_username() + lfm_username = self._integration.get_lastfm_username() + + library_mbids = await self._mbid.get_library_artist_mbids(lidarr_configured) + + seed_artists = await self._get_seed_artists( + lb_enabled, username, jf_enabled, + resolved_source=resolved_source, + lfm_enabled=lfm_enabled, + lfm_username=lfm_username, + ) + + tasks: dict[str, Any] = {} + + for i, seed in enumerate(seed_artists[:3]): + mbid = seed.artist_mbids[0] if hasattr(seed, 'artist_mbids') and seed.artist_mbids else getattr(seed, 'artist_mbid', None) + if mbid: + if resolved_source == "lastfm" and self._lfm_repo and lfm_enabled: + tasks[f"similar_{i}"] = self._lfm_repo.get_similar_artists( + seed.artist_name, mbid=mbid, limit=20 + ) + else: + tasks[f"similar_{i}"] = self._lb_repo.get_similar_artists(mbid, max_similar=20) + + if resolved_source == "listenbrainz": + tasks["lb_trending"] = self._lb_repo.get_sitewide_top_artists(count=20) + elif resolved_source == "lastfm" and self._lfm_repo and lfm_enabled: + tasks["lfm_global_top"] = self._lfm_repo.get_global_top_artists(limit=20) + + if self._lfm_repo and lfm_enabled and lfm_username: + tasks["lfm_weekly_artists"] = self._lfm_repo.get_user_weekly_artist_chart( + lfm_username + ) + tasks["lfm_weekly_albums"] = self._lfm_repo.get_user_weekly_album_chart( + lfm_username + ) + tasks["lfm_recent"] = self._lfm_repo.get_user_recent_tracks( + lfm_username, limit=20 + ) + + if resolved_source == "listenbrainz" and lb_enabled and username: + tasks["lb_fresh"] = self._lb_repo.get_user_fresh_releases() + tasks["lb_genres"] = self._lb_repo.get_user_genre_activity(username) + elif resolved_source == "lastfm" and self._lfm_repo and lfm_enabled and lfm_username: + tasks["lfm_user_top_artists_for_genres"] = self._lfm_repo.get_user_top_artists( + lfm_username, period="3month", limit=5 + ) + + if jf_enabled: + tasks["jf_most_played"] = self._jf_repo.get_most_played_artists(limit=50) + + if lidarr_configured: + tasks["library_artists"] = self._lidarr_repo.get_artists_from_library() + tasks["library_albums"] = self._lidarr_repo.get_library() + + results = await self._execute_tasks(tasks) + + logger.info( + "Discover data fetch results: %s", + {k: "ok" if v is not None else "empty" for k, v in results.items()}, + ) + + response = DiscoverResponse( + integration_status=self._integration.get_integration_status(), + ) + + seen_artist_mbids: set[str] = set() + + response.because_you_listen_to = self._build_because_sections( + seed_artists, results, library_mbids, seen_artist_mbids, + resolved_source=resolved_source, + ) + await self._enrich_because_sections_audiodb(response.because_you_listen_to) + logger.info("because_you_listen_to: %d sections", len(response.because_you_listen_to)) + + response.fresh_releases = self._build_fresh_releases(results, library_mbids) + + post_tasks: dict[str, Any] = { + "missing_essentials": self._build_missing_essentials(results, library_mbids), + "lastfm_weekly_album_chart": self._build_lastfm_weekly_album_chart( + results, library_mbids + ), + "lastfm_recent_scrobbles": self._build_lastfm_recent_scrobbles( + results, library_mbids + ), + } + if resolved_source == "listenbrainz" and lb_enabled and username: + post_tasks["weekly_exploration"] = self._weekly_exploration.build_section(username) + post_results = await self._execute_tasks(post_tasks) + response.missing_essentials = post_results.get("missing_essentials") + response.weekly_exploration = post_results.get("weekly_exploration") + + response.rediscover = self._build_rediscover(results, library_mbids, jf_enabled) + + response.artists_you_might_like = self._build_artists_you_might_like( + seed_artists, results, library_mbids, seen_artist_mbids, + resolved_source=resolved_source, + ) + + response.popular_in_your_genres = await self._build_popular_in_genres( + results, library_mbids, seen_artist_mbids, + resolved_source=resolved_source, + ) + + response.genre_list = self._build_genre_list(results, lb_enabled) + + if response.genre_list and response.genre_list.items: + genre_names = [ + g.name for g in response.genre_list.items[:20] + if isinstance(g, HomeGenre) + ] + if genre_names: + raw_mbids = await asyncio.gather( + *(self._get_genre_artist(g) for g in genre_names) + ) + used_mbids: set[str] = set() + genre_artists: dict[str, str | None] = {} + for g, mbid in zip(genre_names, raw_mbids): + if mbid and mbid not in used_mbids: + genre_artists[g] = mbid + used_mbids.add(mbid) + elif mbid and mbid in used_mbids: + alt = await self._get_genre_artist(g, exclude_mbids=used_mbids) + genre_artists[g] = alt + if alt: + used_mbids.add(alt) + else: + genre_artists[g] = None + response.genre_artists = genre_artists + response.genre_artist_images = await self._resolve_genre_artist_images( + response.genre_artists + ) + + if resolved_source == "lastfm": + response.globally_trending = self._build_lastfm_globally_trending( + results, library_mbids, seen_artist_mbids + ) + else: + response.globally_trending = self._build_globally_trending( + results, library_mbids, seen_artist_mbids + ) + + response.lastfm_weekly_artist_chart = self._build_lastfm_weekly_artist_chart( + results, library_mbids, seen_artist_mbids + ) + response.lastfm_weekly_album_chart = post_results.get("lastfm_weekly_album_chart") + response.lastfm_recent_scrobbles = post_results.get("lastfm_recent_scrobbles") + + response.service_prompts = self._build_service_prompts() + + sections_status = { + "because": len(response.because_you_listen_to), + "fresh_releases": response.fresh_releases is not None, + "missing_essentials": response.missing_essentials is not None, + "rediscover": response.rediscover is not None, + "artists_you_might_like": response.artists_you_might_like is not None, + "popular_in_genres": response.popular_in_your_genres is not None, + "genre_list": response.genre_list is not None, + "globally_trending": response.globally_trending is not None, + "weekly_exploration": response.weekly_exploration is not None, + "lastfm_weekly_artist_chart": getattr(response, "lastfm_weekly_artist_chart", None) is not None, + "lastfm_weekly_album_chart": getattr(response, "lastfm_weekly_album_chart", None) is not None, + "lastfm_recent_scrobbles": getattr(response, "lastfm_recent_scrobbles", None) is not None, + } + logger.info("Discover build complete (source=%s): %s", resolved_source, sections_status) + + return response + + async def _get_seed_artists( + self, + lb_enabled: bool, + username: str | None, + jf_enabled: bool, + resolved_source: str = "listenbrainz", + lfm_enabled: bool = False, + lfm_username: str | None = None, + ) -> list[ListenBrainzArtist]: + seeds: list[ListenBrainzArtist] = [] + seen_mbids: set[str] = set() + + if resolved_source == "lastfm" and lfm_enabled and lfm_username and self._lfm_repo: + try: + lfm_artists = await self._lfm_repo.get_user_top_artists( + lfm_username, period="3month", limit=10 + ) + for a in lfm_artists: + if len(seeds) >= 3: + break + mbid = a.mbid + if mbid and mbid not in seen_mbids: + seeds.append( + ListenBrainzArtist( + artist_name=a.name, + listen_count=a.playcount, + artist_mbids=[mbid], + ) + ) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get Last.fm seed artists: %s", e) + + if resolved_source != "lastfm" and len(seeds) < 3 and lb_enabled and username: + for range_ in ("this_week", "this_month"): + if len(seeds) >= 3: + break + try: + artists = await self._lb_repo.get_user_top_artists(count=10, range_=range_) + for a in artists: + if len(seeds) >= 3: + break + mbid = a.artist_mbids[0] if a.artist_mbids else None + if mbid and mbid not in seen_mbids: + seeds.append(a) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get LB top artists ({range_}): {e}") + + if resolved_source != "lastfm" and len(seeds) < 3 and jf_enabled: + for fetch_fn in ( + lambda: self._jf_repo.get_most_played_artists(limit=10), + lambda: self._jf_repo.get_favorite_artists(limit=10), + ): + if len(seeds) >= 3: + break + try: + jf_items = await fetch_fn() + for item in jf_items: + if len(seeds) >= 3: + break + mbid = None + if item.provider_ids: + mbid = item.provider_ids.get("MusicBrainzArtist") + if mbid and mbid not in seen_mbids: + seeds.append(ListenBrainzArtist( + artist_name=item.artist_name or item.name, + listen_count=item.play_count, + artist_mbids=[mbid], + )) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get Jellyfin seed artists: {e}") + continue + + logger.info( + "Seed artists found: %d — %s", + len(seeds), + [(s.artist_name, s.artist_mbids[0][:8] if s.artist_mbids else "?") for s in seeds], + ) + return seeds + + async def _enrich_because_sections_audiodb( + self, sections: list[BecauseYouListenTo] + ) -> None: + if not self._audiodb_image_service: + return + for section in sections: + if not section.seed_artist_mbid: + continue + images = await self._audiodb_image_service.get_cached_artist_images( + section.seed_artist_mbid + ) + if not images or images.is_negative: + continue + section.banner_url = images.banner_url + section.wide_thumb_url = images.wide_thumb_url + section.fanart_url = images.fanart_url + + def _build_because_sections( + self, + seed_artists: list, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + resolved_source: str = "listenbrainz", + ) -> list[BecauseYouListenTo]: + sections: list[BecauseYouListenTo] = [] + + for i, seed in enumerate(seed_artists[:3]): + similar = results.get(f"similar_{i}") + if not similar: + continue + + seed_name = getattr(seed, 'artist_name', 'Unknown') + seed_mbid = "" + if hasattr(seed, 'artist_mbids') and seed.artist_mbids: + seed_mbid = seed.artist_mbids[0] + elif hasattr(seed, 'artist_mbid'): + seed_mbid = seed.artist_mbid + + items: list[HomeArtist] = [] + for artist in similar: + mbid = getattr(artist, 'artist_mbid', None) or getattr(artist, 'mbid', None) + name = getattr(artist, 'artist_name', None) or getattr(artist, 'name', '') + listen_count = getattr(artist, 'listen_count', None) or getattr(artist, 'playcount', 0) + if not mbid: + continue + if mbid.lower() in seen_artist_mbids: + continue + items.append(HomeArtist( + mbid=mbid, + name=name, + listen_count=listen_count, + in_library=mbid.lower() in library_mbids, + )) + seen_artist_mbids.add(mbid.lower()) + + if not items: + continue + + min_unique = 3 + if len(items) < min_unique and len(sections) > 0: + continue + + source_label = "lastfm" if resolved_source == "lastfm" else "listenbrainz" + sections.append(BecauseYouListenTo( + seed_artist=seed_name, + seed_artist_mbid=seed_mbid, + listen_count=getattr(seed, 'listen_count', 0), + section=HomeSection( + title=f"Because You Listen To {seed_name}", + type="artists", + items=items[:15], + source=source_label, + ), + )) + + return sections + + def _build_fresh_releases( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + releases = results.get("lb_fresh") + if not releases: + return None + items: list[HomeAlbum] = [] + for r in releases[:15]: + try: + if isinstance(r, dict): + mbid = r.get("release_group_mbid", "") + artist_mbids = r.get("artist_mbids", []) + items.append(HomeAlbum( + mbid=mbid, + name=r.get("title", r.get("release_group_name", "Unknown")), + artist_name=r.get("artist_credit_name", r.get("artist_name", "")), + artist_mbid=artist_mbids[0] if artist_mbids else None, + listen_count=r.get("listen_count"), + in_library=mbid.lower() in library_mbids if isinstance(mbid, str) and mbid else False, + )) + else: + items.append(self._transformers.lb_release_to_home(r, library_mbids)) + except Exception as e: # noqa: BLE001 + logger.debug(f"Skipping fresh release item: {e}") + continue + if not items: + return None + return HomeSection( + title="Fresh Releases For You", + type="albums", + items=items, + source="listenbrainz", + ) + + async def _build_missing_essentials( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + library_artists = results.get("library_artists") or [] + library_albums = results.get("library_albums") or [] + + if not library_artists or not library_albums: + return None + + from collections import Counter + artist_album_counts: Counter[str] = Counter() + for album in library_albums: + artist_mbid = getattr(album, 'artist_mbid', None) + if artist_mbid: + artist_album_counts[artist_mbid.lower()] += 1 + + library_album_mbids = set() + for album in library_albums: + mbid = getattr(album, 'musicbrainz_id', None) + if mbid: + library_album_mbids.add(mbid.lower()) + + qualifying_artists = [ + (mbid, count) for mbid, count in artist_album_counts.items() + if count >= MISSING_ESSENTIALS_MIN_ALBUMS + ] + qualifying_artists.sort(key=lambda x: -x[1]) + + semaphore = asyncio.Semaphore(3) + + async def _fetch_artist_missing(artist_mbid: str) -> list[HomeAlbum]: + try: + async with semaphore: + top_releases = await self._lb_repo.get_artist_top_release_groups( + artist_mbid, count=10 + ) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get releases for artist {artist_mbid[:8]}: {e}") + return [] + + artist_missing = 0 + artist_items: list[HomeAlbum] = [] + for rg in top_releases: + if artist_missing >= MISSING_ESSENTIALS_MAX_PER_ARTIST: + break + rg_mbid = rg.release_group_mbid + if not rg_mbid or rg_mbid.lower() in library_album_mbids: + continue + artist_items.append(HomeAlbum( + mbid=rg_mbid, + name=rg.release_group_name, + artist_name=rg.artist_name, + listen_count=rg.listen_count, + in_library=False, + )) + artist_missing += 1 + + return artist_items + + artist_results = await asyncio.gather( + *(_fetch_artist_missing(artist_mbid) for artist_mbid, _ in qualifying_artists[:10]), + return_exceptions=True, + ) + + all_missing: list[HomeAlbum] = [] + for result in artist_results: + if isinstance(result, Exception): + logger.debug("Failed to fetch missing essentials batch item: %s", result) + continue + all_missing.extend(result) + + if not all_missing: + return None + + all_missing.sort(key=lambda x: x.listen_count or 0, reverse=True) + return HomeSection( + title="Missing Essentials", + type="albums", + items=all_missing[:15], + source="lidarr", + ) + + def _build_rediscover( + self, + results: dict[str, Any], + library_mbids: set[str], + jf_enabled: bool, + ) -> HomeSection | None: + if not jf_enabled: + return None + + jf_artists = results.get("jf_most_played") + if not jf_artists: + return None + + now = datetime.now(timezone.utc) + rediscover_items: list[HomeArtist] = [] + seen: set[str] = set() + + for item in jf_artists: + if item.play_count < REDISCOVER_PLAY_THRESHOLD: + continue + if not item.last_played: + continue + + try: + last_played = datetime.fromisoformat(item.last_played.replace("Z", "+00:00")) + months_since = (now - last_played).days / 30.0 + if months_since < REDISCOVER_MONTHS_AGO: + continue + except (ValueError, TypeError): + continue + + artist_name = item.artist_name or item.name + if artist_name.lower() in seen: + continue + seen.add(artist_name.lower()) + + mbid = None + if item.provider_ids: + mbid = item.provider_ids.get("MusicBrainzArtist") + + image_url = None + if self._jf_repo and hasattr(self._jf_repo, 'get_image_url'): + target_id = item.artist_id or item.id + image_url = prefer_artist_cover_url( + mbid, + self._jf_repo.get_image_url(target_id, item.image_tag), + size=500, + ) + + rediscover_items.append(HomeArtist( + mbid=mbid, + name=artist_name, + listen_count=item.play_count, + image_url=image_url, + in_library=mbid.lower() in library_mbids if mbid else False, + )) + + if len(rediscover_items) >= 15: + break + + if not rediscover_items: + return None + + return HomeSection( + title="Rediscover", + type="artists", + items=rediscover_items, + source="jellyfin", + ) + + def _build_artists_you_might_like( + self, + seed_artists: list, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + resolved_source: str = "listenbrainz", + ) -> HomeSection | None: + aggregated: list[HomeArtist] = [] + for i in range(len(seed_artists[:3])): + similar = results.get(f"similar_{i}") + if not similar: + continue + for artist in similar: + mbid = getattr(artist, 'artist_mbid', None) or getattr(artist, 'mbid', None) + name = getattr(artist, 'artist_name', None) or getattr(artist, 'name', '') + listen_count = getattr(artist, 'listen_count', None) or getattr(artist, 'playcount', 0) + if not mbid: + continue + if mbid.lower() in seen_artist_mbids: + continue + aggregated.append(HomeArtist( + mbid=mbid, + name=name, + listen_count=listen_count, + in_library=mbid.lower() in library_mbids, + )) + seen_artist_mbids.add(mbid.lower()) + + if not aggregated: + return None + + aggregated.sort(key=lambda x: x.listen_count or 0, reverse=True) + source_label = "lastfm" if resolved_source == "lastfm" else "listenbrainz" + return HomeSection( + title="Artists You Might Like", + type="artists", + items=aggregated[:15], + source=source_label, + ) + + async def _build_popular_in_genres( + self, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + resolved_source: str = "listenbrainz", + ) -> HomeSection | None: + if resolved_source == "lastfm" and self._lfm_repo: + return await self._build_popular_in_genres_lastfm( + results, library_mbids, seen_artist_mbids + ) + + genres = results.get("lb_genres") + + if not genres: + return None + else: + genre_names = [] + for genre in genres[:3]: + name = genre.genre if hasattr(genre, 'genre') else str(genre) + genre_names.append(name) + + all_artists: list[HomeArtist] = [] + tag_results = await asyncio.gather( + *(self._mb_repo.search_artists_by_tag(genre_name, limit=10) for genre_name in genre_names), + return_exceptions=True, + ) + + for genre_name, tag_artists in zip(genre_names, tag_results): + if isinstance(tag_artists, Exception): + logger.debug(f"Failed to search artists for genre '{genre_name}': {tag_artists}") + continue + for artist in tag_artists: + if artist is None: + continue + mbid = artist.musicbrainz_id + if not mbid or mbid.lower() in seen_artist_mbids: + continue + all_artists.append(HomeArtist( + mbid=mbid, + name=artist.title if hasattr(artist, 'title') else str(artist), + in_library=mbid.lower() in library_mbids, + )) + seen_artist_mbids.add(mbid.lower()) + + if not all_artists: + return None + + return HomeSection( + title="Popular In Your Genres", + type="artists", + items=all_artists[:15], + source="musicbrainz", + ) + + async def _build_popular_in_genres_lastfm( + self, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + ) -> HomeSection | None: + top_artists = results.get("lfm_user_top_artists_for_genres") or [] + if not top_artists or not self._lfm_repo: + return None + + artist_info_results = await asyncio.gather( + *( + self._lfm_repo.get_artist_info(artist.name, mbid=artist.mbid) + for artist in top_artists[:5] + ), + return_exceptions=True, + ) + + genre_names: list[str] = [] + seen_genres: set[str] = set() + for info in artist_info_results: + if isinstance(info, Exception): + logger.debug("Failed to get artist info for genre extraction: %s", info) + continue + if info and info.tags: + for tag in info.tags[:2]: + if tag.name and tag.name.lower() not in seen_genres: + genre_names.append(tag.name) + seen_genres.add(tag.name.lower()) + if len(genre_names) >= 3: + break + if len(genre_names) >= 3: + break + + if not genre_names: + return None + + tag_top_artist_results = await asyncio.gather( + *( + self._lfm_repo.get_tag_top_artists(genre_name, limit=10) + for genre_name in genre_names + ), + return_exceptions=True, + ) + + all_artists: list[HomeArtist] = [] + for genre_name, tag_artists in zip(genre_names, tag_top_artist_results): + if isinstance(tag_artists, Exception): + logger.debug("Failed to get tag top artists for '%s': %s", genre_name, tag_artists) + continue + for artist in tag_artists: + mbid = artist.mbid + if not mbid or mbid.lower() in seen_artist_mbids: + continue + all_artists.append(HomeArtist( + mbid=mbid, + name=artist.name, + listen_count=artist.playcount, + in_library=mbid.lower() in library_mbids, + )) + seen_artist_mbids.add(mbid.lower()) + + if not all_artists: + return None + + return HomeSection( + title="Popular In Your Genres", + type="artists", + items=all_artists[:15], + source="lastfm", + ) + + def _build_genre_list( + self, results: dict[str, Any], lb_enabled: bool + ) -> HomeSection | None: + lb_genres = results.get("lb_genres") + library_albums = results.get("library_albums") or [] + genres = self._transformers.extract_genres_from_library(library_albums, lb_genres) + if not genres: + return None + source = "listenbrainz" if lb_genres else ("lidarr" if library_albums else None) + return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source) + + def _build_globally_trending( + self, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + ) -> HomeSection | None: + artists = results.get("lb_trending") or [] + items = [] + for artist in artists[:20]: + home_artist = self._transformers.lb_artist_to_home(artist, library_mbids) + if home_artist and home_artist.mbid and home_artist.mbid.lower() not in seen_artist_mbids: + items.append(home_artist) + seen_artist_mbids.add(home_artist.mbid.lower()) + + if not items: + return None + + return HomeSection( + title="Globally Trending", + type="artists", + items=items[:15], + source="listenbrainz", + ) + + def _build_lastfm_globally_trending( + self, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + ) -> HomeSection | None: + artists = results.get("lfm_global_top") or [] + items = [] + for artist in artists[:20]: + home_artist = self._transformers.lastfm_artist_to_home(artist, library_mbids) + if home_artist and home_artist.mbid and home_artist.mbid.lower() not in seen_artist_mbids: + items.append(home_artist) + seen_artist_mbids.add(home_artist.mbid.lower()) + + if not items: + return None + + return HomeSection( + title="Globally Trending", + type="artists", + items=items[:15], + source="lastfm", + ) + + def _build_lastfm_weekly_artist_chart( + self, + results: dict[str, Any], + library_mbids: set[str], + seen_artist_mbids: set[str], + ) -> HomeSection | None: + artists = results.get("lfm_weekly_artists") or [] + items = [] + for artist in artists[:20]: + home_artist = self._transformers.lastfm_artist_to_home(artist, library_mbids) + if home_artist and home_artist.mbid and home_artist.mbid.lower() not in seen_artist_mbids: + items.append(home_artist) + seen_artist_mbids.add(home_artist.mbid.lower()) + + if not items: + return None + + return HomeSection( + title="Your Weekly Top Artists", + type="artists", + items=items[:15], + source="lastfm", + ) + + async def _build_lastfm_weekly_album_chart( + self, + results: dict[str, Any], + library_mbids: set[str], + ) -> HomeSection | None: + albums = results.get("lfm_weekly_albums") or [] + if not albums: + return None + + release_mbids = list({a.mbid for a in albums[:20] if a.mbid}) + rg_map = await self._resolve_release_mbids(release_mbids) if release_mbids else {} + + items = [] + for album in albums[:20]: + home_album = self._transformers.lastfm_album_to_home(album, library_mbids) + if home_album and home_album.mbid: + home_album.mbid = rg_map.get(home_album.mbid, home_album.mbid) + items.append(home_album) + + if not items: + return None + + return HomeSection( + title="Your Top Albums This Week", + type="albums", + items=items[:15], + source="lastfm", + ) + + async def _build_lastfm_recent_scrobbles( + self, + results: dict[str, Any], + library_mbids: set[str], + ) -> HomeSection | None: + tracks = results.get("lfm_recent") or [] + if not tracks: + return None + + release_mbids = list({t.album_mbid for t in tracks[:30] if t.album_mbid}) + rg_map = await self._resolve_release_mbids(release_mbids) if release_mbids else {} + + items = [] + seen_album_mbids: set[str] = set() + for track in tracks[:30]: + home_album = self._transformers.lastfm_recent_to_home(track, library_mbids) + if home_album and home_album.mbid: + resolved = rg_map.get(home_album.mbid, home_album.mbid) + home_album.mbid = resolved + if resolved.lower() not in seen_album_mbids: + items.append(home_album) + seen_album_mbids.add(resolved.lower()) + + if not items: + return None + + return HomeSection( + title="Recently Scrobbled", + type="albums", + items=items[:15], + source="lastfm", + ) + + async def _resolve_release_mbids(self, release_ids: list[str]) -> dict[str, str]: + if not release_ids: + return {} + unique_ids = list(dict.fromkeys(release_ids)) + tasks = [self._mb_repo.get_release_group_id_from_release(rid) for rid in unique_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + rg_map: dict[str, str] = {} + for rid, rg_id in zip(unique_ids, results): + if isinstance(rg_id, str) and rg_id: + rg_map[rid] = rg_id + return rg_map + + async def _get_genre_artist(self, genre_name: str, exclude_mbids: set[str] | None = None) -> str | None: + try: + artists = await self._mb_repo.search_artists_by_tag(genre_name, limit=10) + for artist in artists: + if not artist.musicbrainz_id or artist.musicbrainz_id == VARIOUS_ARTISTS_MBID: + continue + if exclude_mbids and artist.musicbrainz_id in exclude_mbids: + continue + return artist.musicbrainz_id + except Exception: # noqa: BLE001 + logger.debug("Failed to resolve genre artist from library") + return None + + async def _resolve_genre_artist_images( + self, genre_artists: dict[str, str | None] + ) -> dict[str, str | None]: + if not self._audiodb_image_service or not genre_artists: + return {} + + sem = asyncio.Semaphore(5) + + async def _resolve_one(genre: str, mbid: str) -> tuple[str, str | None]: + async with sem: + try: + images = await self._audiodb_image_service.fetch_and_cache_artist_images(mbid) + if images and not images.is_negative: + url = images.wide_thumb_url or images.banner_url or images.fanart_url + if url: + return (genre, url) + except Exception as exc: # noqa: BLE001 + logger.debug("Failed to resolve discover genre image for %s: %s", genre, exc) + return (genre, None) + + tasks = [ + _resolve_one(genre, mbid) + for genre, mbid in genre_artists.items() + if mbid + ] + if not tasks: + return {} + + results = await asyncio.gather(*tasks) + return {genre: url for genre, url in results if url} + + def _build_service_prompts(self) -> list[ServicePrompt]: + prompts = [] + if not self._integration.is_listenbrainz_enabled(): + prompts.append(ServicePrompt( + service="listenbrainz", + title="Connect ListenBrainz", + description="Get recommendations from your listening history, find similar artists, and keep an eye on your top genres. Connect Last.fm too if you want global listener stats.", + icon="🎵", + color="primary", + features=["Personalized recommendations", "Similar artists", "Listening stats", "Genre insights"], + )) + if not self._integration.is_jellyfin_enabled(): + prompts.append(ServicePrompt( + service="jellyfin", + title="Connect Jellyfin", + description="Use your play history to surface favorites and sharpen recommendations.", + icon="📺", + color="secondary", + features=["Rediscover favorites", "Play statistics", "Listening history", "Better recommendations"], + )) + if not self._integration.is_lidarr_configured(): + prompts.append(ServicePrompt( + service="lidarr-connection", + title="Connect Lidarr", + description="Spot gaps in your collection and keep your library in sync.", + icon="🎶", + color="accent", + features=["Missing essentials", "Library management", "Album requests", "Collection tracking"], + )) + if not self._integration.is_lastfm_enabled(): + prompts.append(ServicePrompt( + service="lastfm", + title="Connect Last.fm", + description="Track your listening, compare stats, and discover music that matches your taste.", + icon="🎸", + color="primary", + features=["Scrobbling", "Global listener stats", "Artist recommendations", "Play history"], + )) + return prompts + + async def _execute_tasks(self, tasks: dict[str, Any]) -> dict[str, Any]: + if not tasks: + return {} + keys = list(tasks.keys()) + coros = list(tasks.values()) + raw_results = await asyncio.gather(*coros, return_exceptions=True) + results = {} + for key, result in zip(keys, raw_results): + if isinstance(result, Exception): + logger.warning(f"Discover task {key} failed: {result}") + results[key] = None + else: + results[key] = result + return results diff --git a/backend/services/discover/integration_helpers.py b/backend/services/discover/integration_helpers.py new file mode 100644 index 0000000..4df6db4 --- /dev/null +++ b/backend/services/discover/integration_helpers.py @@ -0,0 +1,82 @@ +import logging + +from api.v1.schemas.discover import ( + DiscoverIntegrationStatus, + QueueSettings, +) +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +DISCOVER_CACHE_KEY = "discover_response" + + +class IntegrationHelpers: + def __init__(self, preferences_service: PreferencesService) -> None: + self._preferences = preferences_service + + def is_listenbrainz_enabled(self) -> bool: + lb_settings = self._preferences.get_listenbrainz_connection() + return lb_settings.enabled and bool(lb_settings.username) + + def is_jellyfin_enabled(self) -> bool: + jf_settings = self._preferences.get_jellyfin_connection() + return jf_settings.enabled and bool(jf_settings.jellyfin_url) and bool(jf_settings.api_key) + + def is_lidarr_configured(self) -> bool: + lidarr_connection = self._preferences.get_lidarr_connection() + return bool(lidarr_connection.lidarr_url) and bool(lidarr_connection.lidarr_api_key) + + def is_youtube_api_enabled(self) -> bool: + yt_settings = self._preferences.get_youtube_connection() + return yt_settings.enabled and yt_settings.api_enabled and yt_settings.has_valid_api_key() + + def is_lastfm_enabled(self) -> bool: + return self._preferences.is_lastfm_enabled() + + def get_listenbrainz_username(self) -> str | None: + lb_settings = self._preferences.get_listenbrainz_connection() + return lb_settings.username if lb_settings.enabled else None + + def get_lastfm_username(self) -> str | None: + lf_settings = self._preferences.get_lastfm_connection() + return lf_settings.username if lf_settings.enabled else None + + def resolve_source(self, source: str | None) -> str: + if source in ("listenbrainz", "lastfm"): + resolved = source + else: + resolved = self._preferences.get_primary_music_source().source + lb_enabled = self.is_listenbrainz_enabled() + lfm_enabled = self.is_lastfm_enabled() + if resolved == "listenbrainz" and not lb_enabled and lfm_enabled: + return "lastfm" + if resolved == "lastfm" and not lfm_enabled and lb_enabled: + return "listenbrainz" + return resolved + + def get_queue_settings(self) -> QueueSettings: + adv = self._preferences.get_advanced_settings() + return QueueSettings( + queue_size=adv.discover_queue_size, + queue_ttl=adv.discover_queue_ttl, + seed_artists=adv.discover_queue_seed_artists, + wildcard_slots=adv.discover_queue_wildcard_slots, + similar_artists_limit=adv.discover_queue_similar_artists_limit, + albums_per_similar=adv.discover_queue_albums_per_similar, + enrich_ttl=adv.discover_queue_enrich_ttl, + lastfm_mbid_max_lookups=adv.discover_queue_lastfm_mbid_max_lookups, + ) + + def get_discover_cache_key(self, source: str | None = None) -> str: + resolved = self.resolve_source(source) + return f"{DISCOVER_CACHE_KEY}:{resolved}" + + def get_integration_status(self) -> DiscoverIntegrationStatus: + return DiscoverIntegrationStatus( + listenbrainz=self.is_listenbrainz_enabled(), + jellyfin=self.is_jellyfin_enabled(), + lidarr=self.is_lidarr_configured(), + youtube=self.is_youtube_api_enabled(), + lastfm=self.is_lastfm_enabled(), + ) diff --git a/backend/services/discover/mbid_resolution_service.py b/backend/services/discover/mbid_resolution_service.py new file mode 100644 index 0000000..9e274ad --- /dev/null +++ b/backend/services/discover/mbid_resolution_service.py @@ -0,0 +1,288 @@ +import asyncio +import logging +from typing import Any + +from api.v1.schemas.discover import DiscoverQueueItemLight +from infrastructure.persistence import LibraryDB, MBIDStore +from repositories.protocols import ( + LidarrRepositoryProtocol, + ListenBrainzRepositoryProtocol, + MusicBrainzRepositoryProtocol, +) + +logger = logging.getLogger(__name__) + + +class MbidResolutionService: + def __init__( + self, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + library_db: LibraryDB | None = None, + mbid_store: MBIDStore | None = None, + ) -> None: + self._mb_repo = musicbrainz_repo + self._lidarr_repo = lidarr_repo + self._lb_repo = listenbrainz_repo + self._library_db = library_db + self._mbid_store = mbid_store + + @staticmethod + def normalize_mbid(mbid: str | None) -> str | None: + if not mbid: + return None + normalized = mbid.strip().lower() + return normalized or None + + async def resolve_lastfm_release_group_mbids( + self, + album_mbids: list[str], + *, + max_lookups: int = 10, + allow_passthrough: bool = True, + resolver_cache: dict[str, str | None] | None = None, + ) -> dict[str, str]: + normalized: list[str] = [] + seen: set[str] = set() + for mbid in album_mbids: + mbid_normalized = self.normalize_mbid(mbid) + if not mbid_normalized or mbid_normalized in seen: + continue + normalized.append(mbid_normalized) + seen.add(mbid_normalized) + + if not normalized: + return {} + + cache = resolver_cache if resolver_cache is not None else {} + resolved: dict[str, str] = {} + pending: list[str] = [] + + for mbid in normalized: + if mbid in cache: + cached_value = cache[mbid] + if cached_value: + resolved[mbid] = cached_value + elif allow_passthrough: + resolved[mbid] = mbid + continue + pending.append(mbid) + + if pending and self._mbid_store: + try: + persisted = await self._mbid_store.get_mbid_resolution_map(pending) + still_pending: list[str] = [] + for mbid in pending: + if mbid in persisted: + rg_mbid = persisted[mbid] + cache[mbid] = rg_mbid + if rg_mbid: + resolved[mbid] = rg_mbid + elif allow_passthrough: + resolved[mbid] = mbid + else: + still_pending.append(mbid) + pending = still_pending + except Exception: # noqa: BLE001 + logger.warning("Failed to load MBID resolution from persistent cache") + + if not pending: + return resolved + + new_resolutions: dict[str, str | None] = {} + + lookup_mbids = pending[:max_lookups] + skipped_mbids = pending[max_lookups:] + for mbid in skipped_mbids: + if allow_passthrough: + resolved[mbid] = mbid + cache[mbid] = mbid + else: + cache[mbid] = None + + release_results = await asyncio.gather( + *[self._mb_repo.get_release_group_id_from_release(mbid) for mbid in lookup_mbids], + return_exceptions=True, + ) + unresolved: list[str] = [] + + for mbid, result in zip(lookup_mbids, release_results): + if isinstance(result, Exception): + unresolved.append(mbid) + continue + rg_mbid = self.normalize_mbid(result) + if rg_mbid: + resolved[mbid] = rg_mbid + cache[mbid] = rg_mbid + new_resolutions[mbid] = rg_mbid + else: + unresolved.append(mbid) + + if not unresolved: + if new_resolutions and self._mbid_store: + try: + await self._mbid_store.save_mbid_resolution_map(new_resolutions) + except Exception: # noqa: BLE001 + logger.warning("Failed to persist MBID resolutions") + return resolved + + rg_checks = await asyncio.gather( + *[ + self._mb_repo.get_release_group_by_id(mbid, includes=["artist-credits"]) + for mbid in unresolved + ], + return_exceptions=True, + ) + + for mbid, result in zip(unresolved, rg_checks): + if isinstance(result, Exception): + if allow_passthrough: + resolved[mbid] = mbid + cache[mbid] = mbid + else: + cache[mbid] = None + continue + if isinstance(result, dict) and result.get("id"): + resolved[mbid] = mbid + cache[mbid] = mbid + new_resolutions[mbid] = mbid + elif allow_passthrough: + resolved[mbid] = mbid + cache[mbid] = mbid + else: + cache[mbid] = None + new_resolutions[mbid] = None + + if new_resolutions and self._mbid_store: + try: + await self._mbid_store.save_mbid_resolution_map(new_resolutions) + except Exception: # noqa: BLE001 + logger.warning("Failed to persist MBID resolutions") + + return resolved + + async def lastfm_albums_to_queue_items( + self, + artist_albums_pairs: list[tuple[Any, list]], + *, + exclude: set[str] | None = None, + target: int, + reason: str, + is_wildcard: bool = False, + resolver_cache: dict[str, str | None] | None = None, + use_album_artist_name: bool = True, + ) -> list[DiscoverQueueItemLight]: + all_album_mbids: list[str] = [] + for _, albums in artist_albums_pairs: + all_album_mbids.extend(a.mbid for a in albums if a.mbid) + rg_mbid_map = await self.resolve_lastfm_release_group_mbids( + all_album_mbids, resolver_cache=resolver_cache, + ) + items: list[DiscoverQueueItemLight] = [] + seen_rg_mbids: set[str] = {mbid.lower() for mbid in (exclude or set())} + for artist, albums in artist_albums_pairs: + if len(items) >= target: + break + artist_mbid = self.normalize_mbid(artist.mbid) + for album in albums: + if len(items) >= target: + break + raw_album_mbid = self.normalize_mbid(album.mbid) + if not raw_album_mbid: + continue + rg_mbid = rg_mbid_map.get(raw_album_mbid) + if not rg_mbid: + continue + rg_mbid_lower = rg_mbid.lower() + if rg_mbid_lower in seen_rg_mbids: + continue + artist_name = (album.artist_name or artist.name) if use_album_artist_name else artist.name + items.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=album.name, + artist_name=artist_name, + artist_mbid=artist_mbid or "", + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason=reason, + is_wildcard=is_wildcard, + in_library=False, + )) + seen_rg_mbids.add(rg_mbid_lower) + return items + + async def resolve_release_mbids( + self, + release_ids: list[str], + ) -> dict[str, str]: + return await self.resolve_lastfm_release_group_mbids( + release_ids, allow_passthrough=False, + ) + + async def get_library_artist_mbids(self, lidarr_configured: bool) -> set[str]: + if not lidarr_configured: + return set() + try: + artists = await self._lidarr_repo.get_artists_from_library() + return {a.get("mbid", "").lower() for a in artists if a.get("mbid")} + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch library artists from Lidarr") + return set() + + async def get_library_album_mbids(self, lidarr_configured: bool) -> set[str]: + if not lidarr_configured: + if self._library_db: + try: + return await self._library_db.get_all_album_mbids() + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch album MBIDs from library cache") + return set() + try: + return await self._lidarr_repo.get_library_mbids(include_release_ids=False) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch library album MBIDs from Lidarr") + return set() + + async def get_user_listened_release_group_mbids( + self, + lb_enabled: bool, + username: str | None, + resolved_source: str, + ) -> set[str]: + if resolved_source != "listenbrainz" or not lb_enabled or not username: + return set() + try: + listened = await self._lb_repo.get_user_top_release_groups( + username=username, + range_="all_time", + count=100, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch user listened release groups from ListenBrainz") + return set() + return { + rg.release_group_mbid.lower() + for rg in listened + if getattr(rg, "release_group_mbid", None) + } + + def make_queue_item( + self, + *, + release_group_mbid: str, + album_name: str, + artist_name: str, + artist_mbid: str, + reason: str, + is_wildcard: bool = False, + ) -> DiscoverQueueItemLight: + return DiscoverQueueItemLight( + release_group_mbid=release_group_mbid, + album_name=album_name, + artist_name=artist_name, + artist_mbid=artist_mbid, + cover_url=f"/api/v1/covers/release-group/{release_group_mbid}?size=500", + recommendation_reason=reason, + is_wildcard=is_wildcard, + in_library=False, + ) diff --git a/backend/services/discover/queue_service.py b/backend/services/discover/queue_service.py new file mode 100644 index 0000000..4e9844b --- /dev/null +++ b/backend/services/discover/queue_service.py @@ -0,0 +1,886 @@ +import asyncio +import logging +import random +import uuid +from typing import Any + +from api.v1.schemas.discover import ( + DiscoverQueueItemLight, + DiscoverQueueResponse, + DiscoverIgnoredRelease, + QueueSettings, +) +from infrastructure.persistence import LibraryDB, MBIDStore +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + JellyfinRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from repositories.listenbrainz_models import ListenBrainzArtist +from services.discover.integration_helpers import IntegrationHelpers +from services.discover.mbid_resolution_service import MbidResolutionService + +logger = logging.getLogger(__name__) + +VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377" + + +class DiscoverQueueService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + jellyfin_repo: JellyfinRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + integration: IntegrationHelpers, + mbid_resolution: MbidResolutionService, + library_db: LibraryDB | None = None, + mbid_store: MBIDStore | None = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + ) -> None: + self._lb_repo = listenbrainz_repo + self._jf_repo = jellyfin_repo + self._mb_repo = musicbrainz_repo + self._integration = integration + self._mbid = mbid_resolution + self._library_db = library_db + self._mbid_store = mbid_store + self._lfm_repo = lastfm_repo + + async def build_queue(self, count: int | None = None, source: str | None = None) -> DiscoverQueueResponse: + qs = self._integration.get_queue_settings() + if count is None: + count = qs.queue_size + resolved_source = self._integration.resolve_source(source) + logger.info("Building discover queue: requested_source=%s, resolved_source=%s", source, resolved_source) + lb_enabled = self._integration.is_listenbrainz_enabled() + jf_enabled = self._integration.is_jellyfin_enabled() + lidarr_configured = self._integration.is_lidarr_configured() + lfm_enabled = self._integration.is_lastfm_enabled() + username = self._integration.get_listenbrainz_username() + lfm_username = self._integration.get_lastfm_username() + + ignored_mbids: set[str] = set() + if self._mbid_store: + try: + ignored_mbids = await self._mbid_store.get_ignored_release_mbids() + except Exception: # noqa: BLE001 + logger.warning("Failed to load ignored release MBIDs from cache") + + library_album_mbids = await self._mbid.get_library_album_mbids(lidarr_configured) + listened_release_group_mbids = await self._mbid.get_user_listened_release_group_mbids( + lb_enabled, + username, + resolved_source, + ) + + has_services = lb_enabled or jf_enabled or (lfm_enabled and lfm_username) + if has_services: + items = await self._build_personalized_queue( + count, lb_enabled, username, jf_enabled, ignored_mbids, library_album_mbids, + listened_release_group_mbids, + resolved_source=resolved_source, + lfm_enabled=lfm_enabled, + lfm_username=lfm_username, + ) + else: + items = await self._build_anonymous_queue( + count, ignored_mbids, library_album_mbids, resolved_source=resolved_source + ) + + return DiscoverQueueResponse( + items=items, + queue_id=str(uuid.uuid4()), + ) + + async def _get_seed_artists( + self, + lb_enabled: bool, + username: str | None, + jf_enabled: bool, + resolved_source: str = "listenbrainz", + lfm_enabled: bool = False, + lfm_username: str | None = None, + ) -> list[ListenBrainzArtist]: + seeds: list[ListenBrainzArtist] = [] + seen_mbids: set[str] = set() + + if resolved_source == "lastfm" and lfm_enabled and lfm_username and self._lfm_repo: + try: + lfm_artists = await self._lfm_repo.get_user_top_artists( + lfm_username, period="3month", limit=10 + ) + for a in lfm_artists: + if len(seeds) >= 3: + break + mbid = a.mbid + if mbid and mbid not in seen_mbids: + seeds.append( + ListenBrainzArtist( + artist_name=a.name, + listen_count=a.playcount, + artist_mbids=[mbid], + ) + ) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning("Failed to get Last.fm seed artists: %s", e) + + if resolved_source != "lastfm" and len(seeds) < 3 and lb_enabled and username: + for range_ in ("this_week", "this_month"): + if len(seeds) >= 3: + break + try: + artists = await self._lb_repo.get_user_top_artists(count=10, range_=range_) + for a in artists: + if len(seeds) >= 3: + break + mbid = a.artist_mbids[0] if a.artist_mbids else None + if mbid and mbid not in seen_mbids: + seeds.append(a) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get LB top artists ({range_}): {e}") + + if resolved_source != "lastfm" and len(seeds) < 3 and jf_enabled: + for fetch_fn in ( + lambda: self._jf_repo.get_most_played_artists(limit=10), + lambda: self._jf_repo.get_favorite_artists(limit=10), + ): + if len(seeds) >= 3: + break + try: + jf_items = await fetch_fn() + for item in jf_items: + if len(seeds) >= 3: + break + mbid = None + if item.provider_ids: + mbid = item.provider_ids.get("MusicBrainzArtist") + if mbid and mbid not in seen_mbids: + seeds.append(ListenBrainzArtist( + artist_name=item.artist_name or item.name, + listen_count=item.play_count, + artist_mbids=[mbid], + )) + seen_mbids.add(mbid) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to get Jellyfin seed artists: {e}") + continue + + logger.info( + "Seed artists found: %d — %s", + len(seeds), + [(s.artist_name, s.artist_mbids[0][:8] if s.artist_mbids else "?") for s in seeds], + ) + return seeds + + async def validate_queue_mbids(self, mbids: list[str]) -> list[str]: + library_mbids: set[str] = set() + if self._library_db: + try: + library_mbids = await self._library_db.get_all_album_mbids() + except Exception: # noqa: BLE001 + logger.warning("Failed to load album MBIDs from library cache for validation") + if not library_mbids: + try: + lidarr_configured = self._integration.is_lidarr_configured() + if lidarr_configured: + library_mbids = await self._mbid.get_library_album_mbids(True) + except Exception: # noqa: BLE001 + logger.warning("Failed to load album MBIDs from Lidarr for validation") + if not library_mbids: + return mbids + lowered_library = {lm.lower() for lm in library_mbids} + return [m for m in mbids if m.lower() in lowered_library] + + async def ignore_release( + self, release_group_mbid: str, artist_mbid: str, release_name: str, artist_name: str + ) -> None: + if self._mbid_store: + await self._mbid_store.add_ignored_release( + release_group_mbid, artist_mbid, release_name, artist_name + ) + + async def get_ignored_releases(self) -> list[DiscoverIgnoredRelease]: + if self._mbid_store: + rows = await self._mbid_store.get_ignored_releases() + return [DiscoverIgnoredRelease(**row) for row in rows] + return [] + + async def _build_lb_similar_seed_pools( + self, + seeds: list[ListenBrainzArtist], + excluded_mbids: set[str], + qs: QueueSettings, + ) -> list[list[DiscoverQueueItemLight]]: + pools: list[list[DiscoverQueueItemLight]] = [[] for _ in range(len(seeds))] + + async def _process_seed(i: int, seed: ListenBrainzArtist) -> None: + seed_mbid = seed.artist_mbids[0] if seed.artist_mbids else None + if not seed_mbid: + return + + pool_seen: set[str] = set() + try: + similar = await self._lb_repo.get_similar_artists( + seed_mbid, + max_similar=qs.similar_artists_limit, + ) + for sim_artist in similar: + sim_mbid = self._mbid.normalize_mbid(sim_artist.artist_mbid) + if not sim_mbid or sim_mbid == VARIOUS_ARTISTS_MBID: + continue + + try: + release_groups = await self._lb_repo.get_artist_top_release_groups( + sim_mbid, + count=qs.albums_per_similar, + ) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get releases for similar artist: {e}") + continue + + for rg in release_groups: + rg_mbid = self._mbid.normalize_mbid(rg.release_group_mbid) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in pool_seen: + continue + pools[i].append( + self._mbid.make_queue_item( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=sim_mbid, + reason=f"Similar to {seed.artist_name}", + ) + ) + pool_seen.add(rg_mbid) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get similar artists for seed {seed_mbid[:8]}: {e}") + + await asyncio.gather(*[_process_seed(i, seed) for i, seed in enumerate(seeds)]) + return pools + + async def _strategy_lb_genre_discovery( + self, + username: str, + excluded_mbids: set[str], + ) -> list[DiscoverQueueItemLight]: + try: + genres = await self._lb_repo.get_user_genre_activity(username) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch user genre activity from ListenBrainz") + return [] + + if not genres: + return [] + + top_genres = [genre.genre for genre in genres[:4] if getattr(genre, "genre", None)] + if not top_genres: + return [] + + search_results = await asyncio.gather( + *[ + self._mb_repo.search_release_groups_by_tag(tag=genre, limit=8) + for genre in top_genres + ], + return_exceptions=True, + ) + + items: list[DiscoverQueueItemLight] = [] + seen: set[str] = set() + for genre, result in zip(top_genres, search_results): + if isinstance(result, Exception): + continue + for release in result: + rg_mbid = self._mbid.normalize_mbid(getattr(release, "musicbrainz_id", None)) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen: + continue + items.append( + self._mbid.make_queue_item( + release_group_mbid=rg_mbid, + album_name=getattr(release, "title", "Unknown"), + artist_name=getattr(release, "artist", "Unknown") or "Unknown", + artist_mbid="", + reason=f"Because you listen to {genre}", + ) + ) + seen.add(rg_mbid) + return items + + async def _strategy_lb_fresh_releases( + self, + username: str, + excluded_mbids: set[str], + ) -> list[DiscoverQueueItemLight]: + try: + fresh_releases = await self._lb_repo.get_user_fresh_releases(username) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch fresh releases from ListenBrainz") + return [] + + if not fresh_releases: + return [] + + items: list[DiscoverQueueItemLight] = [] + seen: set[str] = set() + for release in fresh_releases: + if not isinstance(release, dict): + continue + rg_mbid = self._mbid.normalize_mbid(release.get("release_group_mbid")) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen: + continue + + artist_mbids = release.get("artist_mbids") + first_artist_mbid = "" + if isinstance(artist_mbids, list) and artist_mbids: + first_artist_mbid = self._mbid.normalize_mbid(artist_mbids[0]) or "" + + album_name = release.get("title") or release.get("release_group_name") or "Unknown" + artist_name = release.get("artist_credit_name") or release.get("artist_name") or "Unknown" + items.append( + self._mbid.make_queue_item( + release_group_mbid=rg_mbid, + album_name=album_name, + artist_name=artist_name, + artist_mbid=first_artist_mbid, + reason="New release for you", + ) + ) + seen.add(rg_mbid) + return items + + async def _strategy_lb_loved_artists( + self, + username: str, + excluded_mbids: set[str], + albums_per_artist: int, + ) -> list[DiscoverQueueItemLight]: + try: + loved = await self._lb_repo.get_user_loved_recordings( + username=username, + count=50, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch loved recordings from ListenBrainz") + return [] + + artist_mbids: list[str] = [] + seen_artists: set[str] = set() + for recording in loved: + mbids = getattr(recording, "artist_mbids", None) or [] + if not mbids: + continue + normalized = self._mbid.normalize_mbid(mbids[0]) + if not normalized or normalized in seen_artists: + continue + artist_mbids.append(normalized) + seen_artists.add(normalized) + if len(artist_mbids) >= 6: + break + + if not artist_mbids: + return [] + + results = await asyncio.gather( + *[ + self._lb_repo.get_artist_top_release_groups(artist_mbid, count=albums_per_artist) + for artist_mbid in artist_mbids + ], + return_exceptions=True, + ) + + items: list[DiscoverQueueItemLight] = [] + seen_rg_mbids: set[str] = set() + for artist_mbid, result in zip(artist_mbids, results): + if isinstance(result, Exception): + continue + for rg in result: + rg_mbid = self._mbid.normalize_mbid(rg.release_group_mbid) + if not rg_mbid: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen_rg_mbids: + continue + items.append( + self._mbid.make_queue_item( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=artist_mbid, + reason="From an artist you love", + ) + ) + seen_rg_mbids.add(rg_mbid) + return items + + async def _strategy_lb_top_artist_deep_cuts( + self, + username: str, + excluded_mbids: set[str], + listened_mbids: set[str], + albums_per_artist: int, + ) -> list[DiscoverQueueItemLight]: + try: + top_release_groups = await self._lb_repo.get_user_top_release_groups( + username=username, + range_="this_month", + count=25, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch top release groups from ListenBrainz for deep cuts") + return [] + + if not top_release_groups: + return [] + + current_top_mbids = { + rg.release_group_mbid.lower() + for rg in top_release_groups + if getattr(rg, "release_group_mbid", None) + } + + artist_seed_names: dict[str, str] = {} + for rg in top_release_groups: + rg_artist_mbids = getattr(rg, "artist_mbids", None) or [] + if not rg_artist_mbids: + continue + artist_mbid = self._mbid.normalize_mbid(rg_artist_mbids[0]) + if not artist_mbid or artist_mbid in artist_seed_names: + continue + artist_seed_names[artist_mbid] = getattr(rg, "artist_name", "") + if len(artist_seed_names) >= 6: + break + + if not artist_seed_names: + return [] + + artist_mbid_list = list(artist_seed_names.keys()) + results = await asyncio.gather( + *[ + self._lb_repo.get_artist_top_release_groups( + a_mbid, + count=max(albums_per_artist + 2, 4), + ) + for a_mbid in artist_mbid_list + ], + return_exceptions=True, + ) + + items: list[DiscoverQueueItemLight] = [] + seen_rg_mbids: set[str] = set() + for a_mbid, result in zip(artist_mbid_list, results): + if isinstance(result, Exception): + continue + for rg in result: + rg_mbid = self._mbid.normalize_mbid(rg.release_group_mbid) + if not rg_mbid: + continue + if rg_mbid in current_top_mbids or rg_mbid in listened_mbids: + continue + if rg_mbid in excluded_mbids or rg_mbid in seen_rg_mbids: + continue + + source_artist_name = artist_seed_names.get(a_mbid) or rg.artist_name + items.append( + self._mbid.make_queue_item( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=a_mbid, + reason=f"More from {source_artist_name}", + ) + ) + seen_rg_mbids.add(rg_mbid) + return items + + async def _build_personalized_queue( + self, + count: int, + lb_enabled: bool, + username: str | None, + jf_enabled: bool, + ignored_mbids: set[str], + library_album_mbids: set[str], + listened_release_group_mbids: set[str], + resolved_source: str = "listenbrainz", + lfm_enabled: bool = False, + lfm_username: str | None = None, + ) -> list[DiscoverQueueItemLight]: + seed_artists = await self._get_seed_artists( + lb_enabled, username, jf_enabled, + resolved_source=resolved_source, + lfm_enabled=lfm_enabled, + lfm_username=lfm_username, + ) + if not seed_artists: + return await self._build_anonymous_queue( + count, ignored_mbids, library_album_mbids, resolved_source=resolved_source + ) + + qs = self._integration.get_queue_settings() + use_lastfm = resolved_source == "lastfm" and lfm_enabled and self._lfm_repo is not None + seeds = seed_artists[:qs.seed_artists] + wildcard_slots = qs.wildcard_slots + personalized_target = max(count - wildcard_slots, 0) + seed_target = max(4, (personalized_target // max(len(seeds), 1)) + 3) + excluded_mbids = ignored_mbids | library_album_mbids + mbid_resolution_cache: dict[str, str | None] = {} + + candidate_pools: list[list[DiscoverQueueItemLight]] = [] + if use_lastfm: + candidate_pools = [[] for _ in range(len(seeds))] + + async def _process_seed_lastfm(i: int, seed: Any) -> None: + seed_mbid = seed.artist_mbids[0] if seed.artist_mbids else None + if not seed_mbid: + return + try: + similar_raw = await self._lfm_repo.get_similar_artists( + seed.artist_name, + mbid=seed_mbid, + limit=qs.similar_artists_limit, + ) + valid_sims = [ + sim + for sim in similar_raw + if self._mbid.normalize_mbid(sim.mbid) + and self._mbid.normalize_mbid(sim.mbid) != VARIOUS_ARTISTS_MBID + ] + album_fetch_results = await asyncio.gather( + *[ + self._lfm_repo.get_artist_top_albums( + sim.name, + mbid=sim.mbid, + limit=qs.albums_per_similar, + ) + for sim in valid_sims + ], + return_exceptions=True, + ) + sim_albums_map: list[tuple[Any, list]] = [] + for sim, result in zip(valid_sims, album_fetch_results): + if isinstance(result, Exception): + logger.debug("Failed to get Last.fm albums for %s: %s", sim.name, result) + continue + sim_albums_map.append((sim, result)) + candidate_pools[i] = await self._mbid.lastfm_albums_to_queue_items( + sim_albums_map, + exclude=excluded_mbids, + target=seed_target, + reason=f"Similar to {seed.artist_name}", + resolver_cache=mbid_resolution_cache, + use_album_artist_name=False, + ) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get similar artists for seed {seed_mbid[:8]}: {e}") + + await asyncio.gather(*[_process_seed_lastfm(i, seed) for i, seed in enumerate(seeds)]) + else: + deep_cut_excluded = excluded_mbids | listened_release_group_mbids + strategy_names = [ + "similar_seeds", "genre_discovery", "fresh_releases", + "loved_artists", "deep_cuts", + ] + strategy_results = await asyncio.gather( + self._build_lb_similar_seed_pools(seeds, excluded_mbids, qs), + self._strategy_lb_genre_discovery(username or "", excluded_mbids), + self._strategy_lb_fresh_releases(username or "", excluded_mbids), + self._strategy_lb_loved_artists( + username or "", + excluded_mbids, + qs.albums_per_similar, + ), + self._strategy_lb_top_artist_deep_cuts( + username or "", + deep_cut_excluded, + listened_release_group_mbids, + qs.albums_per_similar, + ), + return_exceptions=True, + ) + + similar_seed_pools = strategy_results[0] + if isinstance(similar_seed_pools, list): + candidate_pools.extend(similar_seed_pools) + pool_counts = [len(p) for p in similar_seed_pools] + logger.info("Strategy similar_seeds: %d pools, items per pool: %s", len(similar_seed_pools), pool_counts) + elif isinstance(similar_seed_pools, Exception): + logger.warning("Strategy similar_seeds FAILED: %s", similar_seed_pools) + + for idx, strategy_result in enumerate(strategy_results[1:], start=1): + name = strategy_names[idx] + if isinstance(strategy_result, Exception): + logger.warning("Strategy %s FAILED: %s", name, strategy_result) + continue + if strategy_result: + candidate_pools.append(strategy_result) + logger.info("Strategy %s: %d items", name, len(strategy_result)) + else: + logger.info("Strategy %s: 0 items", name) + + personalized = self._round_robin_select(candidate_pools, personalized_target) + logger.info( + "Personalized queue: %d items from %d pools (target=%d, wildcard_slots=%d)", + len(personalized), len(candidate_pools), personalized_target, wildcard_slots, + ) + seen_mbids = {item.release_group_mbid.lower() for item in personalized} + + wildcard_count = max(wildcard_slots, count - len(personalized)) + wildcards = await self._get_wildcard_albums( + wildcard_count, ignored_mbids, library_album_mbids, seen_mbids, + resolved_source=resolved_source, + ) + queue_items = self._interleave_wildcards(personalized, wildcards) + + if len(queue_items) < count: + top_up_seen = {item.release_group_mbid.lower() for item in queue_items} + top_up = await self._get_wildcard_albums( + count - len(queue_items), + ignored_mbids, + library_album_mbids, + top_up_seen, + resolved_source=resolved_source, + ) + queue_items.extend(top_up) + + return queue_items[:count] + + def _round_robin_select( + self, pools: list[list[DiscoverQueueItemLight]], count: int + ) -> list[DiscoverQueueItemLight]: + selected: list[DiscoverQueueItemLight] = [] + seen_mbids: set[str] = set() + artist_counts: dict[str, int] = {} + max_per_artist = 2 + + for pool in pools: + random.shuffle(pool) + + pool_indices = [0] * len(pools) + + for _ in range(count * 3): + if len(selected) >= count: + break + for pool_idx in range(len(pools)): + if len(selected) >= count: + break + pool = pools[pool_idx] + idx = pool_indices[pool_idx] + while idx < len(pool): + item = pool[idx] + idx += 1 + pool_indices[pool_idx] = idx + mbid_lower = item.release_group_mbid.lower() + artist_key = item.artist_mbid.lower() if item.artist_mbid else "" + if mbid_lower in seen_mbids: + continue + if artist_key and artist_counts.get(artist_key, 0) >= max_per_artist: + continue + selected.append(item) + seen_mbids.add(mbid_lower) + if artist_key: + artist_counts[artist_key] = artist_counts.get(artist_key, 0) + 1 + break + + return selected + + async def _get_wildcard_albums( + self, count: int, ignored_mbids: set[str], library_album_mbids: set[str], + seen_mbids: set[str] | None = None, + resolved_source: str = "listenbrainz", + ) -> list[DiscoverQueueItemLight]: + if count <= 0: + return [] + exclude = ignored_mbids | library_album_mbids | (seen_mbids or set()) + use_lastfm = resolved_source == "lastfm" and self._integration.is_lastfm_enabled() and self._lfm_repo is not None + target = max(count * 2, 6) + + try: + if use_lastfm: + top_artists = await self._lfm_repo.get_global_top_artists(limit=15) + random.shuffle(top_artists) + valid_artists = [ + a + for a in top_artists[:10] + if self._mbid.normalize_mbid(a.mbid) != VARIOUS_ARTISTS_MBID + ] + album_fetch_results = await asyncio.gather( + *[ + self._lfm_repo.get_artist_top_albums( + a.name, mbid=a.mbid, limit=3 + ) + for a in valid_artists + ], + return_exceptions=True, + ) + artist_albums_pairs: list[tuple[Any, list]] = [] + for artist, result in zip(valid_artists, album_fetch_results): + if isinstance(result, Exception): + continue + artist_albums_pairs.append((artist, result)) + wildcards = await self._mbid.lastfm_albums_to_queue_items( + artist_albums_pairs, + exclude=exclude, + target=target, + reason="Trending on Last.fm", + is_wildcard=True, + ) + else: + rgs = await self._lb_repo.get_sitewide_top_release_groups(count=25) + random.shuffle(rgs) + wildcards: list[DiscoverQueueItemLight] = [] + for rg in rgs: + if len(wildcards) >= target: + break + rg_mbid = rg.release_group_mbid + if not rg_mbid or rg_mbid.lower() in exclude: + continue + artist_mbid = rg.artist_mbids[0] if rg.artist_mbids else "" + if artist_mbid.lower() == VARIOUS_ARTISTS_MBID: + continue + wildcards.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=artist_mbid, + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason="Trending This Week", + is_wildcard=True, + in_library=False, + )) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get wildcard albums: {e}") + wildcards = [] + + if not wildcards: + if use_lastfm: + decade_tags = ["2020s", "2010s", "2000s", "1990s", "1980s", "1970s"] + for decade in decade_tags: + if len(wildcards) >= target: + break + try: + decade_releases = await self._mb_repo.search_release_groups_by_tag( + tag=decade, + limit=25, + offset=0, + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to search release groups for decade tag %s", decade) + continue + for release in decade_releases: + if len(wildcards) >= target: + break + rg_mbid = self._mbid.normalize_mbid(getattr(release, "musicbrainz_id", None)) + if not rg_mbid or rg_mbid.lower() in exclude: + continue + wildcards.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=getattr(release, "title", "Unknown"), + artist_name=getattr(release, "artist", "Unknown") or "Unknown", + artist_mbid="", + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason="Trending on Last.fm", + is_wildcard=True, + in_library=False, + )) + exclude.add(rg_mbid.lower()) + + if not wildcards: + logger.warning("Failed to populate any wildcard albums for discover queue") + + return wildcards[:count] + + def _interleave_wildcards( + self, + personalized: list[DiscoverQueueItemLight], + wildcards: list[DiscoverQueueItemLight], + ) -> list[DiscoverQueueItemLight]: + result = list(personalized) + positions = [2, 7] + for i, wc in enumerate(wildcards): + pos = positions[i] if i < len(positions) else len(result) + pos = min(pos, len(result)) + result.insert(pos, wc) + return result + + async def _build_anonymous_queue( + self, count: int, ignored_mbids: set[str], library_album_mbids: set[str], + resolved_source: str = "listenbrainz", + ) -> list[DiscoverQueueItemLight]: + items: list[DiscoverQueueItemLight] = [] + use_lastfm = resolved_source == "lastfm" and self._integration.is_lastfm_enabled() and self._lfm_repo is not None + exclude = ignored_mbids | library_album_mbids + + try: + if use_lastfm: + top_artists = await self._lfm_repo.get_global_top_artists(limit=15) + random.shuffle(top_artists) + valid_artists = [ + a + for a in top_artists + if self._mbid.normalize_mbid(a.mbid) != VARIOUS_ARTISTS_MBID + ] + album_fetch_results = await asyncio.gather( + *[ + self._lfm_repo.get_artist_top_albums( + a.name, mbid=a.mbid, limit=3 + ) + for a in valid_artists + ], + return_exceptions=True, + ) + artist_albums_pairs: list[tuple[Any, list]] = [] + for artist, result in zip(valid_artists, album_fetch_results): + if isinstance(result, Exception): + continue + artist_albums_pairs.append((artist, result)) + items = await self._mbid.lastfm_albums_to_queue_items( + artist_albums_pairs, + exclude=exclude, + target=count, + reason="Trending on Last.fm", + is_wildcard=True, + ) + else: + trending = await self._lb_repo.get_sitewide_top_release_groups(count=50) + random.shuffle(trending) + for rg in trending: + if len(items) >= count: + break + rg_mbid = rg.release_group_mbid + if not rg_mbid or rg_mbid.lower() in exclude: + continue + artist_mbid = rg.artist_mbids[0] if rg.artist_mbids else "" + if artist_mbid.lower() == VARIOUS_ARTISTS_MBID: + continue + items.append(DiscoverQueueItemLight( + release_group_mbid=rg_mbid, + album_name=rg.release_group_name, + artist_name=rg.artist_name, + artist_mbid=artist_mbid, + cover_url=f"/api/v1/covers/release-group/{rg_mbid}?size=500", + recommendation_reason="Trending This Week", + is_wildcard=True, + in_library=False, + )) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get trending for anonymous queue: {e}") + + if len(items) < count: + top_up_seen = {item.release_group_mbid.lower() for item in items} + top_up = await self._get_wildcard_albums( + count - len(items), + ignored_mbids, + library_album_mbids, + top_up_seen, + resolved_source=resolved_source, + ) + items.extend(top_up) + + return items[:count] diff --git a/backend/services/discover_queue_manager.py b/backend/services/discover_queue_manager.py new file mode 100644 index 0000000..81fb2aa --- /dev/null +++ b/backend/services/discover_queue_manager.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from enum import Enum +from typing import Any, TYPE_CHECKING + +import msgspec + +from api.v1.schemas.discover import ( + DiscoverQueueEnrichment, + DiscoverQueueItemFull, + DiscoverQueueResponse, + DiscoverQueueStatusResponse, + QueueGenerateResponse, +) +from infrastructure.serialization import clone_with_updates +from services.discover_service import DiscoverService +from services.preferences_service import PreferencesService + +if TYPE_CHECKING: + from repositories.coverart_repository import CoverArtRepository + +logger = logging.getLogger(__name__) + + +class QueueBuildStatus(str, Enum): + IDLE = "idle" + BUILDING = "building" + READY = "ready" + ERROR = "error" + + +class SourceQueueState: + __slots__ = ("status", "queue", "error", "built_at", "task") + + def __init__(self) -> None: + self.status: QueueBuildStatus = QueueBuildStatus.IDLE + self.queue: DiscoverQueueResponse | None = None + self.error: str | None = None + self.built_at: float = 0.0 + self.task: asyncio.Task[None] | None = None + + +_COVER_PREWARM_CONCURRENCY = 4 +_COVER_PREWARM_DELAY = 0.5 + + +class DiscoverQueueManager: + def __init__( + self, + discover_service: DiscoverService, + preferences_service: PreferencesService, + cover_repo: CoverArtRepository | None = None, + ) -> None: + self._discover = discover_service + self._preferences = preferences_service + self._cover_repo = cover_repo + self._states: dict[str, SourceQueueState] = {} + self._lock = asyncio.Lock() + + def _get_state(self, source: str) -> SourceQueueState: + if source not in self._states: + self._states[source] = SourceQueueState() + return self._states[source] + + def _get_ttl(self) -> int: + adv = self._preferences.get_advanced_settings() + return adv.discover_queue_ttl + + def _is_stale(self, state: SourceQueueState) -> bool: + if state.status != QueueBuildStatus.READY or state.queue is None: + return True + return (time.time() - state.built_at) > self._get_ttl() + + def get_status(self, source: str) -> DiscoverQueueStatusResponse: + state = self._get_state(source) + if state.status == QueueBuildStatus.READY and state.queue: + return DiscoverQueueStatusResponse( + status=state.status.value, + source=source, + queue_id=state.queue.queue_id, + item_count=len(state.queue.items), + built_at=state.built_at, + stale=self._is_stale(state), + ) + if state.status == QueueBuildStatus.ERROR: + return DiscoverQueueStatusResponse( + status=state.status.value, + source=source, + error=state.error, + ) + return DiscoverQueueStatusResponse(status=state.status.value, source=source) + + @staticmethod + def _build_generate_response(action: str, status: DiscoverQueueStatusResponse) -> QueueGenerateResponse: + return QueueGenerateResponse( + action=action, + status=status.status, + source=status.source, + queue_id=status.queue_id, + item_count=status.item_count, + built_at=status.built_at, + stale=status.stale, + error=status.error, + ) + + def get_queue(self, source: str) -> DiscoverQueueResponse | None: + state = self._get_state(source) + if state.status == QueueBuildStatus.READY and state.queue and not self._is_stale(state): + return state.queue + return None + + async def start_build(self, source: str, *, force: bool = False) -> QueueGenerateResponse: + async with self._lock: + state = self._get_state(source) + + if state.status == QueueBuildStatus.BUILDING: + return self._build_generate_response("already_building", self.get_status(source)) + + if not force and state.status == QueueBuildStatus.READY and not self._is_stale(state): + return self._build_generate_response("already_ready", self.get_status(source)) + + if state.task and not state.task.done(): + state.task.cancel() + + state.status = QueueBuildStatus.BUILDING + state.error = None + state.task = asyncio.create_task(self._do_build(source)) + from core.task_registry import TaskRegistry + try: + TaskRegistry.get_instance().register(f"discover-build-{source}", state.task) + except RuntimeError: + pass + + return self._build_generate_response("started", self.get_status(source)) + + async def build_hydrated_queue(self, source: str, count: int | None = None) -> DiscoverQueueResponse: + queue = await self._discover.build_queue(count=count, source=source) + return await self._hydrate_queue_items(queue, source) + + async def _hydrate_queue_items( + self, queue: DiscoverQueueResponse, source: str + ) -> DiscoverQueueResponse: + if not queue.items: + return queue + + concurrency = min(4, len(queue.items)) + semaphore = asyncio.Semaphore(concurrency) + + async def hydrate_item(item: Any) -> Any: + if getattr(item, "enrichment", None) is not None: + return item + try: + async with semaphore: + enrichment = await self._discover.enrich_queue_item(item.release_group_mbid) + except Exception as exc: # noqa: BLE001 + logger.warning( + "Queue item enrichment failed (source=%s, release_group_mbid=%s): %s", + source, + item.release_group_mbid, + exc, + ) + enrichment = DiscoverQueueEnrichment() + + item_data = msgspec.to_builtins(item) + item_data["enrichment"] = enrichment + return DiscoverQueueItemFull(**item_data) + + hydrated_items = await asyncio.gather(*(hydrate_item(item) for item in queue.items)) + return clone_with_updates(queue, {"items": hydrated_items}) + + async def _do_build(self, source: str) -> None: + state = self._get_state(source) + try: + logger.info("Background queue build started (source=%s)", source) + queue = await self.build_hydrated_queue(source) + state.queue = queue + state.built_at = time.time() + state.status = QueueBuildStatus.READY + logger.info( + "Background queue build complete (source=%s, items=%d, queue_id=%s)", + source, + len(queue.items), + queue.queue_id, + ) + task = asyncio.create_task(self._prewarm_covers(queue, source)) + from core.task_registry import TaskRegistry + try: + TaskRegistry.get_instance().register(f"discover-cover-prewarm-{source}", task) + except RuntimeError: + pass + except asyncio.CancelledError: + logger.info("Background queue build cancelled (source=%s)", source) + if state.status == QueueBuildStatus.BUILDING: + state.status = QueueBuildStatus.IDLE + raise + except Exception as e: # noqa: BLE001 + logger.error("Background queue build failed (source=%s): %s", source, e) + state.status = QueueBuildStatus.ERROR + state.error = str(e) + + async def _prewarm_covers(self, queue: DiscoverQueueResponse, source: str) -> None: + if not self._cover_repo or not queue.items: + return + + from infrastructure.queue.priority_queue import RequestPriority + + mbids = [ + item.release_group_mbid + for item in queue.items + if getattr(item, "release_group_mbid", None) + ] + if not mbids: + return + + semaphore = asyncio.Semaphore(_COVER_PREWARM_CONCURRENCY) + + async def warm_one(mbid: str) -> bool: + async with semaphore: + try: + result = await self._cover_repo.get_release_group_cover( + mbid, size="500", priority=RequestPriority.BACKGROUND_SYNC + ) + return result is not None + except Exception as exc: # noqa: BLE001 + logger.debug("Discover queue cover pre-warm failed for %s: %s", mbid[:8], exc) + return False + + results = await asyncio.gather(*(warm_one(m) for m in mbids), return_exceptions=True) + warmed = sum(1 for r in results if r is True) + logger.info( + "Pre-warmed %d/%d discover queue covers (source=%s)", + warmed, + len(mbids), + source, + ) + + async def consume_queue(self, source: str) -> DiscoverQueueResponse | None: + state = self._get_state(source) + if state.status != QueueBuildStatus.READY or state.queue is None: + return None + if self._is_stale(state): + logger.info("Discarding stale pre-built queue (source=%s)", source) + state.queue = None + state.status = QueueBuildStatus.IDLE + state.built_at = 0.0 + return None + queue = state.queue + state.queue = None + state.status = QueueBuildStatus.IDLE + state.built_at = 0.0 + return queue + + def invalidate(self, source: str | None = None) -> None: + if source: + state = self._get_state(source) + if state.task and not state.task.done(): + state.task.cancel() + self._states[source] = SourceQueueState() + else: + for src in list(self._states.keys()): + self.invalidate(src) diff --git a/backend/services/discover_service.py b/backend/services/discover_service.py new file mode 100644 index 0000000..b3434cc --- /dev/null +++ b/backend/services/discover_service.py @@ -0,0 +1,7 @@ +"""Backward-compatible shim — re-exports ``DiscoverService`` from its new home. + +All consumers that ``from services.discover_service import DiscoverService`` +continue to work without changes. +""" + +from services.discover.facade import DiscoverService # noqa: F401 diff --git a/backend/services/genre_cover_prewarm_service.py b/backend/services/genre_cover_prewarm_service.py new file mode 100644 index 0000000..f18a74b --- /dev/null +++ b/backend/services/genre_cover_prewarm_service.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from infrastructure.queue.priority_queue import RequestPriority + +if TYPE_CHECKING: + from repositories.coverart_repository import CoverArtRepository + +logger = logging.getLogger(__name__) + +_PREWARM_INTER_ITEM_DELAY = 2.0 +_MAX_CONCURRENT_PREWARMS = 3 +_MAX_MBIDS_PER_RUN = 100 + + +class GenreCoverPrewarmService: + def __init__(self, cover_repo: CoverArtRepository) -> None: + self._cover_repo = cover_repo + self._active_genres: dict[str, asyncio.Task[None]] = {} + self._global_semaphore = asyncio.Semaphore(_MAX_CONCURRENT_PREWARMS) + + def schedule_prewarm( + self, + genre_name: str, + artist_mbids: list[str], + album_mbids: list[str], + ) -> None: + existing = self._active_genres.get(genre_name) + if existing is not None and not existing.done(): + logger.debug("Pre-warm already in progress for genre '%s', skipping", genre_name) + return + + task = asyncio.create_task( + self._prewarm(genre_name, artist_mbids, album_mbids), + name=f"genre-prewarm-{genre_name}", + ) + self._active_genres[genre_name] = task + from core.task_registry import TaskRegistry + try: + TaskRegistry.get_instance().register(f"genre-prewarm-{genre_name}", task) + except RuntimeError: + pass + task.add_done_callback( + lambda _t, _g=genre_name, _ref=task: ( + self._active_genres.pop(_g, None) + if self._active_genres.get(_g) is _ref + else None + ) + ) + + async def shutdown(self) -> None: + tasks = list(self._active_genres.values()) + if not tasks: + return + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + self._active_genres.clear() + logger.info("GenreCoverPrewarmService shutdown complete (%d tasks cancelled)", len(tasks)) + + async def _prewarm( + self, + genre_name: str, + artist_mbids: list[str], + album_mbids: list[str], + ) -> None: + async with self._global_semaphore: + all_artist = artist_mbids[:_MAX_MBIDS_PER_RUN] + remaining = _MAX_MBIDS_PER_RUN - len(all_artist) + all_album = album_mbids[:remaining] if remaining > 0 else [] + total = len(all_artist) + len(all_album) + warmed = 0 + try: + for i, mbid in enumerate(all_artist): + try: + await self._cover_repo.get_artist_image( + mbid, size=250, priority=RequestPriority.BACKGROUND_SYNC + ) + warmed += 1 + except Exception as exc: # noqa: BLE001 + logger.debug("Pre-warm artist image failed for %s: %s", mbid[:8], exc) + if i < len(all_artist) - 1 or all_album: + await asyncio.sleep(_PREWARM_INTER_ITEM_DELAY) + + for i, mbid in enumerate(all_album): + try: + await self._cover_repo.get_release_group_cover( + mbid, size="250", priority=RequestPriority.BACKGROUND_SYNC + ) + warmed += 1 + except Exception as exc: # noqa: BLE001 + logger.debug("Pre-warm album cover failed for %s: %s", mbid[:8], exc) + if i < len(all_album) - 1: + await asyncio.sleep(_PREWARM_INTER_ITEM_DELAY) + + logger.info("Pre-warmed %d/%d genre covers for '%s'", warmed, total, genre_name) + except Exception as exc: # noqa: BLE001 + logger.error("Genre cover pre-warm failed for '%s': %s", genre_name, exc) diff --git a/backend/services/home/__init__.py b/backend/services/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/home/charts_service.py b/backend/services/home/charts_service.py new file mode 100644 index 0000000..365af34 --- /dev/null +++ b/backend/services/home/charts_service.py @@ -0,0 +1,610 @@ +"""Refactored HomeChartsService — uses shared integration helpers.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, TYPE_CHECKING + +from api.v1.schemas.home import ( + HomeArtist, + HomeAlbum, + GenreDetailResponse, + GenreLibrarySection, + GenrePopularSection, + TrendingArtistsResponse, + TrendingTimeRange, + TrendingArtistsRangeResponse, + PopularAlbumsResponse, + PopularTimeRange, + PopularAlbumsRangeResponse, +) +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + LidarrRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from services.home_transformers import HomeDataTransformers +from services.preferences_service import PreferencesService +from infrastructure.persistence import GenreIndex + +from .integration_helpers import HomeIntegrationHelpers + +if TYPE_CHECKING: + from services.genre_cover_prewarm_service import GenreCoverPrewarmService + +logger = logging.getLogger(__name__) + + +class HomeChartsService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + genre_index: GenreIndex | None = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + preferences_service: PreferencesService | None = None, + prewarm_service: 'GenreCoverPrewarmService | None' = None, + ): + self._lb_repo = listenbrainz_repo + self._lidarr_repo = lidarr_repo + self._mb_repo = musicbrainz_repo + self._genre_index = genre_index + self._lfm_repo = lastfm_repo + self._preferences = preferences_service + self._prewarm_service = prewarm_service + self._transformers = HomeDataTransformers() + + self._helpers: HomeIntegrationHelpers | None = None + if preferences_service: + self._helpers = HomeIntegrationHelpers(preferences_service) + + def _resolve_source(self, source: str | None) -> str: + if self._helpers: + return self._helpers.resolve_source(source) + if source in ("listenbrainz", "lastfm"): + return source + return "listenbrainz" + + async def _execute_tasks(self, tasks: dict[str, Any]) -> dict[str, Any]: + if self._helpers: + return await self._helpers.execute_tasks(tasks) + if not tasks: + return {} + keys = list(tasks.keys()) + coros = list(tasks.values()) + raw_results = await asyncio.gather(*coros, return_exceptions=True) + results = {} + for key, result in zip(keys, raw_results): + if isinstance(result, Exception): + logger.warning(f"Task {key} failed: {result}") + results[key] = None + else: + results[key] = result + return results + + def _get_lastfm_username(self) -> str | None: + if self._helpers: + return self._helpers.get_lastfm_username() + if not self._preferences: + return None + lf_settings = self._preferences.get_lastfm_connection() + if lf_settings.enabled and lf_settings.username: + return lf_settings.username + return None + + def _get_lb_username(self) -> str | None: + if self._helpers: + return self._helpers.get_lb_username() + if not self._preferences: + return None + lb_settings = self._preferences.get_listenbrainz_connection() + if lb_settings.enabled and lb_settings.username: + return lb_settings.username + return None + + async def get_genre_artists( + self, genre: str, limit: int = 100, artist_offset: int = 0, album_offset: int = 0 + ) -> GenreDetailResponse: + lidarr_results = await asyncio.gather( + self._lidarr_repo.get_artists_from_library(), + self._lidarr_repo.get_library(), + return_exceptions=True, + ) + lidarr_failed = any(isinstance(r, BaseException) for r in lidarr_results) + if lidarr_failed: + logger.warning("Lidarr unavailable for genre '%s', proceeding with MusicBrainz data only", genre) + library_artists = lidarr_results[0] if not isinstance(lidarr_results[0], BaseException) else [] + library_albums = lidarr_results[1] if not isinstance(lidarr_results[1], BaseException) else [] + library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")} + library_album_mbids = {a.musicbrainz_id.lower() for a in library_albums if a.musicbrainz_id} + library_section = None + if self._genre_index: + lib_artists_data = await self._genre_index.get_artists_by_genre(genre, limit=50) + lib_albums_data = await self._genre_index.get_albums_by_genre(genre, limit=50) + lib_artists = [ + HomeArtist( + mbid=a.get("mbid"), + name=a.get("name", "Unknown"), + image_url=None, + listen_count=a.get("album_count"), + in_library=True, + ) + for a in lib_artists_data + ] + lib_albums = [ + HomeAlbum( + mbid=a.get("mbid"), + name=a.get("title", "Unknown"), + artist_name=a.get("artist_name"), + artist_mbid=a.get("artist_mbid"), + image_url=a.get("cover_url"), + release_date=str(a.get("year")) if a.get("year") else None, + in_library=True, + ) + for a in lib_albums_data + ] + library_section = GenreLibrarySection( + artists=lib_artists, + albums=lib_albums, + artist_count=len(lib_artists_data), + album_count=len(lib_albums_data), + ) + mb_artist_results = await self._mb_repo.search_artists_by_tag( + tag=genre, limit=limit, offset=artist_offset + ) + mb_album_results = await self._mb_repo.search_release_groups_by_tag( + tag=genre, limit=limit, offset=album_offset + ) + popular_artists = [ + HomeArtist( + mbid=result.musicbrainz_id, + name=result.title, + image_url=None, + listen_count=None, + in_library=result.musicbrainz_id.lower() in library_mbids, + ) + for result in mb_artist_results + ] + popular_albums = [ + HomeAlbum( + mbid=result.musicbrainz_id, + name=result.title, + artist_name=result.artist, + artist_mbid=None, + image_url=None, + release_date=str(result.year) if result.year else None, + in_library=result.musicbrainz_id.lower() in library_album_mbids, + ) + for result in mb_album_results + ] + popular_section = GenrePopularSection( + artists=popular_artists, + albums=popular_albums, + has_more_artists=len(mb_artist_results) >= limit, + has_more_albums=len(mb_album_results) >= limit, + ) + if self._prewarm_service: + artist_mbids = [a.mbid for a in popular_artists if a.mbid] + album_mbids = [a.mbid for a in popular_albums if a.mbid] + self._prewarm_service.schedule_prewarm(genre, artist_mbids, album_mbids) + return GenreDetailResponse( + genre=genre, + library=library_section, + popular=popular_section, + artists=popular_artists, + total_count=len(popular_artists), + ) + + async def get_trending_artists(self, limit: int = 10, source: str | None = None) -> TrendingArtistsResponse: + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_trending_artists_lastfm(limit) + + library_artists = await self._lidarr_repo.get_artists_from_library() + library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")} + ranges = ["this_week", "this_month", "this_year", "all_time"] + tasks = {r: self._lb_repo.get_sitewide_top_artists(range_=r, count=limit + 1) for r in ranges} + results = await self._execute_tasks(tasks) + response_data = {} + for r in ranges: + lb_artists = results.get(r) or [] + artists = [ + a for a in (self._transformers.lb_artist_to_home(artist, library_mbids) for artist in lb_artists) + if a is not None + ] + featured = artists[0] if artists else None + items = artists[1:limit] if len(artists) > 1 else [] + response_data[r] = TrendingTimeRange( + range_key=r, + label=HomeDataTransformers.get_range_label(r), + featured=featured, + items=items, + total_count=len(artists), + ) + return TrendingArtistsResponse( + this_week=response_data["this_week"], + this_month=response_data["this_month"], + this_year=response_data["this_year"], + all_time=response_data["all_time"], + ) + + async def get_trending_artists_by_range( + self, + range_key: str = "this_week", + limit: int = 25, + offset: int = 0, + source: str | None = None, + ) -> TrendingArtistsRangeResponse: + allowed_ranges = ["this_week", "this_month", "this_year", "all_time"] + if range_key not in allowed_ranges: + range_key = "this_week" + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_trending_artists_lastfm_range( + range_key=range_key, + limit=limit, + offset=offset, + ) + library_artists, lb_artists = await asyncio.gather( + self._lidarr_repo.get_artists_from_library(), + self._lb_repo.get_sitewide_top_artists( + range_=range_key, count=limit + 1, offset=offset + ), + ) + library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")} + artists = [ + a for a in (self._transformers.lb_artist_to_home(artist, library_mbids) for artist in lb_artists) + if a is not None + ] + has_more = len(artists) > limit + items = artists[:limit] + return TrendingArtistsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=items, + offset=offset, + limit=limit, + has_more=has_more, + ) + + async def get_popular_albums(self, limit: int = 10, source: str | None = None) -> PopularAlbumsResponse: + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_popular_albums_lastfm(limit) + + library_albums = await self._lidarr_repo.get_library() + library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id} + ranges = ["this_week", "this_month", "this_year", "all_time"] + tasks = {r: self._lb_repo.get_sitewide_top_release_groups(range_=r, count=limit + 1) for r in ranges} + results = await self._execute_tasks(tasks) + response_data = {} + for r in ranges: + lb_albums = results.get(r) or [] + albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums] + featured = albums[0] if albums else None + items = albums[1:limit] if len(albums) > 1 else [] + response_data[r] = PopularTimeRange( + range_key=r, + label=HomeDataTransformers.get_range_label(r), + featured=featured, + items=items, + total_count=len(albums), + ) + return PopularAlbumsResponse( + this_week=response_data["this_week"], + this_month=response_data["this_month"], + this_year=response_data["this_year"], + all_time=response_data["all_time"], + ) + + async def get_popular_albums_by_range( + self, + range_key: str = "this_week", + limit: int = 25, + offset: int = 0, + source: str | None = None, + ) -> PopularAlbumsRangeResponse: + allowed_ranges = ["this_week", "this_month", "this_year", "all_time"] + if range_key not in allowed_ranges: + range_key = "this_week" + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_popular_albums_lastfm_range( + range_key=range_key, + limit=limit, + offset=offset, + ) + library_albums, lb_albums = await asyncio.gather( + self._lidarr_repo.get_library(), + self._lb_repo.get_sitewide_top_release_groups( + range_=range_key, count=limit + 1, offset=offset + ), + ) + library_mbids = {(a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id} + albums = [self._transformers.lb_release_to_home(a, library_mbids) for a in lb_albums] + has_more = len(albums) > limit + items = albums[:limit] + return PopularAlbumsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=items, + offset=offset, + limit=limit, + has_more=has_more, + ) + + async def _get_trending_artists_lastfm(self, limit: int = 10) -> TrendingArtistsResponse: + library_artists = await self._lidarr_repo.get_artists_from_library() + library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")} + lfm_artists = await self._lfm_repo.get_global_top_artists(limit=limit + 1) + artists = [ + a + for a in ( + self._transformers.lastfm_artist_to_home(artist, library_mbids) + for artist in lfm_artists + ) + if a is not None + ] + featured = artists[0] if artists else None + items = artists[1:limit] if len(artists) > 1 else [] + single_range = TrendingTimeRange( + range_key="all_time", + label="Global", + featured=featured, + items=items, + total_count=len(artists), + ) + return TrendingArtistsResponse( + this_week=single_range, + this_month=single_range, + this_year=single_range, + all_time=single_range, + ) + + async def _get_popular_albums_lastfm(self, limit: int = 10) -> PopularAlbumsResponse: + ranges = ["this_week", "this_month", "this_year", "all_time"] + library_albums = await self._lidarr_repo.get_library() + library_mbids = { + (a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id + } + lfm_username = self._get_lastfm_username() + if lfm_username: + tasks = { + range_key: self._lfm_repo.get_user_top_albums( + lfm_username, + period=self._lastfm_period_for_range(range_key), + limit=limit + 1, + ) + for range_key in ranges + } + results = await self._execute_tasks(tasks) + else: + logger.warning("No Last.fm username configured; returning empty popular albums") + empty_range = PopularTimeRange( + range_key="all_time", + label="Global", + featured=None, + items=[], + total_count=0, + ) + return PopularAlbumsResponse( + this_week=empty_range, + this_month=empty_range, + this_year=empty_range, + all_time=empty_range, + ) + response_data: dict[str, PopularTimeRange] = {} + for range_key in ranges: + lfm_albums = results.get(range_key) or [] + albums = [ + HomeAlbum( + mbid=None, + name=album.name, + artist_name=album.artist_name, + artist_mbid=None, + image_url=album.image_url or None, + listen_count=album.playcount, + in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False, + source="lastfm", + ) + for album in lfm_albums + ] + response_data[range_key] = PopularTimeRange( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + featured=albums[0] if albums else None, + items=albums[1:limit] if len(albums) > 1 else [], + total_count=len(albums), + ) + + return PopularAlbumsResponse( + this_week=response_data["this_week"], + this_month=response_data["this_month"], + this_year=response_data["this_year"], + all_time=response_data["all_time"], + ) + + async def _get_trending_artists_lastfm_range( + self, range_key: str = "this_week", limit: int = 25, offset: int = 0 + ) -> TrendingArtistsRangeResponse: + total_to_fetch = min(limit + offset + 1, 200) + lfm_artists, library_artists = await asyncio.gather( + self._lfm_repo.get_global_top_artists(limit=total_to_fetch), + self._lidarr_repo.get_artists_from_library(), + ) + library_mbids = {a.get("mbid", "").lower() for a in library_artists if a.get("mbid")} + artists = [ + a + for a in ( + self._transformers.lastfm_artist_to_home(artist, library_mbids) + for artist in lfm_artists + ) + if a is not None + ] + start = min(offset, len(artists)) + end = start + limit + return TrendingArtistsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=artists[start:end], + offset=offset, + limit=limit, + has_more=end < len(artists), + ) + + async def _get_popular_albums_lastfm_range( + self, range_key: str = "this_week", limit: int = 25, offset: int = 0 + ) -> PopularAlbumsRangeResponse: + lfm_username = self._get_lastfm_username() + if not lfm_username: + return PopularAlbumsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=[], + offset=offset, + limit=limit, + has_more=False, + ) + + total_to_fetch = min(limit + offset + 1, 200) + lfm_albums, library_albums = await asyncio.gather( + self._lfm_repo.get_user_top_albums( + lfm_username, + period=self._lastfm_period_for_range(range_key), + limit=total_to_fetch, + ), + self._lidarr_repo.get_library(), + ) + library_mbids = { + (a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id + } + albums = [ + HomeAlbum( + mbid=album.mbid, + name=album.name, + artist_name=album.artist_name, + artist_mbid=None, + image_url=album.image_url or None, + listen_count=album.playcount, + in_library=(album.mbid or "").lower() in library_mbids if album.mbid else False, + source="lastfm", + ) + for album in lfm_albums + ] + start = min(offset, len(albums)) + end = start + limit + return PopularAlbumsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=albums[start:end], + offset=offset, + limit=limit, + has_more=end < len(albums), + ) + + async def get_your_top_albums( + self, limit: int = 10, source: str | None = None + ) -> PopularAlbumsResponse: + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_popular_albums_lastfm(limit) + + lb_username = self._get_lb_username() + if not lb_username: + empty = PopularTimeRange( + range_key="all_time", label="All Time", featured=None, items=[], total_count=0 + ) + return PopularAlbumsResponse( + this_week=empty, this_month=empty, this_year=empty, all_time=empty + ) + + library_albums = await self._lidarr_repo.get_library() + library_mbids = { + (a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id + } + ranges = ["this_week", "this_month", "this_year", "all_time"] + tasks = { + r: self._lb_repo.get_user_top_release_groups( + username=lb_username, range_=r, count=limit + 1 + ) + for r in ranges + } + results = await self._execute_tasks(tasks) + response_data: dict[str, PopularTimeRange] = {} + for r in ranges: + rgs = results.get(r) or [] + albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs] + response_data[r] = PopularTimeRange( + range_key=r, + label=HomeDataTransformers.get_range_label(r), + featured=albums[0] if albums else None, + items=albums[1:limit] if len(albums) > 1 else [], + total_count=len(albums), + ) + return PopularAlbumsResponse( + this_week=response_data["this_week"], + this_month=response_data["this_month"], + this_year=response_data["this_year"], + all_time=response_data["all_time"], + ) + + async def get_your_top_albums_by_range( + self, + range_key: str = "this_week", + limit: int = 25, + offset: int = 0, + source: str | None = None, + ) -> PopularAlbumsRangeResponse: + allowed_ranges = ["this_week", "this_month", "this_year", "all_time"] + if range_key not in allowed_ranges: + range_key = "this_week" + resolved = self._resolve_source(source) + if resolved == "lastfm" and self._lfm_repo: + return await self._get_popular_albums_lastfm_range( + range_key=range_key, limit=limit, offset=offset + ) + + lb_username = self._get_lb_username() + if not lb_username: + return PopularAlbumsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=[], + offset=offset, + limit=limit, + has_more=False, + ) + + library_albums, rgs = await asyncio.gather( + self._lidarr_repo.get_library(), + self._lb_repo.get_user_top_release_groups( + username=lb_username, range_=range_key, count=limit + 1, offset=offset + ), + ) + library_mbids = { + (a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id + } + albums = [self._transformers.lb_release_to_home(rg, library_mbids) for rg in rgs] + has_more = len(albums) > limit + items = albums[:limit] + return PopularAlbumsRangeResponse( + range_key=range_key, + label=HomeDataTransformers.get_range_label(range_key), + items=items, + offset=offset, + limit=limit, + has_more=has_more, + ) + + @staticmethod + def _lastfm_period_for_range(range_key: str) -> str: + mapping = { + "this_week": "7day", + "this_month": "1month", + "this_year": "12month", + "all_time": "overall", + } + return mapping.get(range_key, "1month") diff --git a/backend/services/home/facade.py b/backend/services/home/facade.py new file mode 100644 index 0000000..29d2f35 --- /dev/null +++ b/backend/services/home/facade.py @@ -0,0 +1,300 @@ +"""Slim HomeService facade — preserves constructor signature, delegates to sub-services.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any + +from api.v1.schemas.home import ( + HomeResponse, + HomeGenre, + HomeArtist, + DiscoverPreview, + HomeIntegrationStatus, +) +from api.v1.schemas.library import LibraryAlbum +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + JellyfinRepositoryProtocol, + LidarrRepositoryProtocol, + MusicBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from services.preferences_service import PreferencesService +from services.home_transformers import HomeDataTransformers +from infrastructure.cache.cache_keys import DISCOVER_RESPONSE_PREFIX, HOME_RESPONSE_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.http.deduplication import deduplicate + +from .integration_helpers import HomeIntegrationHelpers +from .section_builders import HomeSectionBuilders +from .genre_service import GenreService +from services.weekly_exploration_service import WeeklyExplorationService + +logger = logging.getLogger(__name__) + + +class HomeService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + jellyfin_repo: JellyfinRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + preferences_service: PreferencesService, + memory_cache: CacheInterface | None = None, + lastfm_repo: LastFmRepositoryProtocol | None = None, + audiodb_image_service: Any = None, + cache_dir: Path | None = None, + ): + self._lb_repo = listenbrainz_repo + self._jf_repo = jellyfin_repo + self._lidarr_repo = lidarr_repo + self._mb_repo = musicbrainz_repo + self._preferences = preferences_service + self._memory_cache = memory_cache + self._lfm_repo = lastfm_repo + self._audiodb_image_service = audiodb_image_service + self._transformers = HomeDataTransformers(jellyfin_repo) + + self._helpers = HomeIntegrationHelpers(preferences_service) + self._builders = HomeSectionBuilders(self._transformers) + self._genre = GenreService( + musicbrainz_repo, memory_cache, audiodb_image_service, + cache_dir=cache_dir, preferences_service=preferences_service, + ) + self._weekly_exploration = WeeklyExplorationService(listenbrainz_repo, musicbrainz_repo) + + def clear_genre_disk_cache(self) -> int: + """Delegate to GenreService to delete genre section files from disk.""" + return self._genre.clear_disk_cache() + + def _resolve_source(self, source: str | None = None) -> str: + return self._helpers.resolve_source(source) + + def _build_service_prompts(self, lb_enabled, lidarr_configured, lfm_enabled): + return self._builders.build_service_prompts(lb_enabled, lidarr_configured, lfm_enabled) + + def get_integration_status(self) -> HomeIntegrationStatus: + return HomeIntegrationStatus( + listenbrainz=self._helpers.is_listenbrainz_enabled(), + jellyfin=self._helpers.is_jellyfin_enabled(), + lidarr=self._helpers.is_lidarr_configured(), + youtube=self._helpers.is_youtube_enabled(), + youtube_api=self._helpers.is_youtube_api_enabled(), + localfiles=self._helpers.is_local_files_enabled(), + lastfm=self._helpers.is_lastfm_enabled(), + navidrome=self._helpers.is_navidrome_enabled(), + ) + + async def get_genre_artist( + self, genre_name: str, exclude_mbids: set[str] | None = None + ) -> str | None: + return await self._genre.get_genre_artist(genre_name, exclude_mbids) + + async def get_genre_artists_batch(self, genres: list[str]) -> dict[str, str | None]: + return await self._genre.get_genre_artists_batch(genres) + + def _get_home_cache_key(self, source: str | None = None) -> str: + resolved = self._helpers.resolve_source(source) + lb_enabled = self._helpers.is_listenbrainz_enabled() + lfm_enabled = self._helpers.is_lastfm_enabled() + lb_username = self._helpers.get_listenbrainz_username() or "" + lfm_username = self._helpers.get_lastfm_username() or "" + return f"{HOME_RESPONSE_PREFIX}{resolved}:{lb_enabled}:{lfm_enabled}:{lb_username}:{lfm_username}" + + async def get_cached_home_data(self, source: str | None = None) -> HomeResponse | None: + if not self._memory_cache: + return None + cache_key = self._get_home_cache_key(source) + return await self._memory_cache.get(cache_key) + + @deduplicate(lambda self, source=None: self._get_home_cache_key(source)) + async def get_home_data(self, source: str | None = None) -> HomeResponse: + HOME_CACHE_TTL = 300 + resolved_source = self._helpers.resolve_source(source) + + if self._memory_cache: + cache_key = self._get_home_cache_key(source) + cached = await self._memory_cache.get(cache_key) + if cached is not None: + if not cached.genre_artists: + genre_section = await self._genre.get_cached_genre_section(resolved_source) + if genre_section: + from infrastructure.serialization import clone_with_updates + cached = clone_with_updates(cached, { + "genre_artists": genre_section[0], + "genre_artist_images": genre_section[1], + }) + if cached.genre_list and cached.genre_list.items: + cur_names = [g.name for g in cached.genre_list.items[:20] if isinstance(g, HomeGenre)] + missing = [n for n in cur_names if n not in genre_section[0]] + if missing: + asyncio.create_task( + self._genre.build_and_cache_genre_section(resolved_source, cur_names) + ) + return cached + + integration_status = self.get_integration_status() + lb_enabled = integration_status.listenbrainz + lidarr_configured = integration_status.lidarr + lfm_enabled = integration_status.lastfm + username = self._helpers.get_listenbrainz_username() + lfm_username = self._helpers.get_lastfm_username() + + tasks: dict[str, Any] = {} + + if resolved_source == "listenbrainz": + tasks["lb_trending_artists"] = self._lb_repo.get_sitewide_top_artists(count=20) + tasks["lb_trending_albums"] = self._lb_repo.get_sitewide_top_release_groups(count=20) + elif resolved_source == "lastfm" and self._lfm_repo and lfm_enabled: + tasks["lfm_global_top_artists"] = self._lfm_repo.get_global_top_artists(limit=20) + if lfm_username: + tasks["lfm_top_albums"] = self._lfm_repo.get_user_top_albums( + lfm_username, period="1month", limit=20 + ) + else: + logger.warning( + "Last.fm enabled as home source but username is missing; skipping top album fetch" + ) + + if lidarr_configured: + tasks["library_albums"] = self._lidarr_repo.get_library() + tasks["library_artists"] = self._lidarr_repo.get_artists_from_library() + tasks["recently_imported"] = self._lidarr_repo.get_recently_imported(limit=15) + + if resolved_source == "listenbrainz" and lb_enabled and username: + lb_settings = self._preferences.get_listenbrainz_connection() + self._lb_repo.configure(username=username, user_token=lb_settings.user_token) + tasks["lb_listens"] = self._lb_repo.get_user_listens(count=20) + tasks["lb_loved"] = self._lb_repo.get_user_loved_recordings(count=20) + tasks["lb_genres"] = self._lb_repo.get_user_genre_activity(username) + tasks["lb_user_top_rgs"] = self._lb_repo.get_user_top_release_groups( + username=username, range_="this_month", count=20 + ) + tasks["lb_weekly_exploration"] = self._weekly_exploration.build_section(username) + elif resolved_source == "lastfm" and self._lfm_repo and lfm_enabled and lfm_username: + tasks["lfm_recent"] = self._lfm_repo.get_user_recent_tracks( + lfm_username, limit=20 + ) + tasks["lfm_loved"] = self._lfm_repo.get_user_loved_tracks( + lfm_username, limit=20 + ) + + results = await self._helpers.execute_tasks(tasks) + + library_albums: list[LibraryAlbum] = results.get("library_albums") or [] + library_artists: list[dict] = results.get("library_artists") or [] + recently_imported: list[LibraryAlbum] = results.get("recently_imported") or [] + library_artist_mbids = { + a.get("mbid", "").lower() for a in library_artists if a.get("mbid") + } + library_album_mbids = { + (a.musicbrainz_id or "").lower() for a in library_albums if a.musicbrainz_id + } + + response = HomeResponse(integration_status=integration_status) + + response.recently_added = self._builders.build_recently_added_section(recently_imported) + response.library_artists = self._builders.build_library_artists_section(library_artists) + response.library_albums = self._builders.build_library_albums_section(library_albums) + + if resolved_source == "listenbrainz": + response.trending_artists = self._builders.build_trending_artists_section( + results, library_artist_mbids + ) + response.popular_albums = self._builders.build_popular_albums_section( + results, library_album_mbids + ) + response.your_top_albums = self._builders.build_lb_user_top_albums_section( + results, library_album_mbids + ) + response.recently_played = self._builders.build_listenbrainz_recent_section(results) + response.favorite_artists = self._builders.build_listenbrainz_favorites_section(results) + response.weekly_exploration = results.get("lb_weekly_exploration") + elif resolved_source == "lastfm": + response.trending_artists = self._builders.build_lastfm_trending_section( + results, library_artist_mbids + ) + response.your_top_albums = self._builders.build_lastfm_top_albums_section( + results, library_album_mbids + ) + response.recently_played = self._builders.build_lastfm_recent_section(results) + response.favorite_artists = self._builders.build_lastfm_favorites_section(results) + + response.genre_list = self._builders.build_genre_list_section( + library_albums, + results.get("lb_genres") if resolved_source == "listenbrainz" else None, + ) + + if response.genre_list and response.genre_list.items: + genre_names = [ + g.name for g in response.genre_list.items[:20] + if isinstance(g, HomeGenre) + ] + cached_section = await self._genre.get_cached_genre_section(resolved_source) + if cached_section: + response.genre_artists, response.genre_artist_images = cached_section + missing = [n for n in genre_names if n not in cached_section[0]] + if missing: + asyncio.create_task( + self._genre.build_and_cache_genre_section(resolved_source, genre_names) + ) + elif genre_names: + asyncio.create_task( + self._genre.build_and_cache_genre_section(resolved_source, genre_names) + ) + + response.service_prompts = self._builders.build_service_prompts( + lb_enabled, + lidarr_configured, + lfm_enabled, + ) + + response.discover_preview = await self._build_discover_preview() + + if self._memory_cache: + cache_key = self._get_home_cache_key(source) + await self._memory_cache.set(cache_key, response, HOME_CACHE_TTL) + + return response + + async def _build_discover_preview(self) -> DiscoverPreview | None: + if not self._memory_cache: + return None + try: + from api.v1.schemas.discover import DiscoverResponse as DR + resolved = self._helpers.resolve_source(None) + cache_key = f"{DISCOVER_RESPONSE_PREFIX}{resolved}" + cached = await self._memory_cache.get(cache_key) + if not cached or not isinstance(cached, DR): + return None + if not cached.because_you_listen_to: + return None + first = cached.because_you_listen_to[0] + preview_items = [ + item for item in first.section.items[:15] + if isinstance(item, HomeArtist) + ] + return DiscoverPreview( + seed_artist=first.seed_artist, + seed_artist_mbid=first.seed_artist_mbid, + items=preview_items, + ) + except Exception as e: # noqa: BLE001 + logger.debug(f"Could not build discover preview: {e}") + return None + + async def _resolve_release_mbids(self, release_ids: list[str]) -> dict[str, str]: + if not release_ids: + return {} + import asyncio as _asyncio + tasks = [self._mb_repo.get_release_group_id_from_release(rid) for rid in release_ids] + results = await _asyncio.gather(*tasks, return_exceptions=True) + rg_map: dict[str, str] = {} + for rid, rg_id in zip(release_ids, results): + if isinstance(rg_id, str) and rg_id: + rg_map[rid] = rg_id + return rg_map diff --git a/backend/services/home/genre_service.py b/backend/services/home/genre_service.py new file mode 100644 index 0000000..1f8866d --- /dev/null +++ b/backend/services/home/genre_service.py @@ -0,0 +1,220 @@ +"""Genre artist resolution and image enrichment.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from pathlib import Path +from typing import Any + +from infrastructure.cache.cache_keys import GENRE_ARTIST_PREFIX, GENRE_SECTION_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from repositories.protocols import MusicBrainzRepositoryProtocol + +logger = logging.getLogger(__name__) + +VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377" +GENRE_CACHE_TTL = 24 * 60 * 60 +GENRE_SECTION_TTL_DEFAULT = 6 * 60 * 60 + + +class GenreService: + def __init__( + self, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + memory_cache: CacheInterface | None = None, + audiodb_image_service: Any = None, + cache_dir: Path | None = None, + preferences_service: Any = None, + ): + self._mb_repo = musicbrainz_repo + self._memory_cache = memory_cache + self._audiodb_image_service = audiodb_image_service + self._preferences_service = preferences_service + self._genre_build_locks: dict[str, asyncio.Lock] = {} + + self._genre_section_dir: Path | None = None + if cache_dir: + self._genre_section_dir = cache_dir / "genre_sections" + self._genre_section_dir.mkdir(parents=True, exist_ok=True) + + def _get_genre_section_ttl(self) -> int: + if self._preferences_service: + try: + adv = self._preferences_service.get_advanced_settings() + return getattr(adv, "genre_section_ttl", GENRE_SECTION_TTL_DEFAULT) + except Exception: # noqa: BLE001 + pass + return GENRE_SECTION_TTL_DEFAULT + + async def get_cached_genre_section( + self, source_key: str + ) -> tuple[dict[str, str | None], dict[str, str | None]] | None: + cache_key = f"{GENRE_SECTION_PREFIX}{source_key}" + ttl = self._get_genre_section_ttl() + + if self._memory_cache: + cached = await self._memory_cache.get(cache_key) + if cached is not None: + return cached + + if self._genre_section_dir: + file_path = self._genre_section_dir / f"{source_key}.json" + try: + if file_path.exists(): + data = json.loads(file_path.read_text()) + built_at = data.get("built_at", 0) + if time.time() - built_at < ttl: + result = (data["genre_artists"], data["genre_artist_images"]) + if self._memory_cache: + remaining = max(1, int(ttl - (time.time() - built_at))) + await self._memory_cache.set(cache_key, result, remaining) + return result + except Exception: # noqa: BLE001 + logger.debug("Failed to read genre section from disk for %s", source_key) + + return None + + async def save_genre_section( + self, + source_key: str, + genre_artists: dict[str, str | None], + genre_artist_images: dict[str, str | None], + ) -> None: + cache_key = f"{GENRE_SECTION_PREFIX}{source_key}" + ttl = self._get_genre_section_ttl() + result = (genre_artists, genre_artist_images) + + if self._memory_cache: + await self._memory_cache.set(cache_key, result, ttl) + + if self._genre_section_dir: + file_path = self._genre_section_dir / f"{source_key}.json" + try: + payload = json.dumps({ + "genre_artists": genre_artists, + "genre_artist_images": genre_artist_images, + "built_at": time.time(), + }) + file_path.write_text(payload) + except Exception: # noqa: BLE001 + logger.warning("Failed to write genre section to disk for %s", source_key) + + async def build_and_cache_genre_section( + self, source_key: str, genre_names: list[str] + ) -> None: + if source_key not in self._genre_build_locks: + self._genre_build_locks[source_key] = asyncio.Lock() + lock = self._genre_build_locks[source_key] + if lock.locked(): + logger.debug("Genre section build already in progress for source=%s, skipping", source_key) + return + async with lock: + try: + logger.debug( + "Building genre section for source=%s (%d genres)", + source_key, + len(genre_names), + ) + genre_artists = await self.get_genre_artists_batch(genre_names) + genre_artist_images = await self.resolve_genre_artist_images(genre_artists) + await self.save_genre_section(source_key, genre_artists, genre_artist_images) + logger.debug("Genre section build complete for source=%s", source_key) + except Exception as exc: # noqa: BLE001 + logger.error("Genre section build failed for source=%s: %s", source_key, exc) + + async def get_genre_artist( + self, genre_name: str, exclude_mbids: set[str] | None = None + ) -> str | None: + cache_key = f"{GENRE_ARTIST_PREFIX}{genre_name.lower()}" + + if self._memory_cache and not exclude_mbids: + cached = await self._memory_cache.get(cache_key) + if cached is not None: + return cached if cached != "" else None + + try: + artists = await self._mb_repo.search_artists_by_tag(genre_name, limit=10) + for artist in artists: + if not artist.musicbrainz_id or artist.musicbrainz_id == VARIOUS_ARTISTS_MBID: + continue + if exclude_mbids and artist.musicbrainz_id in exclude_mbids: + continue + if self._memory_cache and not exclude_mbids: + await self._memory_cache.set(cache_key, artist.musicbrainz_id, GENRE_CACHE_TTL) + return artist.musicbrainz_id + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to fetch artist for genre '{genre_name}': {e}") + + if self._memory_cache and not exclude_mbids: + await self._memory_cache.set(cache_key, "", GENRE_CACHE_TTL) + + return None + + async def get_genre_artists_batch(self, genres: list[str]) -> dict[str, str | None]: + if not genres: + return {} + capped = genres[:20] + + raw_results = await asyncio.gather( + *(self.get_genre_artist(genre) for genre in capped) + ) + + used_mbids: set[str] = set() + results: dict[str, str | None] = {} + for genre, mbid in zip(capped, raw_results): + if mbid and mbid not in used_mbids: + results[genre] = mbid + used_mbids.add(mbid) + elif mbid and mbid in used_mbids: + alt = await self.get_genre_artist(genre, exclude_mbids=used_mbids) + results[genre] = alt + if alt: + used_mbids.add(alt) + else: + results[genre] = None + return results + + def clear_disk_cache(self) -> int: + """Delete all genre section JSON files from disk.""" + if not self._genre_section_dir or not self._genre_section_dir.exists(): + return 0 + count = 0 + for f in self._genre_section_dir.glob("*.json"): + f.unlink(missing_ok=True) + count += 1 + if count: + logger.info("Cleared %d genre section files from disk", count) + return count + + async def resolve_genre_artist_images( + self, genre_artists: dict[str, str | None] + ) -> dict[str, str | None]: + if not self._audiodb_image_service or not genre_artists: + return {} + + sem = asyncio.Semaphore(5) + + async def _resolve_one(genre: str, mbid: str) -> tuple[str, str | None]: + async with sem: + try: + images = await self._audiodb_image_service.fetch_and_cache_artist_images(mbid) + if images and not images.is_negative: + url = images.wide_thumb_url or images.banner_url or images.fanart_url + if url: + return (genre, url) + except Exception as exc: # noqa: BLE001 + logger.debug("Failed to resolve genre image for %s: %s", genre, exc) + return (genre, None) + + tasks = [ + _resolve_one(genre, mbid) + for genre, mbid in genre_artists.items() + if mbid + ] + if not tasks: + return {} + results = await asyncio.gather(*tasks) + return {genre: url for genre, url in results if url} diff --git a/backend/services/home/integration_helpers.py b/backend/services/home/integration_helpers.py new file mode 100644 index 0000000..28c2d7b --- /dev/null +++ b/backend/services/home/integration_helpers.py @@ -0,0 +1,94 @@ +"""Shared integration checks and helpers used by HomeService and HomeChartsService.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + + +class HomeIntegrationHelpers: + def __init__(self, preferences_service: PreferencesService): + self._preferences = preferences_service + + def is_listenbrainz_enabled(self) -> bool: + lb_settings = self._preferences.get_listenbrainz_connection() + return lb_settings.enabled and bool(lb_settings.username) + + def is_jellyfin_enabled(self) -> bool: + jf_settings = self._preferences.get_jellyfin_connection() + return jf_settings.enabled and bool(jf_settings.jellyfin_url) and bool(jf_settings.api_key) + + def is_lidarr_configured(self) -> bool: + lidarr_connection = self._preferences.get_lidarr_connection() + return bool(lidarr_connection.lidarr_url) and bool(lidarr_connection.lidarr_api_key) + + def is_youtube_enabled(self) -> bool: + yt_settings = self._preferences.get_youtube_connection() + return yt_settings.enabled + + def is_youtube_api_enabled(self) -> bool: + yt_settings = self._preferences.get_youtube_connection() + return yt_settings.enabled and yt_settings.api_enabled and yt_settings.has_valid_api_key() + + def is_local_files_enabled(self) -> bool: + lf_settings = self._preferences.get_local_files_connection() + return lf_settings.enabled and bool(lf_settings.music_path) + + def is_navidrome_enabled(self) -> bool: + nd_settings = self._preferences.get_navidrome_connection() + return ( + nd_settings.enabled + and bool(nd_settings.navidrome_url) + and bool(nd_settings.username) + and bool(nd_settings.password) + ) + + def is_lastfm_enabled(self) -> bool: + return self._preferences.is_lastfm_enabled() + + def get_listenbrainz_username(self) -> str | None: + lb_settings = self._preferences.get_listenbrainz_connection() + return lb_settings.username if lb_settings.enabled else None + + def get_lastfm_username(self) -> str | None: + lf_settings = self._preferences.get_lastfm_connection() + return lf_settings.username if lf_settings.enabled else None + + def get_lb_username(self) -> str | None: + lb_settings = self._preferences.get_listenbrainz_connection() + if lb_settings.enabled and lb_settings.username: + return lb_settings.username + return None + + def resolve_source(self, source: str | None) -> str: + if source in ("listenbrainz", "lastfm"): + resolved = source + else: + resolved = self._preferences.get_primary_music_source().source + lb_enabled = self.is_listenbrainz_enabled() + lfm_enabled = self.is_lastfm_enabled() + if resolved == "listenbrainz" and not lb_enabled and lfm_enabled: + return "lastfm" + if resolved == "lastfm" and not lfm_enabled and lb_enabled: + return "listenbrainz" + return resolved + + async def execute_tasks(self, tasks: dict[str, Any]) -> dict[str, Any]: + if not tasks: + return {} + keys = list(tasks.keys()) + coros = list(tasks.values()) + raw_results = await asyncio.gather(*coros, return_exceptions=True) + results = {} + for key, result in zip(keys, raw_results): + if isinstance(result, Exception): + logger.warning(f"Task {key} failed: {result}") + results[key] = None + else: + results[key] = result + return results diff --git a/backend/services/home/section_builders.py b/backend/services/home/section_builders.py new file mode 100644 index 0000000..835c8ee --- /dev/null +++ b/backend/services/home/section_builders.py @@ -0,0 +1,278 @@ +"""Home page section builder methods — pure data transformation.""" + +from __future__ import annotations + +from typing import Any + +from api.v1.schemas.home import HomeSection, HomeGenre, ServicePrompt +from api.v1.schemas.library import LibraryAlbum +from services.home_transformers import HomeDataTransformers + + +class HomeSectionBuilders: + def __init__(self, transformers: HomeDataTransformers): + self._transformers = transformers + + def build_recently_added_section( + self, recently_imported: list[LibraryAlbum] + ) -> HomeSection: + return HomeSection( + title="Recently Added", + type="albums", + items=[self._transformers.lidarr_album_to_home(a) for a in recently_imported[:15]], + source="lidarr", + ) + + def build_library_artists_section(self, library_artists: list[dict]) -> HomeSection: + sorted_artists = sorted( + library_artists, key=lambda x: x.get("album_count", 0), reverse=True + )[:15] + items = [ + a for a in (self._transformers.lidarr_artist_to_home(artist) for artist in sorted_artists) + if a is not None + ] + return HomeSection(title="Your Artists", type="artists", items=items, source="lidarr") + + def build_library_albums_section(self, library_albums: list[LibraryAlbum]) -> HomeSection: + sorted_albums = sorted( + library_albums, key=lambda x: (x.year or 0, x.album or ""), reverse=True + )[:15] + return HomeSection( + title="Your Albums", + type="albums", + items=[self._transformers.lidarr_album_to_home(a) for a in sorted_albums], + source="lidarr", + ) + + def build_trending_artists_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection: + artists = results.get("lb_trending_artists") or [] + items = [ + a for a in (self._transformers.lb_artist_to_home(artist, library_mbids) for artist in artists[:15]) + if a is not None + ] + return HomeSection( + title="Trending Artists", + type="artists", + items=items, + source="listenbrainz" if artists else None, + ) + + def build_popular_albums_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection: + albums = results.get("lb_trending_albums") or [] + return HomeSection( + title="Popular Right Now", + type="albums", + items=[self._transformers.lb_release_to_home(a, library_mbids) for a in albums[:15]], + source="listenbrainz" if albums else None, + ) + + def build_lb_user_top_albums_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + release_groups = results.get("lb_user_top_rgs") or [] + if not release_groups: + return None + items = [ + self._transformers.lb_release_to_home(rg, library_mbids) + for rg in release_groups[:15] + ] + return HomeSection( + title="Your Top Albums", + type="albums", + items=items, + source="listenbrainz", + ) + + def build_genre_list_section( + self, library_albums: list[LibraryAlbum], lb_genres: list | None = None + ) -> HomeSection: + genres = self._transformers.extract_genres_from_library(library_albums, lb_genres) + source = "listenbrainz" if lb_genres else ("lidarr" if library_albums else None) + return HomeSection(title="Browse by Genre", type="genres", items=genres, source=source) + + def build_fresh_releases_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + releases = results.get("lb_fresh") + if not releases: + return None + return HomeSection( + title="New From Artists You Follow", + type="albums", + items=[self._transformers.lb_release_to_home(r, library_mbids) for r in releases[:15]], + source="listenbrainz", + ) + + def build_recommended_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + artists = results.get("lb_top_artists") + if not artists: + return None + items = [ + a for a in (self._transformers.lb_artist_to_home(artist, library_mbids) for artist in artists[:15]) + if a is not None + ] + return HomeSection( + title="Based on Your Listening", type="artists", items=items, source="listenbrainz" + ) + + def build_listenbrainz_recent_section(self, results: dict[str, Any]) -> HomeSection | None: + listens = results.get("lb_listens") or [] + if not listens: + return None + items = [ + self._transformers.lb_listen_to_home_track(listen) + for listen in listens[:15] + ] + return HomeSection( + title="Recently Scrobbled", + type="tracks", + items=items, + source="listenbrainz", + ) + + def build_listenbrainz_favorites_section(self, results: dict[str, Any]) -> HomeSection | None: + loved = results.get("lb_loved") or [] + if not loved: + return None + items = [ + self._transformers.lb_feedback_to_home_track(recording) + for recording in loved[:15] + ] + return HomeSection( + title="Your Favorites", + type="tracks", + items=items, + source="listenbrainz", + ) + + def build_lastfm_trending_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection: + artists = results.get("lfm_global_top_artists") or [] + items = [ + a for a in ( + self._transformers.lastfm_artist_to_home(artist, library_mbids) + for artist in artists[:15] + ) + if a is not None + ] + return HomeSection( + title="Trending Artists", + type="artists", + items=items, + source="lastfm" if artists else None, + ) + + def build_lastfm_top_albums_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection: + albums = results.get("lfm_top_albums") or [] + items = [ + a for a in ( + self._transformers.lastfm_album_to_home(album, library_mbids) + for album in albums[:15] + ) + if a is not None + ] + return HomeSection( + title="Your Top Albums", + type="albums", + items=items, + source="lastfm" if albums else None, + ) + + def build_lastfm_recommended_section( + self, results: dict[str, Any], library_mbids: set[str] + ) -> HomeSection | None: + artists = results.get("lfm_top_artists") or [] + if not artists: + return None + items = [ + a for a in ( + self._transformers.lastfm_artist_to_home(artist, library_mbids) + for artist in artists[:15] + ) + if a is not None + ] + if not items: + return None + return HomeSection( + title="Based on Your Listening", + type="artists", + items=items, + source="lastfm", + ) + + def build_lastfm_recent_section(self, results: dict[str, Any]) -> HomeSection | None: + tracks = results.get("lfm_recent") or [] + if not tracks: + return None + items = [ + self._transformers.lastfm_recent_to_home_track(track) + for track in tracks[:15] + ] + if not items: + return None + return HomeSection( + title="Recently Scrobbled", + type="tracks", + items=items, + source="lastfm", + ) + + def build_lastfm_favorites_section(self, results: dict[str, Any]) -> HomeSection | None: + tracks = results.get("lfm_loved") or [] + if not tracks: + return None + items = [ + self._transformers.lastfm_loved_to_home_track(track) + for track in tracks[:15] + ] + return HomeSection( + title="Your Favorites", + type="tracks", + items=items, + source="lastfm", + ) + + @staticmethod + def build_service_prompts( + lb_enabled: bool, + lidarr_configured: bool = True, + lfm_enabled: bool = False, + ) -> list[ServicePrompt]: + prompts = [] + if not lidarr_configured: + prompts.append(ServicePrompt( + service="lidarr-connection", + title="Connect Lidarr", + description="Lidarr is required to manage your music library, request albums, and track your collection. Set up the connection to get started.", + icon="🎶", + color="accent", + features=["Music library management", "Album requests", "Collection tracking", "Automatic imports"], + )) + if not lb_enabled and not lfm_enabled: + prompts.append(ServicePrompt( + service="listenbrainz", + title="Connect ListenBrainz", + description="Get recommendations from your listening history, spot new releases from artists you already love, and keep an eye on your top genres. Connect Last.fm too if you want global listener stats.", + icon="🎵", + color="primary", + features=["Personalized recommendations", "New release alerts", "Listening stats", "Top genres"], + )) + if not lfm_enabled and not lb_enabled: + prompts.append(ServicePrompt( + service="lastfm", + title="Connect Last.fm", + description="Track your listening, compare stats, and discover music that matches your taste.", + icon="🎸", + color="primary", + features=["Scrobbling", "Global listener stats", "Artist recommendations", "Play history"], + )) + return prompts diff --git a/backend/services/home_charts_service.py b/backend/services/home_charts_service.py new file mode 100644 index 0000000..74e72b0 --- /dev/null +++ b/backend/services/home_charts_service.py @@ -0,0 +1,4 @@ +"""Backward-compat shim — re-exports HomeChartsService from services.home.charts_service.""" +from services.home.charts_service import HomeChartsService + +__all__ = ["HomeChartsService"] diff --git a/backend/services/home_service.py b/backend/services/home_service.py new file mode 100644 index 0000000..8490f95 --- /dev/null +++ b/backend/services/home_service.py @@ -0,0 +1,4 @@ +"""Backward-compat shim — re-exports HomeService from services.home.facade.""" +from services.home.facade import HomeService + +__all__ = ["HomeService"] diff --git a/backend/services/home_transformers.py b/backend/services/home_transformers.py new file mode 100644 index 0000000..496be5e --- /dev/null +++ b/backend/services/home_transformers.py @@ -0,0 +1,262 @@ +from datetime import UTC, datetime + +from api.v1.schemas.home import HomeArtist, HomeAlbum, HomeGenre, HomeTrack +from api.v1.schemas.library import LibraryAlbum +from repositories.lastfm_models import ( + LastFmAlbum, + LastFmArtist, + LastFmLovedTrack, + LastFmRecentTrack, + LastFmSimilarArtist, +) +from repositories.listenbrainz_models import ListenBrainzFeedbackRecording, ListenBrainzListen +from repositories.protocols import ( + ListenBrainzArtist, + ListenBrainzReleaseGroup, + JellyfinRepositoryProtocol, +) +from repositories.jellyfin_models import JellyfinItem + + +class HomeDataTransformers: + def __init__(self, jellyfin_repo: JellyfinRepositoryProtocol | None = None): + self._jf_repo = jellyfin_repo + + @staticmethod + def _cover_url(release_mbid: str | None) -> str | None: + if release_mbid: + return f"/api/v1/covers/release/{release_mbid}?size=250" + return None + + def lidarr_album_to_home(self, album: LibraryAlbum) -> HomeAlbum: + return HomeAlbum( + mbid=album.musicbrainz_id, + name=album.album or "Unknown Album", + artist_name=album.artist, + artist_mbid=album.artist_mbid, + image_url=album.cover_url, + release_date=str(album.year) if album.year else None, + in_library=True, + ) + + def lidarr_artist_to_home(self, artist_data: dict) -> HomeArtist | None: + mbid = artist_data.get("mbid") + if not mbid: + return None + return HomeArtist( + mbid=mbid, + name=artist_data.get("name", "Unknown Artist"), + image_url=None, + listen_count=artist_data.get("album_count"), + in_library=True, + ) + + def lb_artist_to_home( + self, + artist: ListenBrainzArtist, + library_mbids: set[str] + ) -> HomeArtist | None: + mbid = artist.artist_mbids[0] if artist.artist_mbids else None + if not mbid: + return None + return HomeArtist( + mbid=mbid, + name=artist.artist_name, + image_url=None, + listen_count=artist.listen_count, + in_library=mbid.lower() in library_mbids, + ) + + def lb_release_to_home( + self, + release: ListenBrainzReleaseGroup, + library_mbids: set[str] + ) -> HomeAlbum: + artist_mbid = release.artist_mbids[0] if release.artist_mbids else None + return HomeAlbum( + mbid=release.release_group_mbid, + name=release.release_group_name, + artist_name=release.artist_name, + artist_mbid=artist_mbid, + image_url=None, + release_date=None, + listen_count=release.listen_count, + in_library=(release.release_group_mbid or "").lower() in library_mbids, + ) + + def jf_item_to_artist( + self, + item: JellyfinItem, + library_mbids: set[str] + ) -> HomeArtist | None: + mbid = None + if item.provider_ids: + mbid = item.provider_ids.get("MusicBrainzArtist") + + artist_name = item.artist_name or item.name + if not artist_name: + return None + + image_url = None + if self._jf_repo: + if item.artist_id: + image_url = self._jf_repo.get_image_url(item.artist_id, item.image_tag) + else: + image_url = self._jf_repo.get_image_url(item.id, item.image_tag) + + return HomeArtist( + mbid=mbid, + name=artist_name, + image_url=image_url, + listen_count=item.play_count, + in_library=mbid.lower() in library_mbids if mbid else False, + ) + + def lastfm_artist_to_home( + self, + artist: LastFmArtist, + library_mbids: set[str], + ) -> HomeArtist | None: + return HomeArtist( + mbid=artist.mbid, + name=artist.name, + image_url=None, + listen_count=artist.playcount, + in_library=artist.mbid.lower() in library_mbids if artist.mbid else False, + source="lastfm", + ) + + def lastfm_album_to_home( + self, + album: LastFmAlbum, + library_mbids: set[str], + ) -> HomeAlbum | None: + return HomeAlbum( + mbid=None, + name=album.name, + artist_name=album.artist_name, + artist_mbid=None, + image_url=album.image_url or None, + listen_count=album.playcount, + in_library=album.mbid.lower() in library_mbids if album.mbid else False, + source="lastfm", + ) + + def lastfm_similar_to_home( + self, + similar: LastFmSimilarArtist, + library_mbids: set[str], + ) -> HomeArtist | None: + return HomeArtist( + mbid=similar.mbid, + name=similar.name, + image_url=None, + in_library=similar.mbid.lower() in library_mbids if similar.mbid else False, + source="lastfm", + ) + + def lastfm_recent_to_home( + self, + track: LastFmRecentTrack, + library_mbids: set[str], + ) -> HomeAlbum | None: + return HomeAlbum( + mbid=track.album_mbid, + name=track.album_name or track.track_name, + artist_name=track.artist_name, + artist_mbid=track.artist_mbid, + image_url=track.image_url or None, + in_library=track.album_mbid.lower() in library_mbids if track.album_mbid else False, + source="lastfm", + ) + + def lb_listen_to_home_track(self, listen: ListenBrainzListen) -> HomeTrack: + listened_at = None + if listen.listened_at: + listened_at = datetime.fromtimestamp(listen.listened_at, tz=UTC).isoformat() + artist_mbid = listen.artist_mbids[0] if listen.artist_mbids else None + image_url = self._cover_url(listen.release_mbid) + return HomeTrack( + mbid=listen.recording_mbid, + name=listen.track_name, + artist_name=listen.artist_name, + artist_mbid=artist_mbid, + album_name=listen.release_name, + listen_count=None, + listened_at=listened_at, + image_url=image_url, + ) + + def lastfm_recent_to_home_track(self, track: LastFmRecentTrack) -> HomeTrack: + listened_at = None + if track.timestamp: + listened_at = datetime.fromtimestamp(track.timestamp, tz=UTC).isoformat() + return HomeTrack( + mbid=None, + name=track.track_name, + artist_name=track.artist_name, + artist_mbid=None, + album_name=track.album_name or None, + listen_count=None, + listened_at=listened_at, + image_url=track.image_url or None, + ) + + def lastfm_loved_to_home_track(self, track: LastFmLovedTrack) -> HomeTrack: + return HomeTrack( + mbid=None, + name=track.track_name, + artist_name=track.artist_name, + artist_mbid=None, + album_name=track.album_name or None, + listen_count=None, + listened_at=None, + image_url=track.image_url or None, + ) + + def lb_feedback_to_home_track(self, feedback: ListenBrainzFeedbackRecording) -> HomeTrack: + artist_mbid = feedback.artist_mbids[0] if feedback.artist_mbids else None + image_url = self._cover_url(feedback.release_mbid) + return HomeTrack( + mbid=feedback.recording_mbid, + name=feedback.track_name, + artist_name=feedback.artist_name, + artist_mbid=artist_mbid, + album_name=feedback.release_name, + listen_count=None, + listened_at=None, + image_url=image_url, + ) + + def extract_genres_from_library( + self, + albums: list[LibraryAlbum], + lb_genres: list | None = None + ) -> list[HomeGenre]: + if lb_genres: + return [ + HomeGenre(name=g.genre, listen_count=g.listen_count) + for g in lb_genres[:20] + ] + + default_genres = [ + "Rock", "Pop", "Hip Hop", "Electronic", "Jazz", + "Classical", "R&B", "Country", "Metal", "Folk", + "Blues", "Reggae", "Soul", "Punk", "Indie", + "Alternative", "Dance", "Soundtrack", "World", "Latin" + ] + + return [HomeGenre(name=g) for g in default_genres] + + @staticmethod + def get_range_label(range_key: str) -> str: + labels = { + "this_week": "This Week", + "this_month": "This Month", + "this_year": "This Year", + "all_time": "All Time", + "week": "This Week", + "month": "This Month", + "year": "This Year", + } + return labels.get(range_key, range_key.replace("_", " ").title()) diff --git a/backend/services/jellyfin_library_service.py b/backend/services/jellyfin_library_service.py new file mode 100644 index 0000000..e979af4 --- /dev/null +++ b/backend/services/jellyfin_library_service.py @@ -0,0 +1,292 @@ +import asyncio +import logging + +from api.v1.schemas.jellyfin import ( + JellyfinAlbumDetail, + JellyfinAlbumMatch, + JellyfinAlbumSummary, + JellyfinArtistSummary, + JellyfinLibraryStats, + JellyfinSearchResponse, + JellyfinTrackInfo, +) +from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url +from repositories.protocols import JellyfinRepositoryProtocol +from repositories.jellyfin_models import JellyfinItem +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + + +class JellyfinLibraryService: + _DEFAULT_RECENTLY_PLAYED_TTL = 300 + _DEFAULT_FAVORITES_TTL = 300 + _DEFAULT_GENRES_TTL = 3600 + _DEFAULT_STATS_TTL = 600 + + def __init__( + self, + jellyfin_repo: JellyfinRepositoryProtocol, + preferences_service: PreferencesService, + ): + self._jellyfin = jellyfin_repo + self._preferences = preferences_service + + def _get_recently_played_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_jellyfin_recently_played + except Exception: # noqa: BLE001 + return self._DEFAULT_RECENTLY_PLAYED_TTL + + def _get_favorites_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_jellyfin_favorites + except Exception: # noqa: BLE001 + return self._DEFAULT_FAVORITES_TTL + + def _get_genres_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_jellyfin_genres + except Exception: # noqa: BLE001 + return self._DEFAULT_GENRES_TTL + + def _get_stats_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_jellyfin_library_stats + except Exception: # noqa: BLE001 + return self._DEFAULT_STATS_TTL + + def _item_to_album_summary(self, item: JellyfinItem) -> JellyfinAlbumSummary: + pids = item.provider_ids or {} + mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum") + artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist") + image_url = prefer_release_group_cover_url( + mbid, + self._jellyfin.get_image_url(item.id, item.image_tag), + size=500, + ) + return JellyfinAlbumSummary( + jellyfin_id=item.id, + name=item.name, + artist_name=item.artist_name or "", + year=item.year, + track_count=item.child_count or 0, + image_url=image_url, + musicbrainz_id=mbid, + artist_musicbrainz_id=artist_mbid, + ) + + def _item_to_track_info(self, item: JellyfinItem) -> JellyfinTrackInfo: + duration_seconds = (item.duration_ticks / 10_000_000.0) if item.duration_ticks else 0.0 + return JellyfinTrackInfo( + jellyfin_id=item.id, + title=item.name, + track_number=item.index_number or 0, + disc_number=item.parent_index_number or 1, + duration_seconds=duration_seconds, + album_name=item.album_name or "", + artist_name=item.artist_name or "", + codec=item.codec, + bitrate=item.bitrate, + ) + + @staticmethod + def _fix_missing_track_numbers(tracks: list[JellyfinTrackInfo]) -> list[JellyfinTrackInfo]: + """When all tracks share the same track_number (e.g. Jellyfin returns 0 + for every track), assign 1-based indices so downstream Map lookups work.""" + if len(tracks) <= 1: + return tracks + tracks_by_disc: dict[int, list[JellyfinTrackInfo]] = {} + for track in tracks: + tracks_by_disc.setdefault(track.disc_number, []).append(track) + + renumbered_ids: dict[str, int] = {} + for disc_tracks in tracks_by_disc.values(): + numbers = {t.track_number for t in disc_tracks} + if len(numbers) > 1: + continue + for i, track in enumerate(disc_tracks, start=1): + renumbered_ids[track.jellyfin_id] = i + + fixed: list[JellyfinTrackInfo] = [] + for track in tracks: + track_number = renumbered_ids.get(track.jellyfin_id, track.track_number) + fixed.append(JellyfinTrackInfo( + jellyfin_id=track.jellyfin_id, + title=track.title, + track_number=track_number, + disc_number=track.disc_number, + duration_seconds=track.duration_seconds, + album_name=track.album_name, + artist_name=track.artist_name, + codec=track.codec, + bitrate=track.bitrate, + )) + return fixed + + async def get_albums( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "SortName", + sort_order: str = "Ascending", + genre: str | None = None, + ) -> tuple[list[JellyfinAlbumSummary], int]: + items, total = await self._jellyfin.get_albums( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, genre=genre + ) + return [self._item_to_album_summary(i) for i in items], total + + async def get_album_detail(self, album_id: str) -> JellyfinAlbumDetail | None: + item = await self._jellyfin.get_album_detail(album_id) + if not item: + return None + + tracks_items = await self._jellyfin.get_album_tracks(album_id) + tracks = self._fix_missing_track_numbers( + [self._item_to_track_info(t) for t in tracks_items] + ) + pids = item.provider_ids or {} + mbid = pids.get("MusicBrainzReleaseGroup") or pids.get("MusicBrainzAlbum") + artist_mbid = pids.get("MusicBrainzAlbumArtist") or pids.get("MusicBrainzArtist") + image_url = prefer_release_group_cover_url( + mbid, + self._jellyfin.get_image_url(item.id, item.image_tag), + size=500, + ) + + return JellyfinAlbumDetail( + jellyfin_id=item.id, + name=item.name, + artist_name=item.artist_name or "", + year=item.year, + track_count=len(tracks), + image_url=image_url, + musicbrainz_id=mbid, + artist_musicbrainz_id=artist_mbid, + tracks=tracks, + ) + + async def get_album_tracks(self, album_id: str) -> list[JellyfinTrackInfo]: + items = await self._jellyfin.get_album_tracks(album_id) + return self._fix_missing_track_numbers( + [self._item_to_track_info(i) for i in items] + ) + + async def match_album_by_mbid(self, musicbrainz_id: str) -> JellyfinAlbumMatch: + item = await self._jellyfin.get_album_by_mbid(musicbrainz_id) + if not item: + return JellyfinAlbumMatch(found=False) + + tracks_items = await self._jellyfin.get_album_tracks(item.id) + tracks = self._fix_missing_track_numbers( + [self._item_to_track_info(t) for t in tracks_items] + ) + + return JellyfinAlbumMatch( + found=True, + jellyfin_album_id=item.id, + tracks=tracks, + ) + + async def get_artists( + self, limit: int = 50, offset: int = 0 + ) -> list[JellyfinArtistSummary]: + items = await self._jellyfin.get_artists(limit=limit, offset=offset) + artists = [] + for item in items: + mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None + image_url = prefer_artist_cover_url( + mbid, + self._jellyfin.get_image_url(item.id, item.image_tag), + size=500, + ) + artists.append(JellyfinArtistSummary( + jellyfin_id=item.id, + name=item.name, + image_url=image_url, + album_count=item.album_count or 0, + musicbrainz_id=mbid, + )) + return artists + + async def search( + self, query: str + ) -> JellyfinSearchResponse: + items = await self._jellyfin.search_items(query) + albums = [] + artists = [] + tracks = [] + for item in items: + if item.type == "MusicAlbum": + albums.append(self._item_to_album_summary(item)) + elif item.type in ("MusicArtist", "Artist"): + mbid = item.provider_ids.get("MusicBrainzArtist") if item.provider_ids else None + image_url = prefer_artist_cover_url( + mbid, + self._jellyfin.get_image_url(item.id, item.image_tag), + size=500, + ) + artists.append(JellyfinArtistSummary( + jellyfin_id=item.id, + name=item.name, + image_url=image_url, + musicbrainz_id=mbid, + )) + elif item.type == "Audio": + tracks.append(self._item_to_track_info(item)) + return JellyfinSearchResponse(albums=albums, artists=artists, tracks=tracks) + + async def get_recently_played(self, limit: int = 20) -> list[JellyfinAlbumSummary]: + ttl_seconds = self._get_recently_played_ttl() + items = await self._jellyfin.get_recently_played( + limit=limit, + ttl_seconds=ttl_seconds, + ) + seen_album_ids: set[str] = set() + unique_album_ids: list[str] = [] + for item in items: + aid = item.album_id or item.parent_id + if not aid or aid in seen_album_ids: + continue + seen_album_ids.add(aid) + unique_album_ids.append(aid) + if len(unique_album_ids) >= limit: + break + + _CONCURRENCY_LIMIT = 5 + sem = asyncio.Semaphore(_CONCURRENCY_LIMIT) + + async def _fetch(aid: str) -> JellyfinItem | None: + async with sem: + return await self._jellyfin.get_album_detail(aid) + + details = await asyncio.gather( + *(_fetch(aid) for aid in unique_album_ids) + ) + return [ + self._item_to_album_summary(detail) + for detail in details + if detail is not None + ] + + async def get_favorites(self, limit: int = 20) -> list[JellyfinAlbumSummary]: + ttl_seconds = self._get_favorites_ttl() + items = await self._jellyfin.get_favorite_albums( + limit=limit, + ttl_seconds=ttl_seconds, + ) + return [self._item_to_album_summary(i) for i in items] + + async def get_genres(self) -> list[str]: + ttl_seconds = self._get_genres_ttl() + return await self._jellyfin.get_genres(ttl_seconds=ttl_seconds) + + async def get_stats(self) -> JellyfinLibraryStats: + ttl_seconds = self._get_stats_ttl() + raw = await self._jellyfin.get_library_stats(ttl_seconds=ttl_seconds) + return JellyfinLibraryStats( + total_tracks=raw.get("total_tracks", 0), + total_albums=raw.get("total_albums", 0), + total_artists=raw.get("total_artists", 0), + ) diff --git a/backend/services/jellyfin_playback_service.py b/backend/services/jellyfin_playback_service.py new file mode 100644 index 0000000..d6c02cd --- /dev/null +++ b/backend/services/jellyfin_playback_service.py @@ -0,0 +1,82 @@ +import logging + +import httpx + +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError +from infrastructure.constants import JELLYFIN_TICKS_PER_SECOND +from repositories.protocols import JellyfinRepositoryProtocol + +logger = logging.getLogger(__name__) + + +class JellyfinPlaybackService: + def __init__(self, jellyfin_repo: JellyfinRepositoryProtocol): + self._jellyfin = jellyfin_repo + + async def start_playback(self, item_id: str, play_session_id: str | None = None) -> str: + """Report playback start to Jellyfin. Returns play_session_id. + + Handles nullable PlaySessionId and checks for ErrorCode in the + PlaybackInfoResponse (NotAllowed, NoCompatibleStream, RateLimitExceeded). + """ + resolved_play_session_id = play_session_id + + if not resolved_play_session_id: + info = await self._jellyfin.get_playback_info(item_id) + + error_code = info.get("ErrorCode") + if error_code: + raise PlaybackNotAllowedError( + f"Jellyfin playback not allowed: {error_code}" + ) + + resolved_play_session_id = info.get("PlaySessionId") + if not resolved_play_session_id: + logger.warning( + "Jellyfin returned null PlaySessionId for item %s — " + "streaming without session reporting", + item_id, + ) + return "" + + try: + await self._jellyfin.report_playback_start(item_id, resolved_play_session_id) + except (httpx.HTTPError, ExternalServiceError) as e: + logger.error( + "Failed to report playback start for %s: %s", item_id, e + ) + + return resolved_play_session_id + + async def report_progress( + self, + item_id: str, + play_session_id: str, + position_seconds: float, + is_paused: bool, + ) -> None: + if not play_session_id: + return + position_ticks = int(position_seconds * JELLYFIN_TICKS_PER_SECOND) + try: + await self._jellyfin.report_playback_progress( + item_id, play_session_id, position_ticks, is_paused + ) + except (httpx.HTTPError, ExternalServiceError) as e: + logger.warning("Progress report failed for %s: %s", item_id, e) + + async def stop_playback( + self, + item_id: str, + play_session_id: str, + position_seconds: float, + ) -> None: + if not play_session_id: + return + position_ticks = int(position_seconds * JELLYFIN_TICKS_PER_SECOND) + try: + await self._jellyfin.report_playback_stopped( + item_id, play_session_id, position_ticks + ) + except (httpx.HTTPError, ExternalServiceError) as e: + logger.warning("Stop report failed for %s: %s", item_id, e) diff --git a/backend/services/lastfm_auth_service.py b/backend/services/lastfm_auth_service.py new file mode 100644 index 0000000..f1bc1cb --- /dev/null +++ b/backend/services/lastfm_auth_service.py @@ -0,0 +1,63 @@ +import logging +import time + +import msgspec + +from core.exceptions import ConfigurationError +from repositories.protocols import LastFmRepositoryProtocol + +logger = logging.getLogger(__name__) + +MAX_PENDING_TOKENS = 5 +TOKEN_TTL_SECONDS = 600 + +LASTFM_AUTH_URL = "https://www.last.fm/api/auth/" + + +class TokenEntry(msgspec.Struct): + token: str + created_at: float + + +class LastFmAuthService: + def __init__(self, lastfm_repo: LastFmRepositoryProtocol): + self._repo = lastfm_repo + self._pending_tokens: dict[str, TokenEntry] = {} + + def _evict_expired(self) -> None: + now = time.time() + expired = [ + k for k, v in self._pending_tokens.items() + if now - v.created_at > TOKEN_TTL_SECONDS + ] + for k in expired: + del self._pending_tokens[k] + + async def request_token(self, api_key: str) -> tuple[str, str]: + self._evict_expired() + + if len(self._pending_tokens) >= MAX_PENDING_TOKENS: + oldest_key = min(self._pending_tokens, key=lambda k: self._pending_tokens[k].created_at) + del self._pending_tokens[oldest_key] + + result = await self._repo.get_token() + token = result.token + + self._pending_tokens[token] = TokenEntry(token=token, created_at=time.time()) + + auth_url = f"{LASTFM_AUTH_URL}?api_key={api_key}&token={token}" + return token, auth_url + + async def exchange_session(self, token: str) -> tuple[str, str, str]: + self._evict_expired() + + if token not in self._pending_tokens: + raise ConfigurationError( + "Token expired or not recognized. Please restart the authorization flow." + ) + + result = await self._repo.get_session(token) + + self._pending_tokens.pop(token, None) + + return result.name, result.key, "" diff --git a/backend/services/library_precache_service.py b/backend/services/library_precache_service.py new file mode 100644 index 0000000..09da863 --- /dev/null +++ b/backend/services/library_precache_service.py @@ -0,0 +1,4 @@ +"""Backward-compat shim — re-exports LibraryPrecacheService from services.precache.orchestrator.""" +from services.precache.orchestrator import LibraryPrecacheService + +__all__ = ["LibraryPrecacheService"] diff --git a/backend/services/library_service.py b/backend/services/library_service.py new file mode 100644 index 0000000..0d93f29 --- /dev/null +++ b/backend/services/library_service.py @@ -0,0 +1,691 @@ +import logging +import asyncio +import time +from typing import Any, TYPE_CHECKING +from repositories.protocols import LidarrRepositoryProtocol, CoverArtRepositoryProtocol +from api.v1.schemas.library import ( + LibraryAlbum, + LibraryArtist, + LibraryGroupedArtist, + LibraryStatsResponse, + SyncLibraryResponse, + AlbumRemovePreviewResponse, + AlbumRemoveResponse, + ResolvedTrack, + TrackResolveResponse, + TrackResolveRequest, +) +from infrastructure.persistence import LibraryDB, SyncStateStore, GenreIndex +from infrastructure.cache.cache_keys import ( + lidarr_requested_mbids_key, + SOURCE_RESOLUTION_PREFIX, + ALBUM_INFO_PREFIX, ARTIST_INFO_PREFIX, LIDARR_PREFIX, + LIDARR_ALBUM_DETAILS_PREFIX, LIDARR_ALBUM_TRACKS_PREFIX, + LIDARR_ALBUM_TRACKFILES_PREFIX, LIDARR_ARTIST_ALBUMS_PREFIX, + LIDARR_ARTIST_DETAILS_PREFIX, LIDARR_ARTIST_IMAGE_PREFIX, + LIDARR_ALBUM_IMAGE_PREFIX, LIDARR_REQUESTED_PREFIX, +) +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.serialization import clone_with_updates +from core.exceptions import ExternalServiceError +from services.cache_status_service import CacheStatusService +from services.library_precache_service import LibraryPrecacheService + +if TYPE_CHECKING: + from services.preferences_service import PreferencesService + from services.local_files_service import LocalFilesService + from services.jellyfin_library_service import JellyfinLibraryService + from services.navidrome_library_service import NavidromeLibraryService + +logger = logging.getLogger(__name__) + +MAX_RESOLVE_ITEMS = 50 + + +class LibraryService: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + library_db: LibraryDB, + cover_repo: CoverArtRepositoryProtocol, + preferences_service: 'PreferencesService', + memory_cache: CacheInterface | None = None, + disk_cache: DiskMetadataCache | None = None, + artist_discovery_service: Any = None, + audiodb_image_service: Any = None, + local_files_service: 'LocalFilesService | None' = None, + jellyfin_library_service: 'JellyfinLibraryService | None' = None, + navidrome_library_service: 'NavidromeLibraryService | None' = None, + sync_state_store: SyncStateStore | None = None, + genre_index: GenreIndex | None = None, + ): + self._lidarr_repo = lidarr_repo + self._library_db = library_db + self._cover_repo = cover_repo + self._preferences_service = preferences_service + self._memory_cache = memory_cache + self._disk_cache = disk_cache + self._local_files_service = local_files_service + self._jellyfin_library_service = jellyfin_library_service + self._navidrome_library_service = navidrome_library_service + self._can_precache = sync_state_store is not None and genre_index is not None + self._precache_service: LibraryPrecacheService | None = None + if self._can_precache: + self._precache_service = LibraryPrecacheService( + lidarr_repo, cover_repo, preferences_service, + sync_state_store, genre_index, library_db, + artist_discovery_service=artist_discovery_service, + audiodb_image_service=audiodb_image_service, + ) + self._last_sync_time: float = 0.0 + self._last_manual_sync: float = 0.0 + self._manual_sync_cooldown: float = 60.0 + self._global_sync_cooldown: float = 30.0 + self._sync_lock = asyncio.Lock() + + def _update_last_sync_timestamp(self) -> None: + try: + lidarr_settings = self._preferences_service.get_lidarr_settings() + updated_settings = clone_with_updates(lidarr_settings, {'last_sync': int(time.time())}) + self._preferences_service.save_lidarr_settings(updated_settings) + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to update last_sync timestamp: {e}") + + @staticmethod + def _normalized_album_cover_url(album_mbid: str | None, cover_url: str | None) -> str | None: + return prefer_release_group_cover_url(album_mbid, cover_url, size=500) + + async def get_library(self) -> list[LibraryAlbum]: + try: + albums_data = await self._library_db.get_albums() + + if not albums_data: + logger.info("Library cache is empty, syncing from Lidarr") + await self.sync_library() + albums_data = await self._library_db.get_albums() + + albums = [ + LibraryAlbum( + artist=album['artist_name'], + album=album['title'], + year=album.get('year'), + monitored=bool(album.get('monitored', 1)), + quality=None, + cover_url=self._normalized_album_cover_url( + album.get('mbid'), + album.get('cover_url'), + ), + musicbrainz_id=album.get('mbid'), + artist_mbid=album.get('artist_mbid'), + date_added=album.get('date_added') + ) + for album in albums_data + ] + + return albums + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch library: {e}") + raise ExternalServiceError(f"Failed to fetch library: {e}") + + async def get_library_mbids(self) -> list[str]: + if not self._lidarr_repo.is_configured(): + return [] + try: + mbids_set = await self._lidarr_repo.get_library_mbids(include_release_ids=False) + return list(mbids_set) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch library mbids: {e}") + raise ExternalServiceError(f"Failed to fetch library mbids: {e}") + + async def get_requested_mbids(self) -> list[str]: + if not self._lidarr_repo.is_configured(): + return [] + try: + requested_set = await self._lidarr_repo.get_requested_mbids() + return list(requested_set) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch requested mbids: {e}") + raise ExternalServiceError(f"Failed to fetch requested mbids: {e}") + + async def get_artists(self, limit: int | None = None) -> list[LibraryArtist]: + try: + artists_data = await self._library_db.get_artists(limit=limit) + + if not artists_data: + logger.info("Artists cache is empty, syncing from Lidarr") + await self.sync_library() + artists_data = await self._library_db.get_artists(limit=limit) + + artists = [ + LibraryArtist( + mbid=artist['mbid'], + name=artist['name'], + album_count=artist.get('album_count', 0), + date_added=artist.get('date_added') + ) + for artist in artists_data + ] + + return artists + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch artists: {e}") + raise ExternalServiceError(f"Failed to fetch artists: {e}") + + async def get_albums_paginated( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "date_added", + sort_order: str = "desc", + search: str | None = None, + ) -> tuple[list[LibraryAlbum], int]: + try: + albums_data, total = await self._library_db.get_albums_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search, + ) + + if not albums_data and offset == 0 and not search: + logger.info("Library cache is empty, syncing from Lidarr") + await self.sync_library() + albums_data, total = await self._library_db.get_albums_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search, + ) + + albums = [ + LibraryAlbum( + artist=album['artist_name'], + album=album['title'], + year=album.get('year'), + monitored=bool(album.get('monitored', 1)), + quality=None, + cover_url=self._normalized_album_cover_url(album.get('mbid'), album.get('cover_url')), + musicbrainz_id=album.get('mbid'), + artist_mbid=album.get('artist_mbid'), + date_added=album.get('date_added'), + ) + for album in albums_data + ] + return albums, total + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch paginated albums: {e}") + raise ExternalServiceError(f"Failed to fetch paginated albums: {e}") + + async def get_artists_paginated( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "name", + sort_order: str = "asc", + search: str | None = None, + ) -> tuple[list[LibraryArtist], int]: + try: + artists_data, total = await self._library_db.get_artists_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search, + ) + + if not artists_data and offset == 0 and not search: + logger.info("Artists cache is empty, syncing from Lidarr") + await self.sync_library() + artists_data, total = await self._library_db.get_artists_paginated( + limit=limit, offset=offset, sort_by=sort_by, sort_order=sort_order, search=search, + ) + + artists = [ + LibraryArtist( + mbid=artist['mbid'], + name=artist['name'], + album_count=artist.get('album_count', 0), + date_added=artist.get('date_added'), + ) + for artist in artists_data + ] + return artists, total + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch paginated artists: {e}") + raise ExternalServiceError(f"Failed to fetch paginated artists: {e}") + + async def get_recently_added(self, limit: int = 20) -> list[LibraryAlbum]: + + try: + if self._lidarr_repo.is_configured(): + albums = await self._lidarr_repo.get_recently_imported(limit=limit) + else: + albums = [] + + if not albums: + logger.info("No recent imports from history, falling back to cache") + albums_data = await self._library_db.get_recently_added(limit=limit) + + albums = [ + LibraryAlbum( + artist=album['artist_name'], + album=album['title'], + year=album.get('year'), + monitored=bool(album.get('monitored', 1)), + quality=None, + cover_url=self._normalized_album_cover_url( + album.get('mbid'), + album.get('cover_url'), + ), + musicbrainz_id=album.get('mbid'), + artist_mbid=album.get('artist_mbid'), + date_added=album.get('date_added') + ) + for album in albums_data + ] + + return albums + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch recently added: {e}") + raise ExternalServiceError(f"Failed to fetch recently added: {e}") + + async def sync_library(self, is_manual: bool = False) -> SyncLibraryResponse: + from services.cache_status_service import CacheStatusService + + if not self._lidarr_repo.is_configured(): + raise ExternalServiceError("Lidarr is not configured — set a Lidarr API key in Settings to sync your library.") + + try: + status_service = CacheStatusService() + + async with self._sync_lock: + current_time = time.time() + + time_since_last_sync = current_time - self._last_sync_time + if time_since_last_sync < self._global_sync_cooldown: + remaining = int(self._global_sync_cooldown - time_since_last_sync) + logger.info(f"Global sync cooldown active ({remaining}s remaining). Skipping sync.") + raise ExternalServiceError( + f"Sync cooldown active. Please wait {remaining} seconds before syncing again." + ) + + if is_manual: + time_since_last_manual = current_time - self._last_manual_sync + if time_since_last_manual < self._manual_sync_cooldown: + remaining = int(self._manual_sync_cooldown - time_since_last_manual) + raise ExternalServiceError( + f"Manual sync cooldown active. Please wait {remaining} seconds before syncing again." + ) + + if status_service.is_syncing(): + if is_manual: + logger.warning("Library sync already in progress - cancelling previous sync to start fresh") + await status_service.cancel_current_sync() + await status_service.wait_for_completion() + else: + logger.info("Library sync already in progress - skipping auto-sync") + return SyncLibraryResponse(status="skipped", artists=0, albums=0) + + self._last_sync_time = current_time + if is_manual: + self._last_manual_sync = current_time + + logger.info("Starting library sync from Lidarr") + + albums = await self._lidarr_repo.get_library() + artists = await self._lidarr_repo.get_artists_from_library() + + albums_data = [ + { + 'mbid': album.musicbrainz_id or f"unknown_{album.album}", + 'artist_mbid': album.artist_mbid, + 'artist_name': album.artist, + 'title': album.album, + 'year': album.year, + 'cover_url': self._normalized_album_cover_url( + album.musicbrainz_id, + album.cover_url, + ), + 'monitored': album.monitored, + 'date_added': album.date_added + } + for album in albums + ] + + await self._library_db.save_library(artists, albums_data) + logger.info("Library cache updated - unmonitored items removed") + + if self._precache_service is None: + logger.warning("Precache skipped — sync_state_store/genre_index not provided") + return + + task = asyncio.create_task(self._precache_service.precache_library_resources(artists, albums)) + + def on_task_done(t: asyncio.Task): + try: + exc = t.exception() + if exc: + logger.error(f"Precache task failed: {exc}") + except asyncio.CancelledError: + logger.info("Precache task was cancelled") + finally: + status_service.set_current_task(None) + + task.add_done_callback(on_task_done) + status_service.set_current_task(task) + + logger.info(f"Library sync complete: {len(artists)} artists, {len(albums)} albums") + + self._update_last_sync_timestamp() + + return SyncLibraryResponse( + status='success', + artists=len(artists), + albums=len(albums), + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Couldn't sync the library: {e}") + raise ExternalServiceError(f"Couldn't sync the library: {e}") + + async def get_stats(self) -> LibraryStatsResponse: + try: + stats = await self._library_db.get_stats() + + return LibraryStatsResponse( + artist_count=stats['artist_count'], + album_count=stats['album_count'], + last_sync=stats['last_sync'], + db_size_bytes=stats['db_size_bytes'], + db_size_mb=round(stats['db_size_bytes'] / (1024 * 1024), 2) + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch library stats: {e}") + raise ExternalServiceError(f"Failed to fetch library stats: {e}") + + async def clear_cache(self) -> None: + try: + await self._library_db.clear() + logger.info("Library cache cleared") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to clear library cache: {e}") + raise ExternalServiceError(f"Failed to clear library cache: {e}") + + async def get_library_grouped(self) -> list[LibraryGroupedArtist]: + if not self._lidarr_repo.is_configured(): + return [] + try: + return await self._lidarr_repo.get_library_grouped() + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch grouped library: {e}") + raise ExternalServiceError(f"Failed to fetch grouped library: {e}") + + async def get_album_removal_preview(self, album_mbid: str) -> AlbumRemovePreviewResponse: + try: + album_data = await self._lidarr_repo.get_album_details(album_mbid) + if not album_data or not album_data.get("id"): + raise ExternalServiceError(f"Album not found in Lidarr: {album_mbid}") + + artist_mbid = album_data.get("artist_mbid") + artist_name = album_data.get("artist_name", "Unknown") + + artist_will_be_removed = False + if artist_mbid: + artist_albums = await self._lidarr_repo.get_artist_albums(artist_mbid) + monitored_count = sum(1 for album in artist_albums if album.get("monitored")) + artist_will_be_removed = monitored_count <= 1 + + return AlbumRemovePreviewResponse( + success=True, + artist_will_be_removed=artist_will_be_removed, + artist_name=artist_name if artist_will_be_removed else None, + ) + except ExternalServiceError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to build removal preview for album {album_mbid}: {e}") + raise ExternalServiceError(f"Failed to load removal preview: {e}") + + async def remove_album(self, album_mbid: str, delete_files: bool = False) -> AlbumRemoveResponse: + try: + album_data = await self._lidarr_repo.get_album_details(album_mbid) + if not album_data or not album_data.get("id"): + raise ExternalServiceError(f"Album not found in Lidarr: {album_mbid}") + + album_id = album_data["id"] + artist_mbid = album_data.get("artist_mbid") + artist_name = album_data.get("artist_name", "Unknown") + + await self._lidarr_repo.delete_album(album_id, delete_files=delete_files) + + artist_removed = False + if artist_mbid: + try: + if self._memory_cache: + await asyncio.gather( + self._memory_cache.delete(f"lidarr_artist_albums:{artist_mbid}"), + self._memory_cache.delete(f"lidarr_artist_details:{artist_mbid}"), + ) + artist_albums = await self._lidarr_repo.get_artist_albums(artist_mbid) + if not any(a.get("monitored") for a in artist_albums): + artist_details = await self._lidarr_repo.get_artist_details(artist_mbid) + if artist_details and artist_details.get("id"): + await self._lidarr_repo.delete_artist( + artist_details["id"], delete_files=delete_files + ) + artist_removed = True + logger.info(f"Auto-removed artist '{artist_name}' (no remaining albums)") + except Exception as e: # noqa: BLE001 + logger.warning( + f"Album '{album_mbid}' removed but artist cleanup failed for '{artist_mbid}': {e}" + ) + + try: + await self._invalidate_caches_after_removal(album_mbid, artist_mbid, artist_removed=artist_removed) + except Exception as e: # noqa: BLE001 + logger.warning(f"Album '{album_mbid}' removed but cache invalidation failed: {e}") + + return AlbumRemoveResponse( + success=True, + artist_removed=artist_removed, + artist_name=artist_name if artist_removed else None, + ) + except ExternalServiceError: + raise + except Exception as e: # noqa: BLE001 + logger.error(f"Couldn't remove album {album_mbid}: {e}") + raise ExternalServiceError(f"Couldn't remove this album: {e}") + + async def _invalidate_caches_after_removal(self, album_mbid: str, artist_mbid: str | None, *, artist_removed: bool = False) -> None: + await self._library_db.clear() + + if self._memory_cache: + keys_to_delete = [ + f"{ALBUM_INFO_PREFIX}{album_mbid}", + f"{LIDARR_ALBUM_DETAILS_PREFIX}{album_mbid}", + lidarr_requested_mbids_key(), + ] + if artist_mbid: + keys_to_delete.extend([ + f"{LIDARR_ARTIST_ALBUMS_PREFIX}{artist_mbid}", + f"{LIDARR_ARTIST_DETAILS_PREFIX}{artist_mbid}", + f"{ARTIST_INFO_PREFIX}{artist_mbid}", + ]) + await asyncio.gather( + *[self._memory_cache.delete(k) for k in keys_to_delete], + self._memory_cache.clear_prefix(f"{LIDARR_PREFIX}library:"), + self._memory_cache.clear_prefix(f"{LIDARR_PREFIX}artists:"), + self._memory_cache.clear_prefix(LIDARR_ALBUM_IMAGE_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ALBUM_DETAILS_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ALBUM_TRACKS_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ALBUM_TRACKFILES_PREFIX), + self._memory_cache.clear_prefix(LIDARR_REQUESTED_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ARTIST_IMAGE_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ARTIST_DETAILS_PREFIX), + self._memory_cache.clear_prefix(LIDARR_ARTIST_ALBUMS_PREFIX), + ) + + if self._disk_cache: + coros = [self._disk_cache.delete_album(album_mbid)] + if artist_mbid: + coros.append(self._disk_cache.delete_artist(artist_mbid)) + await asyncio.gather(*coros) + + if self._cover_repo: + try: + await self._cover_repo.delete_covers_for_album(album_mbid) + if artist_mbid and artist_removed: + await self._cover_repo.delete_covers_for_artist(artist_mbid) + except Exception: # noqa: BLE001 + logger.warning("Failed to clean up cover images after removal", exc_info=True) + + # Track resolution — extracted from routes/library.py + + async def _resolve_album_tracks( + self, + album_mbid: str, + ) -> dict[str, tuple[str, str, str | None, float | None]]: + """Resolve album MBID to {disc:track: (source, source_id, format, duration)}. + + Priority: local → navidrome → jellyfin. + Uses source_resolution cache (1h TTL). + """ + if self._memory_cache is None: + raise ExternalServiceError("Memory cache not available for track resolution") + + cache_key = f"{SOURCE_RESOLUTION_PREFIX}_tracks:{album_mbid}" + cached = await self._memory_cache.get(cache_key) + if cached is not None: + return cached + + result: dict[str, tuple[str, str, str | None, float | None]] = {} + + def _track_key(disc: int, track: int) -> str: + return f"{disc}:{track}" + + if self._local_files_service: + try: + match = await self._local_files_service.match_album_by_mbid(album_mbid) + if match.found: + for t in match.tracks: + key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number) + if key not in result: + result[key] = ( + "local", + str(t.track_file_id), + t.format or None, + t.duration_seconds, + ) + except Exception: # noqa: BLE001 + logger.debug("Local track resolution failed for %s", album_mbid, exc_info=True) + + nd_enabled = False + try: + nd_settings = self._preferences_service.get_navidrome_connection_raw() + nd_enabled = nd_settings.enabled + except AttributeError: + logger.debug("Navidrome settings unavailable during track resolution", exc_info=True) + + if nd_enabled and self._navidrome_library_service: + try: + nav_id = self._navidrome_library_service.lookup_navidrome_id(album_mbid) + if nav_id: + detail = await self._navidrome_library_service.get_album_detail(nav_id) + if detail: + for t in detail.tracks: + key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number) + if key not in result: + result[key] = ( + "navidrome", + t.navidrome_id, + t.codec, + t.duration_seconds, + ) + except Exception: # noqa: BLE001 + logger.debug("Navidrome track resolution failed for %s", album_mbid, exc_info=True) + + jf_enabled = False + try: + jf_settings = self._preferences_service.get_jellyfin_connection() + jf_enabled = jf_settings.enabled + except AttributeError: + logger.debug("Jellyfin settings unavailable during track resolution", exc_info=True) + + if jf_enabled and self._jellyfin_library_service: + try: + match = await self._jellyfin_library_service.match_album_by_mbid(album_mbid) + if match.found: + all_same = len(match.tracks) > 1 and len({t.track_number for t in match.tracks}) == 1 + if not all_same: + for t in match.tracks: + key = _track_key(getattr(t, "disc_number", 1) or 1, t.track_number) + if key not in result: + result[key] = ( + "jellyfin", + t.jellyfin_id, + t.codec, + t.duration_seconds, + ) + except Exception: # noqa: BLE001 + logger.debug("Jellyfin track resolution failed for %s", album_mbid, exc_info=True) + + await self._memory_cache.set(cache_key, result, ttl_seconds=3600) + return result + + async def resolve_tracks_batch( + self, + items: list, + ) -> TrackResolveResponse: + """Resolve a batch of track items to stream URLs.""" + items = items[:MAX_RESOLVE_ITEMS] + if not items: + return TrackResolveResponse(items=[]) + + album_mbids = {it.release_group_mbid for it in items if it.release_group_mbid} + + sem = asyncio.Semaphore(5) + + async def _resolve_one(mbid: str) -> tuple[str, dict]: + async with sem: + return mbid, await self._resolve_album_tracks(mbid) + + tasks = [_resolve_one(mbid) for mbid in album_mbids] + album_maps: dict[str, dict] = {} + for r in await asyncio.gather(*tasks, return_exceptions=True): + if isinstance(r, Exception): + logger.warning("Album resolution failed: %s", r) + continue + mbid, track_map = r + album_maps[mbid] = track_map + + resolved: list[ResolvedTrack] = [] + for item in items: + base = ResolvedTrack( + release_group_mbid=item.release_group_mbid, + disc_number=item.disc_number, + track_number=item.track_number, + ) + + if not item.release_group_mbid or item.track_number is None: + resolved.append(base) + continue + + track_map = album_maps.get(item.release_group_mbid, {}) + lookup_key = f"{item.disc_number or 1}:{item.track_number}" + match = track_map.get(lookup_key) + if not match: + resolved.append(base) + continue + + source, source_id, fmt, duration = match + stream_url = None + if source == "local": + stream_url = f"/api/v1/stream/local/{source_id}" + elif source == "navidrome": + stream_url = f"/api/v1/stream/navidrome/{source_id}" + elif source == "jellyfin": + stream_url = f"/api/v1/stream/jellyfin/{source_id}" + + resolved.append(ResolvedTrack( + release_group_mbid=item.release_group_mbid, + disc_number=item.disc_number, + track_number=item.track_number, + source=source, + track_source_id=source_id, + stream_url=stream_url, + format=fmt, + duration=duration, + )) + + return TrackResolveResponse(items=resolved) diff --git a/backend/services/local_files_service.py b/backend/services/local_files_service.py new file mode 100644 index 0000000..c6a5a63 --- /dev/null +++ b/backend/services/local_files_service.py @@ -0,0 +1,628 @@ +import asyncio +import logging +import os +import shutil +from collections.abc import AsyncGenerator +from pathlib import Path +from typing import Any + +import aiofiles + +from api.v1.schemas.local_files import ( + FormatInfo, + LocalAlbumMatch, + LocalAlbumSummary, + LocalPaginatedResponse, + LocalStorageStats, + LocalTrackInfo, +) +from api.v1.schemas.settings import LocalFilesVerifyResponse +from core.exceptions import ExternalServiceError, ResourceNotFoundError +from infrastructure.cache.cache_keys import LOCAL_FILES_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.constants import STREAM_CHUNK_SIZE +from infrastructure.serialization import to_jsonable +from repositories.protocols import LidarrRepositoryProtocol +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +AUDIO_EXTENSIONS: set[str] = { + ".flac", ".mp3", ".ogg", ".m4a", ".aac", ".wav", ".wma", ".opus", +} + +CONTENT_TYPE_MAP: dict[str, str] = { + ".flac": "audio/flac", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".wav": "audio/wav", + ".wma": "audio/x-ms-wma", + ".opus": "audio/opus", +} + + +class LocalFilesService: + _DEFAULT_STORAGE_STATS_TTL = 300 + _ALBUM_LIST_TTL = 120 + _DEFAULT_RECENTLY_ADDED_TTL = 120 + + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + preferences_service: PreferencesService, + cache: CacheInterface, + ): + self._lidarr = lidarr_repo + self._preferences = preferences_service + self._cache = cache + + def _get_config(self) -> tuple[str, str]: + settings = self._preferences.get_local_files_connection() + return settings.music_path, settings.lidarr_root_path + + def _get_recently_added_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_local_files_recently_added + except Exception: # noqa: BLE001 + return self._DEFAULT_RECENTLY_ADDED_TTL + + def _get_storage_stats_ttl(self) -> int: + try: + return self._preferences.get_advanced_settings().cache_ttl_local_files_storage_stats + except Exception: # noqa: BLE001 + return self._DEFAULT_STORAGE_STATS_TTL + + def _remap_path(self, lidarr_path: str) -> Path: + music_path, lidarr_root = self._get_config() + lidarr_root = lidarr_root.rstrip("/") + lidarr_root_parts = Path(lidarr_root).parts + lidarr_path_obj = Path(lidarr_path) + lidarr_path_parts = lidarr_path_obj.parts + + if ( + len(lidarr_path_parts) >= len(lidarr_root_parts) + and lidarr_path_parts[: len(lidarr_root_parts)] == lidarr_root_parts + ): + relative = Path(*lidarr_path_parts[len(lidarr_root_parts):]) + else: + relative = Path(lidarr_path.lstrip("/")) + return Path(music_path) / relative + + async def _fetch_all_albums(self) -> list[dict[str, Any]]: + cache_key = "local_files_all_albums" + cached = await self._cache.get(cache_key) + if cached is not None: + return cached + data = await self._lidarr.get_all_albums() + if data: + await self._cache.set( + cache_key, data, ttl_seconds=self._ALBUM_LIST_TTL + ) + return data or [] + + def _resolve_and_validate_path(self, lidarr_path: str) -> Path: + music_path, _ = self._get_config() + resolved = self._remap_path(lidarr_path) + canonical = resolved.resolve() + music_root = Path(music_path).resolve() + + if not canonical.is_relative_to(music_root): + raise PermissionError("Path outside music directory") + if not canonical.exists(): + raise ResourceNotFoundError(f"File not found: {canonical.name}") + return canonical + + async def get_track_file_path(self, track_file_id: int) -> str: + try: + data = await self._lidarr.get_track_file(track_file_id) + if not data: + raise ResourceNotFoundError(f"Track file {track_file_id} not found in Lidarr") + path = data.get("path", "") + return path + except ResourceNotFoundError: + raise + except Exception as e: # noqa: BLE001 + raise ExternalServiceError(f"Failed to get track file from Lidarr: {e}") + + async def head_track(self, track_file_id: int) -> dict[str, str]: + lidarr_path = await self.get_track_file_path(track_file_id) + file_path = self._resolve_and_validate_path(lidarr_path) + + suffix = file_path.suffix.lower() + if suffix not in AUDIO_EXTENSIONS: + raise ExternalServiceError( + f"Unsupported audio format: {suffix or 'unknown'}" + ) + + try: + stat_result = await asyncio.to_thread(file_path.stat) + except OSError as exc: + raise ResourceNotFoundError( + f"Cannot access file: {file_path.name} ({exc})" + ) + + content_type = CONTENT_TYPE_MAP.get(suffix, "application/octet-stream") + return { + "Content-Type": content_type, + "Content-Length": str(stat_result.st_size), + "Accept-Ranges": "bytes", + } + + async def stream_track( + self, + track_file_id: int, + range_header: str | None = None, + ) -> tuple[AsyncGenerator[bytes, None], dict[str, str], int]: + lidarr_path = await self.get_track_file_path(track_file_id) + file_path = self._resolve_and_validate_path(lidarr_path) + + suffix = file_path.suffix.lower() + if suffix not in AUDIO_EXTENSIONS: + raise ExternalServiceError( + f"Unsupported audio format: {suffix or 'unknown'}" + ) + + try: + stat_result = await asyncio.to_thread(file_path.stat) + except OSError as exc: + raise ResourceNotFoundError( + f"Cannot access file: {file_path.name} ({exc})" + ) + + file_size = stat_result.st_size + content_type = CONTENT_TYPE_MAP.get(suffix, "application/octet-stream") + + if range_header and range_header.startswith("bytes="): + range_spec = range_header[6:] + start_str, _, end_str = range_spec.partition("-") + + try: + if not start_str and end_str: + suffix_len = int(end_str) + start = max(0, file_size - suffix_len) + end = file_size - 1 + elif start_str and not end_str: + start = int(start_str) + end = file_size - 1 + elif start_str and end_str: + start = int(start_str) + end = int(end_str) + else: + raise ValueError("Empty range") + except ValueError: + return self._iter_file(file_path, 0, file_size), { + "Content-Type": content_type, + "Content-Length": str(file_size), + "Accept-Ranges": "bytes", + }, 200 + + end = min(end, file_size - 1) + if start < 0 or start > end or start >= file_size: + raise ExternalServiceError( + f"Range not satisfiable: {range_header} (file size: {file_size})" + ) + + length = end - start + 1 + + headers = { + "Content-Type": content_type, + "Content-Length": str(length), + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + } + return self._iter_file(file_path, start, length), headers, 206 + + headers = { + "Content-Type": content_type, + "Content-Length": str(file_size), + "Accept-Ranges": "bytes", + } + return self._iter_file(file_path, 0, file_size), headers, 200 + + async def _iter_file( + self, path: Path, offset: int, length: int + ) -> AsyncGenerator[bytes, None]: + remaining = length + try: + async with aiofiles.open(path, "rb") as f: + await f.seek(offset) + while remaining > 0: + chunk_size = min(STREAM_CHUNK_SIZE, remaining) + data = await f.read(chunk_size) + if not data: + break + remaining -= len(data) + yield data + except OSError as exc: + logger.warning( + "Local file read error mid-stream", + extra={"path": str(path), "error": str(exc)}, + ) + + async def get_album_track_files( + self, lidarr_album_id: int + ) -> list[dict[str, Any]]: + data = await self._lidarr.get_track_files_by_album(lidarr_album_id) + if not data: + return [] + + track_files = [] + for tf in data: + path_str: str = tf.get("path", "") + suffix = Path(path_str).suffix.lower().lstrip(".") + quality = tf.get("quality", {}) + quality_detail = quality.get("quality", {}) + + track_files.append({ + "track_file_id": tf.get("id"), + "path": path_str, + "size_bytes": tf.get("size", 0), + "format": suffix if suffix else "unknown", + "bitrate": quality_detail.get("bitrate"), + "date_added": tf.get("dateAdded"), + }) + + return track_files + + async def _build_track_list( + self, album_id: int + ) -> tuple[list[LocalTrackInfo], int, dict[str, int]]: + tracks = await self._lidarr.get_album_tracks(album_id) + track_files = await self.get_album_track_files(album_id) + + file_map: dict[int, dict[str, Any]] = { + tf["track_file_id"]: tf for tf in track_files if tf.get("track_file_id") + } + + result: list[LocalTrackInfo] = [] + total_size = 0 + format_counts: dict[str, int] = {} + + for track in tracks: + tf_id = track.get("track_file_id") + has_file = track.get("has_file", False) + if not has_file or not tf_id: + continue + + tf = file_map.get(tf_id, {}) + fmt = tf.get("format", "unknown") + size = tf.get("size_bytes", 0) + total_size += size + format_counts[fmt] = format_counts.get(fmt, 0) + 1 + + raw_track_num = track.get("track_number") or track.get("position") or 0 + raw_disc_num = track.get("disc_number", 1) or 1 + try: + track_num = int(raw_track_num) + except (TypeError, ValueError): + track_num = 0 + try: + disc_num = int(raw_disc_num) + except (TypeError, ValueError): + disc_num = 1 + + result.append(LocalTrackInfo( + track_file_id=tf_id, + title=track.get("title", "Unknown"), + track_number=track_num, + disc_number=disc_num, + duration_seconds=(track.get("duration_ms", 0) or 0) / 1000.0, + size_bytes=size, + format=fmt, + bitrate=tf.get("bitrate"), + date_added=tf.get("date_added"), + )) + + return result, total_size, format_counts + + async def match_album_by_mbid( + self, musicbrainz_id: str + ) -> LocalAlbumMatch: + album_data = await self._lidarr.get_album_details(musicbrainz_id) + if not album_data: + return LocalAlbumMatch(found=False) + + album_id: int = album_data.get("id", 0) + if not album_id: + return LocalAlbumMatch(found=False) + + result_tracks, total_size, format_counts = await self._build_track_list(album_id) + primary_format = max(format_counts, key=lambda k: format_counts[k]) if format_counts else None + + return LocalAlbumMatch( + found=bool(result_tracks), + tracks=result_tracks, + total_size_bytes=total_size, + primary_format=primary_format, + ) + + def _library_album_to_summary( + self, item: Any, album_id: int, track_file_count: int + ) -> LocalAlbumSummary: + artist_data = item.get("artist", {}) + year = None + if date := item.get("releaseDate"): + try: + year = int(date.split("-")[0]) + except ValueError: + pass + + mbid = item.get("foreignAlbumId", "") + cover_url = None + images = item.get("images", []) + for img in images: + if img.get("coverType") == "cover": + cover_url = img.get("remoteUrl") or img.get("url") + break + if not cover_url and images: + cover_url = images[0].get("remoteUrl") or images[0].get("url") + cover_url = prefer_release_group_cover_url(mbid, cover_url, size=500) + + total_size = item.get("statistics", {}).get("sizeOnDisk", 0) + + return LocalAlbumSummary( + lidarr_album_id=album_id, + musicbrainz_id=mbid, + name=item.get("title", "Unknown"), + artist_name=artist_data.get("artistName", "Unknown"), + artist_mbid=artist_data.get("foreignArtistId"), + year=year, + track_count=track_file_count, + total_size_bytes=total_size, + cover_url=cover_url, + date_added=item.get("added"), + ) + + async def get_albums( + self, + limit: int = 50, + offset: int = 0, + sort_by: str = "name", + sort_order: str = "asc", + search_query: str | None = None, + ) -> LocalPaginatedResponse: + all_albums = await self._fetch_all_albums() + + albums_with_files: list[dict[str, Any]] = [] + for item in all_albums: + stats = item.get("statistics", {}) + track_file_count = stats.get("trackFileCount", 0) + if track_file_count > 0: + albums_with_files.append(item) + + if search_query: + q = search_query.lower() + albums_with_files = [ + a for a in albums_with_files + if q in a.get("title", "").lower() + or q in a.get("artist", {}).get("artistName", "").lower() + ] + + descending = sort_order == "desc" + if sort_by == "date_added": + albums_with_files.sort( + key=lambda a: a.get("added", "") or "", + reverse=descending, + ) + elif sort_by == "year": + albums_with_files.sort( + key=lambda a: a.get("releaseDate", "") or "", + reverse=descending, + ) + else: + albums_with_files.sort( + key=lambda a: a.get("title", "").lower(), + reverse=descending, + ) + + total = len(albums_with_files) + page_items = albums_with_files[offset : offset + limit] + + summaries = [ + self._library_album_to_summary( + item, + item.get("id", 0), + item.get("statistics", {}).get("trackFileCount", 0), + ) + for item in page_items + ] + + return LocalPaginatedResponse( + items=summaries, total=total, offset=offset, limit=limit + ) + + async def get_album_tracks_by_id( + self, lidarr_album_id: int + ) -> list[LocalTrackInfo]: + result, _, _ = await self._build_track_list(lidarr_album_id) + return result + + async def search(self, query: str) -> list[LocalAlbumSummary]: + result = await self.get_albums( + limit=50, offset=0, search_query=query + ) + return result.items + + async def get_recently_added( + self, limit: int = 20 + ) -> list[LocalAlbumSummary]: + ttl_seconds = self._get_recently_added_ttl() + cache_key = f"{LOCAL_FILES_PREFIX}recently_added:{limit}" + cached = await self._cache.get(cache_key) + if isinstance(cached, list): + try: + return [ + LocalAlbumSummary(**item) + for item in cached + if isinstance(item, dict) + ] + except (TypeError, ValueError): + logger.debug("Ignoring invalid cached recently-added payload") + + recently_imported = await self._lidarr.get_recently_imported(limit=limit) + if not recently_imported: + await self._cache.set( + cache_key, [], ttl_seconds=ttl_seconds + ) + return [] + + all_albums = await self._fetch_all_albums() + album_lookup: dict[str, dict[str, Any]] = {} + for album in all_albums: + mbid = album.get("foreignAlbumId") + if mbid: + album_lookup[mbid] = album + + summaries: list[LocalAlbumSummary] = [] + for lib_album in recently_imported: + mbid = lib_album.musicbrainz_id + full = album_lookup.get(mbid) if mbid else None + if full: + stats = full.get("statistics", {}) + if stats.get("trackFileCount", 0) == 0: + continue + summaries.append( + self._library_album_to_summary( + full, + full.get("id", 0), + stats.get("trackFileCount", 0), + ) + ) + else: + summaries.append( + LocalAlbumSummary( + lidarr_album_id=0, + musicbrainz_id=mbid or "", + name=lib_album.album or "Unknown", + artist_name=lib_album.artist, + artist_mbid=lib_album.artist_mbid, + year=lib_album.year, + cover_url=lib_album.cover_url, + date_added=str(lib_album.date_added) if lib_album.date_added else None, + ) + ) + + await self._cache.set( + cache_key, + [to_jsonable(summary) for summary in summaries], + ttl_seconds=ttl_seconds, + ) + return summaries + + async def get_storage_stats(self) -> LocalStorageStats: + ttl_seconds = self._get_storage_stats_ttl() + cache_key = "local_files_storage_stats" + cached = await self._cache.get(cache_key) + if cached and isinstance(cached, dict): + try: + return LocalStorageStats(**cached) + except (TypeError, ValueError): + logger.debug("Ignoring invalid cached local storage stats payload") + + music_path, _ = self._get_config() + root = Path(music_path) + if not root.exists(): + return LocalStorageStats() + stats = await asyncio.to_thread(self._scan_storage_sync, root) + + await self._cache.set( + cache_key, to_jsonable(stats), ttl_seconds=ttl_seconds + ) + return stats + + def _scan_storage_sync(self, root: Path) -> LocalStorageStats: + total_tracks = 0 + total_size = 0 + format_breakdown: dict[str, dict[str, int]] = {} + album_dirs: set[str] = set() + artist_dirs: set[str] = set() + + try: + for dirpath, _dirs, files in os.walk(root): + rel = Path(dirpath).relative_to(root) + parts = rel.parts + if len(parts) >= 1: + artist_dirs.add(parts[0]) + if len(parts) >= 2: + album_dirs.add(f"{parts[0]}/{parts[1]}") + + for fname in files: + ext = Path(fname).suffix.lower() + if ext not in AUDIO_EXTENSIONS: + continue + total_tracks += 1 + fp = Path(dirpath) / fname + try: + sz = fp.stat().st_size + except OSError: + sz = 0 + total_size += sz + + fmt = ext.lstrip(".") + if fmt not in format_breakdown: + format_breakdown[fmt] = {"count": 0, "size_bytes": 0} + format_breakdown[fmt]["count"] += 1 + format_breakdown[fmt]["size_bytes"] += sz + + except PermissionError: + logger.warning("Permission denied scanning music directory") + + disk = shutil.disk_usage(root) + + typed_breakdown: dict[str, FormatInfo] = {} + for fmt_name, fmt_data in format_breakdown.items(): + typed_breakdown[fmt_name] = FormatInfo( + count=fmt_data["count"], + size_bytes=fmt_data["size_bytes"], + size_human=self._human_size(fmt_data["size_bytes"]), + ) + + return LocalStorageStats( + total_tracks=total_tracks, + total_albums=len(album_dirs), + total_artists=len(artist_dirs), + total_size_bytes=total_size, + total_size_human=self._human_size(total_size), + disk_free_bytes=disk.free, + disk_free_human=self._human_size(disk.free), + format_breakdown=typed_breakdown, + ) + + @staticmethod + def _human_size(size_bytes: int) -> str: + size = float(size_bytes) + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size) < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} PB" + + async def verify_path(self, music_path_str: str) -> LocalFilesVerifyResponse: + return await asyncio.to_thread(self._verify_path_sync, music_path_str) + + def _verify_path_sync(self, music_path_str: str) -> LocalFilesVerifyResponse: + music_path = Path(music_path_str) + if not music_path.exists(): + return LocalFilesVerifyResponse(success=False, message=f"Path does not exist: {music_path_str}") + if not music_path.is_dir(): + return LocalFilesVerifyResponse(success=False, message=f"Path is not a directory: {music_path_str}") + if not os.access(music_path, os.R_OK): + return LocalFilesVerifyResponse(success=False, message=f"Path is not readable: {music_path_str}") + + track_count = 0 + try: + for _root, _dirs, files in os.walk(music_path): + track_count += sum(1 for f in files if Path(f).suffix.lower() in AUDIO_EXTENSIONS) + if track_count > 50000: + break + except PermissionError: + return LocalFilesVerifyResponse(success=False, message="Permission denied while scanning directory") + + return LocalFilesVerifyResponse( + success=True, + message=f"Connected — {track_count:,} audio files found", + track_count=track_count, + ) diff --git a/backend/services/navidrome_library_service.py b/backend/services/navidrome_library_service.py new file mode 100644 index 0000000..2715d31 --- /dev/null +++ b/backend/services/navidrome_library_service.py @@ -0,0 +1,613 @@ +from __future__ import annotations + +import asyncio +import logging +import time +import unicodedata +import re +from typing import TYPE_CHECKING + +from api.v1.schemas.navidrome import ( + NavidromeAlbumDetail, + NavidromeAlbumMatch, + NavidromeAlbumSummary, + NavidromeArtistSummary, + NavidromeLibraryStats, + NavidromeSearchResponse, + NavidromeTrackInfo, +) +from infrastructure.cover_urls import prefer_artist_cover_url, prefer_release_group_cover_url +from repositories.navidrome_models import SubsonicAlbum, SubsonicSong +from repositories.protocols import NavidromeRepositoryProtocol +from services.preferences_service import PreferencesService + +if TYPE_CHECKING: + from infrastructure.persistence import LibraryDB, MBIDStore + +logger = logging.getLogger(__name__) + +_CONCURRENCY_LIMIT = 5 +_NEGATIVE_CACHE_TTL = 14400 # 4 hours — aligned with periodic warmup interval + + +def _cache_get_mbid(cache: dict[str, str | tuple[None, float]], key: str) -> str | None: + """Extract MBID from cache, returning None for negative or missing entries.""" + val = cache.get(key) + if val is None: + return None + if isinstance(val, str): + return val + return None + + +def _clean_album_name(name: str) -> str: + """Strip common suffixes like '(Remastered 2009)', '[Deluxe Edition]', year prefixes, etc.""" + cleaned = name.strip() + cleaned = re.sub(r'\s*[\(\[][^)\]]*(?:remaster|deluxe|edition|bonus|expanded|mono|stereo|anniversary)[^)\]]*[\)\]]', '', cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r'^\d{4}\s*[-–—]\s*', '', cleaned) + cleaned = re.sub(r'\s*-\s*EP$', '', cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r'\s*\[[^\]]*\]\s*$', '', cleaned) + return cleaned.strip() + + +def _normalize(text: str) -> str: + text = unicodedata.normalize("NFKD", text) + text = text.encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^a-z0-9]", "", text.lower()) + return text + + +class NavidromeLibraryService: + + def __init__( + self, + navidrome_repo: NavidromeRepositoryProtocol, + preferences_service: PreferencesService, + library_db: 'LibraryDB | None' = None, + mbid_store: 'MBIDStore | None' = None, + ): + self._navidrome = navidrome_repo + self._preferences = preferences_service + self._library_db = library_db + self._mbid_store = mbid_store + # Cache values: str (resolved MBID) or tuple (None, timestamp) for negative entries + self._album_mbid_cache: dict[str, str | tuple[None, float]] = {} + self._artist_mbid_cache: dict[str, str | tuple[None, float]] = {} + self._mbid_to_navidrome_id: dict[str, str] = {} + # Lidarr in-memory indices (populated during warmup) + self._lidarr_album_index: dict[str, tuple[str, str]] = {} + self._lidarr_artist_index: dict[str, str] = {} + self._dirty = False + + def lookup_navidrome_id(self, mbid: str) -> str | None: + """Public accessor for MBID → Navidrome album ID reverse index.""" + return self._mbid_to_navidrome_id.get(mbid) + + async def _resolve_album_mbid(self, name: str, artist: str) -> str | None: + """Resolve a release-group MBID for an album via Lidarr library matching.""" + if not name or not artist: + return None + cache_key = f"{_normalize(name)}:{_normalize(artist)}" + if cache_key in self._album_mbid_cache: + cached = self._album_mbid_cache[cache_key] + if isinstance(cached, str): + return cached + if isinstance(cached, tuple): + _, ts = cached + if time.time() - ts < _NEGATIVE_CACHE_TTL: + return None + del self._album_mbid_cache[cache_key] + elif cached is None: + del self._album_mbid_cache[cache_key] + + # Try exact match in Lidarr index + match = self._lidarr_album_index.get(cache_key) + if match: + self._album_mbid_cache[cache_key] = match[0] + self._dirty = True + return match[0] + + # Try cleaned name match (strip remaster/deluxe/EP/single suffixes) + clean_key = f"{_normalize(_clean_album_name(name))}:{_normalize(artist)}" + if clean_key != cache_key: + match = self._lidarr_album_index.get(clean_key) + if match: + self._album_mbid_cache[cache_key] = match[0] + self._dirty = True + return match[0] + + self._album_mbid_cache[cache_key] = (None, time.time()) + self._dirty = True + return None + + async def _resolve_artist_mbid(self, name: str) -> str | None: + """Resolve an artist MBID via Lidarr library matching.""" + if not name: + return None + cache_key = _normalize(name) + if cache_key in self._artist_mbid_cache: + cached = self._artist_mbid_cache[cache_key] + if isinstance(cached, str): + return cached + if isinstance(cached, tuple): + _, ts = cached + if time.time() - ts < _NEGATIVE_CACHE_TTL: + return None + del self._artist_mbid_cache[cache_key] + elif cached is None: + del self._artist_mbid_cache[cache_key] + + match = self._lidarr_artist_index.get(cache_key) + if match: + self._artist_mbid_cache[cache_key] = match + self._dirty = True + return match + + self._artist_mbid_cache[cache_key] = (None, time.time()) + self._dirty = True + return None + + async def persist_if_dirty(self) -> None: + """Persist in-memory MBID cache to SQLite if there are unsaved changes.""" + if not self._dirty or not self._mbid_store: + return + try: + serializable_albums = {k: (v if isinstance(v, str) else None) for k, v in self._album_mbid_cache.items()} + serializable_artists = {k: (v if isinstance(v, str) else None) for k, v in self._artist_mbid_cache.items()} + await self._mbid_store.save_navidrome_album_mbid_index(serializable_albums) + await self._mbid_store.save_navidrome_artist_mbid_index(serializable_artists) + self._dirty = False + logger.debug("Persisted dirty Navidrome MBID cache to disk") + except Exception: # noqa: BLE001 + logger.warning("Failed to persist dirty Navidrome MBID cache", exc_info=True) + + async def _build_artist_summary(self, artist_data: object) -> NavidromeArtistSummary: + """Build an artist summary, enriching MBID from Lidarr if needed.""" + name = getattr(artist_data, 'name', '') + lidarr_mbid = await self._resolve_artist_mbid(name) if name else None + mbid = lidarr_mbid or getattr(artist_data, 'musicBrainzId', None) or None + image_url = prefer_artist_cover_url(mbid, None, size=500) + return NavidromeArtistSummary( + navidrome_id=artist_data.id, + name=name, + image_url=image_url, + album_count=getattr(artist_data, 'albumCount', 0), + musicbrainz_id=mbid, + ) + + def _song_to_track_info(self, song: SubsonicSong) -> NavidromeTrackInfo: + return NavidromeTrackInfo( + navidrome_id=song.id, + title=song.title, + track_number=song.track, + disc_number=song.discNumber or 1, + duration_seconds=float(song.duration), + album_name=song.album, + artist_name=song.artist, + codec=song.suffix or None, + bitrate=song.bitRate or None, + ) + + async def _album_to_summary(self, album: SubsonicAlbum) -> NavidromeAlbumSummary: + # Only expose Lidarr-resolved MBIDs (Navidrome may have release IDs, not release-group IDs) + mbid = await self._resolve_album_mbid(album.name, album.artist) if album.name and album.artist else None + if mbid: + self._mbid_to_navidrome_id[mbid] = album.id + artist_mbid = await self._resolve_artist_mbid(album.artist) if album.artist else None + fallback = f"/api/v1/navidrome/cover/{album.coverArt}" if album.coverArt else None + image_url = prefer_release_group_cover_url(mbid, fallback, size=500) + return NavidromeAlbumSummary( + navidrome_id=album.id, + name=album.name, + artist_name=album.artist, + year=album.year or None, + track_count=album.songCount, + image_url=image_url, + musicbrainz_id=mbid, + artist_musicbrainz_id=artist_mbid, + ) + + @staticmethod + def _fix_missing_track_numbers(tracks: list[NavidromeTrackInfo]) -> list[NavidromeTrackInfo]: + if len(tracks) <= 1: + return tracks + tracks_by_disc: dict[int, list[NavidromeTrackInfo]] = {} + for track in tracks: + tracks_by_disc.setdefault(track.disc_number, []).append(track) + + renumbered_ids: dict[str, int] = {} + for disc_tracks in tracks_by_disc.values(): + numbers = {t.track_number for t in disc_tracks} + if len(numbers) > 1: + continue + for i, track in enumerate(disc_tracks, start=1): + renumbered_ids[track.navidrome_id] = i + + fixed: list[NavidromeTrackInfo] = [] + for track in tracks: + track_number = renumbered_ids.get(track.navidrome_id, track.track_number) + fixed.append(NavidromeTrackInfo( + navidrome_id=track.navidrome_id, + title=track.title, + track_number=track_number, + disc_number=track.disc_number, + duration_seconds=track.duration_seconds, + album_name=track.album_name, + artist_name=track.artist_name, + codec=track.codec, + bitrate=track.bitrate, + )) + return fixed + + async def get_albums( + self, + type: str = "alphabeticalByName", + size: int = 50, + offset: int = 0, + genre: str | None = None, + ) -> list[NavidromeAlbumSummary]: + albums = await self._navidrome.get_album_list(type=type, size=size, offset=offset, genre=genre) + filtered = [a for a in albums if a.name and a.name != "Unknown"] + summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered)) + return list(summaries) + + async def get_album_detail(self, album_id: str) -> NavidromeAlbumDetail | None: + try: + album = await self._navidrome.get_album(album_id) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch Navidrome album %s", album_id, exc_info=True) + return None + + songs = album.song or [] + tracks = self._fix_missing_track_numbers( + [self._song_to_track_info(s) for s in songs] + ) + mbid = await self._resolve_album_mbid(album.name, album.artist) if album.name and album.artist else None + artist_mbid = await self._resolve_artist_mbid(album.artist) if album.artist else None + fallback = f"/api/v1/navidrome/cover/{album.coverArt}" if album.coverArt else None + image_url = prefer_release_group_cover_url(mbid, fallback, size=500) + + return NavidromeAlbumDetail( + navidrome_id=album.id, + name=album.name, + artist_name=album.artist, + year=album.year or None, + track_count=len(tracks), + image_url=image_url, + musicbrainz_id=mbid, + artist_musicbrainz_id=artist_mbid, + tracks=tracks, + ) + + async def get_artists(self) -> list[NavidromeArtistSummary]: + artists = await self._navidrome.get_artists() + summaries = await asyncio.gather(*(self._build_artist_summary(a) for a in artists)) + return list(summaries) + + async def get_artist_detail(self, artist_id: str) -> dict[str, object] | None: + try: + artist = await self._navidrome.get_artist(artist_id) + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch Navidrome artist %s", artist_id, exc_info=True) + return None + + lidarr_mbid = await self._resolve_artist_mbid(artist.name) if artist.name else None + mbid = lidarr_mbid or artist.musicBrainzId or None + image_url = prefer_artist_cover_url(mbid, None, size=500) + + albums: list[NavidromeAlbumSummary] = [] + sem = asyncio.Semaphore(_CONCURRENCY_LIMIT) + + async def _fetch_album(album_id: str) -> NavidromeAlbumSummary | None: + async with sem: + try: + detail = await self._navidrome.get_album(album_id) + return await self._album_to_summary(detail) + except Exception: # noqa: BLE001 + return None + + search_result = await self._navidrome.search(artist.name, artist_count=0, album_count=500, song_count=0) + artist_album_ids = [a.id for a in search_result.album if a.artistId == artist_id and a.name and a.name != "Unknown"] + + if artist_album_ids: + fetched = await asyncio.gather(*(_fetch_album(aid) for aid in artist_album_ids)) + albums = [a for a in fetched if a is not None] + + return { + "artist": NavidromeArtistSummary( + navidrome_id=artist.id, + name=artist.name, + image_url=image_url, + album_count=artist.albumCount, + musicbrainz_id=mbid, + ), + "albums": albums, + } + + async def search(self, query: str) -> NavidromeSearchResponse: + result = await self._navidrome.search(query) + filtered_albums = [a for a in result.album if a.name and a.name != "Unknown"] + albums_task = asyncio.gather(*(self._album_to_summary(a) for a in filtered_albums)) + artists_task = asyncio.gather(*(self._build_artist_summary(a) for a in result.artist)) + albums, artists = await asyncio.gather(albums_task, artists_task) + tracks = [self._song_to_track_info(s) for s in result.song] + return NavidromeSearchResponse(albums=list(albums), artists=list(artists), tracks=tracks) + + async def get_recent(self, limit: int = 20) -> list[NavidromeAlbumSummary]: + albums = await self._navidrome.get_album_list(type="recent", size=limit, offset=0) + filtered = [a for a in albums if a.name and a.name != "Unknown"] + summaries = await asyncio.gather(*(self._album_to_summary(a) for a in filtered)) + return list(summaries) + + async def get_favorites(self) -> NavidromeSearchResponse: + starred = await self._navidrome.get_starred() + filtered_albums = [a for a in starred.album if a.name and a.name != "Unknown"] + albums_task = asyncio.gather(*(self._album_to_summary(a) for a in filtered_albums)) + artists_task = asyncio.gather(*(self._build_artist_summary(a) for a in starred.artist)) + albums, artists = await asyncio.gather(albums_task, artists_task) + tracks = [self._song_to_track_info(s) for s in starred.song] + return NavidromeSearchResponse(albums=list(albums), artists=list(artists), tracks=tracks) + + async def get_genres(self) -> list[str]: + genres = await self._navidrome.get_genres() + return [g.name for g in genres if g.name] + + async def get_stats(self) -> NavidromeLibraryStats: + artists = await self._navidrome.get_artists() + # Fetch a single album just to trigger the endpoint, then count via pagination + first_page = await self._navidrome.get_album_list(type="alphabeticalByName", size=1, offset=0) + total_albums = 0 + if first_page: + # Count all albums by paginating with large pages + all_albums = await self._navidrome.get_album_list(type="alphabeticalByName", size=500, offset=0) + total_albums = len(all_albums) + if total_albums >= 500: + offset = 500 + while True: + batch = await self._navidrome.get_album_list(type="alphabeticalByName", size=500, offset=offset) + if not batch: + break + total_albums += len(batch) + if len(batch) < 500: + break + offset += 500 + genres = await self._navidrome.get_genres() + total_songs = sum(g.songCount for g in genres) + return NavidromeLibraryStats( + total_tracks=total_songs, + total_albums=total_albums, + total_artists=len(artists), + ) + + async def get_album_match( + self, + album_id: str, + album_name: str, + artist_name: str, + ) -> NavidromeAlbumMatch: + sem = asyncio.Semaphore(_CONCURRENCY_LIMIT) + + async def _fetch_detail(aid: str) -> NavidromeAlbumDetail | None: + async with sem: + return await self.get_album_detail(aid) + + # Fast path: direct MBID→navidrome_id lookup from reverse index + if album_id and album_id in self._mbid_to_navidrome_id: + nav_id = self._mbid_to_navidrome_id[album_id] + detail = await _fetch_detail(nav_id) + if detail: + return NavidromeAlbumMatch( + found=True, + navidrome_album_id=detail.navidrome_id, + tracks=detail.tracks, + ) + + if album_id: + search_result = await self._navidrome.search( + album_name, artist_count=0, album_count=50, song_count=0 + ) + for candidate in search_result.album: + if candidate.musicBrainzId and candidate.musicBrainzId == album_id: + detail = await _fetch_detail(candidate.id) + if detail: + return NavidromeAlbumMatch( + found=True, + navidrome_album_id=detail.navidrome_id, + tracks=detail.tracks, + ) + + if album_name and artist_name: + norm_album = _normalize(album_name) + norm_artist = _normalize(artist_name) + + search_result = await self._navidrome.search( + album_name, artist_count=0, album_count=50, song_count=0 + ) + for candidate in search_result.album: + if ( + _normalize(candidate.name) == norm_album + and _normalize(candidate.artist) == norm_artist + ): + detail = await _fetch_detail(candidate.id) + if detail: + return NavidromeAlbumMatch( + found=True, + navidrome_album_id=detail.navidrome_id, + tracks=detail.tracks, + ) + + return NavidromeAlbumMatch(found=False) + + async def warm_mbid_cache(self) -> None: + """Background task: enrich all Navidrome albums and artists with MBIDs from Lidarr library matching. + Loads from SQLite first for instant startup; enriches from Lidarr library matching.""" + + # Phase 0: Build Lidarr indices from library cache + if self._library_db: + try: + lidarr_albums = await self._library_db.get_all_albums_for_matching() + self._lidarr_album_index = {} + self._lidarr_artist_index = {} + for title, artist_name, album_mbid, artist_mbid in lidarr_albums: + key = f"{_normalize(title)}:{_normalize(artist_name)}" + clean_key = f"{_normalize(_clean_album_name(title))}:{_normalize(artist_name)}" + self._lidarr_album_index[key] = (album_mbid, artist_mbid) + if clean_key != key: + self._lidarr_album_index[clean_key] = (album_mbid, artist_mbid) + norm_artist = _normalize(artist_name) + if norm_artist and artist_mbid: + self._lidarr_artist_index[norm_artist] = artist_mbid + logger.info( + "Built Lidarr matching indices: %d album entries, %d artist entries", + len(self._lidarr_album_index), len(self._lidarr_artist_index), + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to build Lidarr matching indices", exc_info=True) + + # Phase 1: Load from persistent SQLite cache (serves requests while Lidarr may be unavailable) + loaded_from_disk = False + if self._mbid_store: + try: + disk_albums = await self._mbid_store.load_navidrome_album_mbid_index(max_age_seconds=86400) + disk_artists = await self._mbid_store.load_navidrome_artist_mbid_index(max_age_seconds=86400) + if disk_albums or disk_artists: + self._album_mbid_cache.update(disk_albums) + self._artist_mbid_cache.update(disk_artists) + loaded_from_disk = True + logger.info( + "Loaded Navidrome MBID cache from disk: %d albums, %d artists", + len(disk_albums), len(disk_artists), + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to load Navidrome MBID cache from disk", exc_info=True) + + if not self._lidarr_album_index: + logger.warning("Lidarr library data unavailable — Lidarr enrichment will be skipped") + + # Phase 2: Fetch current Navidrome library (paginated) for reconciliation + enrichment + try: + all_albums: list[SubsonicAlbum] = [] + offset = 0 + while True: + batch = await self._navidrome.get_album_list( + type="alphabeticalByName", size=500, offset=offset + ) + if not batch: + break + all_albums.extend(batch) + if len(batch) < 500: + break + offset += 500 + except Exception: # noqa: BLE001 + logger.warning("Failed to fetch Navidrome albums for MBID enrichment") + return + + # Phase 3: Reconcile — remove stale entries no longer in Navidrome + current_album_keys: set[str] = set() + current_artist_names: set[str] = set() + for album in all_albums: + if album.name and album.name != "Unknown": + current_album_keys.add(f"{_normalize(album.name)}:{_normalize(album.artist)}") + if album.artist: + current_artist_names.add(album.artist) + + current_artist_keys = {_normalize(n) for n in current_artist_names} + stale_album_keys = set(self._album_mbid_cache.keys()) - current_album_keys + stale_artist_keys = set(self._artist_mbid_cache.keys()) - current_artist_keys + for key in stale_album_keys: + del self._album_mbid_cache[key] + for key in stale_artist_keys: + del self._artist_mbid_cache[key] + if stale_album_keys or stale_artist_keys: + logger.info( + "Removed %d stale album and %d stale artist MBID entries", + len(stale_album_keys), len(stale_artist_keys), + ) + + # Phase 4: Enrich all entries from Lidarr library matching (skipped when Lidarr unavailable) + resolved_albums = 0 + resolved_artists = 0 + + if self._lidarr_album_index: + for album in all_albums: + if not album.name or album.name == "Unknown": + continue + cache_key = f"{_normalize(album.name)}:{_normalize(album.artist)}" + existing = self._album_mbid_cache.get(cache_key) + if isinstance(existing, str): + # Overwrite with Lidarr data if available (corrects old MB-sourced or Navidrome-native MBIDs) + lidarr_match = self._lidarr_album_index.get(cache_key) + if not lidarr_match: + clean_key = f"{_normalize(_clean_album_name(album.name))}:{_normalize(album.artist)}" + if clean_key != cache_key: + lidarr_match = self._lidarr_album_index.get(clean_key) + if lidarr_match and lidarr_match[0] != existing: + self._album_mbid_cache[cache_key] = lidarr_match[0] + self._dirty = True + resolved_albums += 1 + continue + if isinstance(existing, tuple): + # Override negative entries when Lidarr now has a match + lidarr_hit = self._lidarr_album_index.get(cache_key) + if not lidarr_hit: + clean_key = f"{_normalize(_clean_album_name(album.name))}:{_normalize(album.artist)}" + if clean_key != cache_key: + lidarr_hit = self._lidarr_album_index.get(clean_key) + if lidarr_hit: + del self._album_mbid_cache[cache_key] + elif time.time() - existing[1] < _NEGATIVE_CACHE_TTL: + continue + mbid = await self._resolve_album_mbid(album.name, album.artist) + if mbid: + resolved_albums += 1 + + for name in current_artist_names: + norm = _normalize(name) + existing = self._artist_mbid_cache.get(norm) + if isinstance(existing, str): + lidarr_match = self._lidarr_artist_index.get(norm) + if lidarr_match and lidarr_match != existing: + self._artist_mbid_cache[norm] = lidarr_match + self._dirty = True + resolved_artists += 1 + continue + if isinstance(existing, tuple): + lidarr_hit = self._lidarr_artist_index.get(norm) + if lidarr_hit: + del self._artist_mbid_cache[norm] + elif time.time() - existing[1] < _NEGATIVE_CACHE_TTL: + continue + mbid = await self._resolve_artist_mbid(name) + if mbid: + resolved_artists += 1 + + logger.info( + "Navidrome MBID enrichment complete: %d new albums resolved, %d new artists resolved (loaded_from_disk=%s, lidarr_available=%s)", + resolved_albums, resolved_artists, loaded_from_disk, bool(self._lidarr_album_index), + ) + + # Phase 5: Persist to SQLite + if self._mbid_store and (self._dirty or stale_album_keys or stale_artist_keys): + try: + serializable_albums = {k: (v if isinstance(v, str) else None) for k, v in self._album_mbid_cache.items()} + serializable_artists = {k: (v if isinstance(v, str) else None) for k, v in self._artist_mbid_cache.items()} + await self._mbid_store.save_navidrome_album_mbid_index(serializable_albums) + await self._mbid_store.save_navidrome_artist_mbid_index(serializable_artists) + self._dirty = False + logger.info( + "Persisted Navidrome MBID cache to disk: %d albums, %d artists", + len(self._album_mbid_cache), len(self._artist_mbid_cache), + ) + except Exception: # noqa: BLE001 + logger.warning("Failed to persist Navidrome MBID cache to disk", exc_info=True) + + # Phase 6: Rebuild MBID→navidrome_id reverse index from scratch + self._mbid_to_navidrome_id.clear() + for album in all_albums: + if not album.name or album.name == "Unknown": + continue + cache_key = f"{_normalize(album.name)}:{_normalize(album.artist)}" + # Only use Lidarr-resolved MBIDs for reverse index + mbid = _cache_get_mbid(self._album_mbid_cache, cache_key) + if mbid: + self._mbid_to_navidrome_id[mbid] = album.id diff --git a/backend/services/navidrome_playback_service.py b/backend/services/navidrome_playback_service.py new file mode 100644 index 0000000..9116265 --- /dev/null +++ b/backend/services/navidrome_playback_service.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import logging +import time + +from fastapi.responses import Response, StreamingResponse + +from repositories.navidrome_models import StreamProxyResult +from repositories.protocols import NavidromeRepositoryProtocol + +logger = logging.getLogger(__name__) + + +class NavidromePlaybackService: + def __init__(self, navidrome_repo: NavidromeRepositoryProtocol) -> None: + self._navidrome = navidrome_repo + + def get_stream_url(self, song_id: str) -> str: + return self._navidrome.build_stream_url(song_id) + + async def proxy_head(self, item_id: str) -> Response: + """Proxy a HEAD request to Navidrome and return a FastAPI Response.""" + result: StreamProxyResult = await self._navidrome.proxy_head_stream(item_id) + return Response(status_code=200, headers=result.headers) + + async def proxy_stream( + self, item_id: str, range_header: str | None = None + ) -> StreamingResponse: + """Proxy a GET stream from Navidrome and return a FastAPI StreamingResponse.""" + result: StreamProxyResult = await self._navidrome.proxy_get_stream( + item_id, range_header=range_header + ) + return StreamingResponse( + content=result.body_chunks, + status_code=result.status_code, + headers=result.headers, + media_type=result.media_type, + ) + + async def scrobble(self, song_id: str) -> bool: + time_ms = int(time.time() * 1000) + try: + return await self._navidrome.scrobble(song_id, time_ms=time_ms) + except Exception: # noqa: BLE001 + logger.warning("Navidrome scrobble failed for %s", song_id, exc_info=True) + return False + + async def report_now_playing(self, song_id: str) -> bool: + try: + return await self._navidrome.now_playing(song_id) + except Exception: # noqa: BLE001 + logger.warning("Navidrome now-playing failed for %s", song_id, exc_info=True) + return False diff --git a/backend/services/playlist_service.py b/backend/services/playlist_service.py new file mode 100644 index 0000000..f10074d --- /dev/null +++ b/backend/services/playlist_service.py @@ -0,0 +1,500 @@ +import asyncio +import logging +import re +from collections import defaultdict +from difflib import SequenceMatcher +from pathlib import Path +from typing import Optional + +from core.exceptions import InvalidPlaylistDataError, PlaylistNotFoundError, SourceResolutionError +from infrastructure.cache.cache_keys import SOURCE_RESOLUTION_PREFIX +from infrastructure.cache.memory_cache import CacheInterface +from repositories.async_playlist_repository import AsyncPlaylistRepository +from repositories.playlist_repository import ( + PlaylistRecord, + PlaylistRepository, + PlaylistSummaryRecord, + PlaylistTrackRecord, +) + +logger = logging.getLogger(__name__) + +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} +MAX_COVER_SIZE = 2 * 1024 * 1024 # 2 MB +_MIME_TO_EXT = {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"} +_SAFE_ID_RE = re.compile(r"^[a-f0-9\-]+$") +VALID_SOURCE_TYPES = {"local", "jellyfin", "navidrome", "youtube", ""} +MAX_NAME_LENGTH = 100 + +_SOURCE_TYPE_ALIASES = { + "local": "local", + "howler": "local", + "jellyfin": "jellyfin", + "navidrome": "navidrome", + "youtube": "youtube", + "": "", +} + + +def _normalize_source_map(by_num: dict) -> dict[int, tuple[str, str]]: + """Ensure source map keys are ints (guards against cached string keys).""" + if not by_num: + return by_num + first_key = next(iter(by_num)) + if isinstance(first_key, int): + return by_num + normalized: dict[int, tuple[str, str]] = {} + for k, v in by_num.items(): + try: + normalized[int(k)] = v + except (TypeError, ValueError): + continue + return normalized + + +def _safe_track_number(value: object) -> int | None: + """Coerce a track number to int, returning None for non-numeric inputs.""" + if isinstance(value, int): + return value + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _fuzzy_name_match(name1: str, name2: str) -> bool: + if not name1 or not name2: + return False + n1, n2 = name1.lower().strip(), name2.lower().strip() + if n1 == n2: + return True + if n1 in n2 or n2 in n1: + return True + return SequenceMatcher(None, n1, n2).ratio() > 0.6 + + +class PlaylistService: + def __init__(self, repo: PlaylistRepository, cache_dir: Path, cache: Optional[CacheInterface] = None): + self._repo = AsyncPlaylistRepository(repo) + self._cover_dir = cache_dir / "covers" / "playlists" + self._cache = cache + + + async def create_playlist(self, name: str) -> PlaylistRecord: + stripped = name.strip() if name else "" + if not stripped: + raise InvalidPlaylistDataError("Playlist name must not be empty") + if len(stripped) > MAX_NAME_LENGTH: + raise InvalidPlaylistDataError(f"Playlist name must not exceed {MAX_NAME_LENGTH} characters") + result = await self._repo.create_playlist(stripped) + logger.info("Playlist created: id=%s name=%s", result.id, result.name) + return result + + async def get_playlist(self, playlist_id: str) -> PlaylistRecord: + result = await self._repo.get_playlist(playlist_id) + if result is None: + raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") + return result + + async def get_all_playlists(self) -> list[PlaylistSummaryRecord]: + return await self._repo.get_all_playlists() + + async def get_playlist_with_tracks( + self, playlist_id: str, + ) -> tuple[PlaylistRecord, list[PlaylistTrackRecord]]: + playlist = await self.get_playlist(playlist_id) + tracks = await self._repo.get_tracks(playlist_id) + return playlist, tracks + + async def update_playlist( + self, playlist_id: str, name: Optional[str] = None, + ) -> PlaylistRecord: + if name is not None: + stripped = name.strip() + if not stripped: + raise InvalidPlaylistDataError("Playlist name must not be empty") + if len(stripped) > MAX_NAME_LENGTH: + raise InvalidPlaylistDataError(f"Playlist name must not exceed {MAX_NAME_LENGTH} characters") + name = stripped + + result = await self._repo.update_playlist(playlist_id, name=name) + if result is None: + raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") + logger.info("Playlist updated: id=%s", playlist_id) + return result + + async def update_playlist_with_detail( + self, playlist_id: str, name: Optional[str] = None, + ) -> tuple[PlaylistRecord, list[PlaylistTrackRecord]]: + playlist = await self.update_playlist(playlist_id, name=name) + tracks = await self._repo.get_tracks(playlist_id) + return playlist, tracks + + async def delete_playlist(self, playlist_id: str) -> None: + deleted = await self._repo.delete_playlist(playlist_id) + if not deleted: + raise PlaylistNotFoundError(f"Playlist {playlist_id} not found") + await asyncio.to_thread(self._delete_cover_file, playlist_id) + logger.info("Playlist deleted: id=%s", playlist_id) + + + async def add_tracks( + self, + playlist_id: str, + tracks: list[dict], + position: Optional[int] = None, + ) -> list[PlaylistTrackRecord]: + if not tracks: + raise InvalidPlaylistDataError("Track list must not be empty") + normalized_tracks: list[dict] = [] + for track in tracks: + normalized = dict(track) + st = normalized.get("source_type", "") + if st and st not in _SOURCE_TYPE_ALIASES: + raise InvalidPlaylistDataError( + f"Invalid source_type '{st}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 + ) + normalized["source_type"] = _SOURCE_TYPE_ALIASES.get(st, st) + + sources = normalized.get("available_sources") + if sources is not None: + normalized_sources: list[str] = [] + for source in sources: + if source not in _SOURCE_TYPE_ALIASES: + raise InvalidPlaylistDataError( + f"Invalid available source '{source}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 + ) + normalized_sources.append(_SOURCE_TYPE_ALIASES[source]) + normalized["available_sources"] = normalized_sources + + normalized_tracks.append(normalized) + await self.get_playlist(playlist_id) + result = await self._repo.add_tracks(playlist_id, normalized_tracks, position) + logger.info("Added %d tracks to playlist %s", len(result), playlist_id) + return result + + async def remove_track(self, playlist_id: str, track_id: str) -> None: + removed = await self._repo.remove_track(playlist_id, track_id) + if not removed: + raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") + logger.info("Removed track %s from playlist %s", track_id, playlist_id) + + async def remove_tracks(self, playlist_id: str, track_ids: list[str]) -> int: + if not track_ids: + raise InvalidPlaylistDataError("No track IDs provided") + removed = await self._repo.remove_tracks(playlist_id, track_ids) + if not removed: + raise PlaylistNotFoundError(f"No matching tracks found in playlist {playlist_id}") + logger.info("Removed %d tracks from playlist %s", removed, playlist_id) + return removed + + async def reorder_track( + self, playlist_id: str, track_id: str, new_position: int, + ) -> int: + if new_position < 0: + raise InvalidPlaylistDataError("Position must be >= 0") + result = await self._repo.reorder_track(playlist_id, track_id, new_position) + if result is None: + raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") + logger.info("Reordered track %s to position %d in playlist %s", track_id, result, playlist_id) + return result + + async def update_track_source( + self, + playlist_id: str, + track_id: str, + source_type: Optional[str] = None, + available_sources: Optional[list[str]] = None, + jf_service: object = None, + local_service: object = None, + nd_service: object = None, + ) -> PlaylistTrackRecord: + if source_type is not None and source_type not in _SOURCE_TYPE_ALIASES: + raise InvalidPlaylistDataError( + f"Invalid source_type '{source_type}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 + ) + + normalized_source = _SOURCE_TYPE_ALIASES.get(source_type, source_type) + normalized_available_sources = available_sources + if available_sources is not None: + normalized_available_sources = [] + for source in available_sources: + if source not in _SOURCE_TYPE_ALIASES: + raise InvalidPlaylistDataError( + f"Invalid available source '{source}'. Allowed: {', '.join(sorted(_SOURCE_TYPE_ALIASES.keys() - {''}))}" # noqa: E501 + ) + normalized_available_sources.append(_SOURCE_TYPE_ALIASES[source]) + + new_track_source_id: Optional[str] = None + if normalized_source: + current_track = await self._repo.get_track(playlist_id, track_id) + if current_track is None: + raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") + if normalized_source != current_track.source_type: + new_track_source_id = await self._resolve_new_source_id( + current_track, normalized_source, jf_service, local_service, nd_service, + ) + + result = await self._repo.update_track_source( + playlist_id, track_id, normalized_source, normalized_available_sources, + track_source_id=new_track_source_id, + ) + if result is None: + raise PlaylistNotFoundError(f"Track {track_id} not found in playlist {playlist_id}") + logger.info("Updated track source: track=%s playlist=%s", track_id, playlist_id) + return result + + async def get_tracks(self, playlist_id: str) -> list[PlaylistTrackRecord]: + return await self._repo.get_tracks(playlist_id) + + async def check_track_membership( + self, tracks: list[tuple[str, str, str]], + ) -> dict[str, list[int]]: + return await self._repo.check_track_membership(tracks) + + + async def resolve_track_sources( + self, + playlist_id: str, + jf_service: object = None, + local_service: object = None, + nd_service: object = None, + ) -> dict[str, list[str]]: + await self.get_playlist(playlist_id) + tracks = await self._repo.get_tracks(playlist_id) + if not tracks: + return {} + + album_groups: dict[str, list[PlaylistTrackRecord]] = defaultdict(list) + no_album_tracks: list[PlaylistTrackRecord] = [] + for t in tracks: + if t.album_id and t.track_number is not None: + album_groups[t.album_id].append(t) + else: + no_album_tracks.append(t) + + result: dict[str, list[str]] = {} + for album_id, album_tracks in album_groups.items(): + representative = album_tracks[0] + jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources( + album_id, jf_service, local_service, nd_service, + album_name=representative.album_name or "", + artist_name=representative.artist_name or "", + ) + for t in album_tracks: + sources = set() + if t.source_type: + sources.add(t.source_type) + + jf_track = jf_by_num.get(t.track_number) + if jf_track and _fuzzy_name_match(t.track_name, jf_track[0]): + sources.add("jellyfin") + + local_track = local_by_num.get(t.track_number) + if local_track and _fuzzy_name_match(t.track_name, local_track[0]): + sources.add("local") + + nd_track = nd_by_num.get(t.track_number) + if nd_track and _fuzzy_name_match(t.track_name, nd_track[0]): + sources.add("navidrome") + + result[t.id] = sorted(sources) + + for t in no_album_tracks: + result[t.id] = [t.source_type] if t.source_type else [] + + persist_updates: dict[str, list[str]] = {} + for t in tracks: + resolved = result.get(t.id) + if not resolved: + continue + existing = set(t.available_sources) if t.available_sources else set() + if set(resolved) >= existing and set(resolved) != existing: + persist_updates[t.id] = resolved + if persist_updates: + await self._repo.batch_update_available_sources(playlist_id, persist_updates) + + return result + + async def _resolve_album_sources( + self, + album_id: str, + jf_service: object, + local_service: object, + nd_service: object = None, + album_name: str = "", + artist_name: str = "", + ) -> tuple[dict[int, tuple[str, str]], dict[int, tuple[str, str]], dict[int, tuple[str, str]]]: + cache_key = f"{SOURCE_RESOLUTION_PREFIX}:{album_id}" + if self._cache: + cached = await self._cache.get(cache_key) + if cached is not None: + if len(cached) == 2: + return (_normalize_source_map(cached[0]), _normalize_source_map(cached[1]), {}) + return ( + _normalize_source_map(cached[0]), + _normalize_source_map(cached[1]), + _normalize_source_map(cached[2]), + ) + + jf_by_num: dict[int, tuple[str, str]] = {} + local_by_num: dict[int, tuple[str, str]] = {} + nd_by_num: dict[int, tuple[str, str]] = {} + + if jf_service is not None: + try: + match = await jf_service.match_album_by_mbid(album_id) + if match.found: + for t in match.tracks: + key = _safe_track_number(t.track_number) + if key is not None: + jf_by_num[key] = (t.title, t.jellyfin_id) + except Exception: # noqa: BLE001 + logger.debug("Jellyfin source resolution failed for album %s", album_id, exc_info=True) + + if local_service is not None: + try: + match = await local_service.match_album_by_mbid(album_id) + if match.found: + for t in match.tracks: + key = _safe_track_number(t.track_number) + if key is not None: + local_by_num[key] = (t.title, str(t.track_file_id)) + except Exception: # noqa: BLE001 + logger.debug("Local source resolution failed for album %s", album_id, exc_info=True) + + if nd_service is not None: + try: + match = await nd_service.get_album_match( + album_id=album_id, album_name=album_name, artist_name=artist_name, + ) + if match.found: + for t in match.tracks: + key = _safe_track_number(t.track_number) + if key is not None: + nd_by_num[key] = (t.title, t.navidrome_id) + except Exception: # noqa: BLE001 + logger.debug("Navidrome source resolution failed for album %s", album_id, exc_info=True) + + resolved = (jf_by_num, local_by_num, nd_by_num) + if self._cache: + await self._cache.set(cache_key, resolved, ttl_seconds=3600) + return resolved + + async def _resolve_new_source_id( + self, + track: PlaylistTrackRecord, + new_source_type: str, + jf_service: object, + local_service: object, + nd_service: object = None, + ) -> str: + if not track.album_id or track.track_number is None: + raise SourceResolutionError( + f"Cannot switch source for track '{track.track_name}': missing album_id or track_number" + ) + + jf_by_num, local_by_num, nd_by_num = await self._resolve_album_sources( + track.album_id, jf_service, local_service, nd_service, + album_name=track.album_name or "", + artist_name=track.artist_name or "", + ) + + if new_source_type == "jellyfin": + match_info = jf_by_num.get(track.track_number) + if match_info and _fuzzy_name_match(track.track_name, match_info[0]): + return match_info[1] + raise SourceResolutionError( + f"Track '{track.track_name}' not found in Jellyfin for album {track.album_id}" + ) + + if new_source_type == "local": + match_info = local_by_num.get(track.track_number) + if match_info and _fuzzy_name_match(track.track_name, match_info[0]): + return match_info[1] + raise SourceResolutionError( + f"Track '{track.track_name}' not found in local files for album {track.album_id}" + ) + + if new_source_type == "navidrome": + match_info = nd_by_num.get(track.track_number) + if match_info and _fuzzy_name_match(track.track_name, match_info[0]): + return match_info[1] + raise SourceResolutionError( + f"Track '{track.track_name}' not found in Navidrome for album {track.album_id}" + ) + + raise SourceResolutionError(f"Unsupported source type for resolution: {new_source_type}") + + + async def upload_cover( + self, playlist_id: str, data: bytes, content_type: str, + ) -> str: + await self.get_playlist(playlist_id) + self._validate_cover_id(playlist_id) + + if content_type not in ALLOWED_IMAGE_TYPES: + raise InvalidPlaylistDataError( + f"Invalid image type. Allowed: {', '.join(ALLOWED_IMAGE_TYPES)}" + ) + if len(data) > MAX_COVER_SIZE: + raise InvalidPlaylistDataError("Image too large. Maximum size is 2 MB") # defence-in-depth + + ext = _MIME_TO_EXT.get(content_type, ".jpg") + file_path = self._cover_dir / f"{playlist_id}{ext}" + + def _write_cover() -> None: + self._cover_dir.mkdir(parents=True, exist_ok=True) + for old in self._cover_dir.glob(f"{playlist_id}.*"): + try: + old.unlink() + except OSError: + pass + file_path.write_bytes(data) + + await asyncio.to_thread(_write_cover) + + cover_path = str(file_path) + await self._repo.update_playlist( + playlist_id, cover_image_path=cover_path, + ) + + cover_url = f"/api/v1/playlists/{playlist_id}/cover" + return cover_url + + async def get_cover_path(self, playlist_id: str) -> Optional[Path]: + playlist = await self.get_playlist(playlist_id) + if not playlist.cover_image_path: + return None + path = Path(playlist.cover_image_path) + exists = await asyncio.to_thread(path.exists) + if exists: + return path + return None + + async def remove_cover(self, playlist_id: str) -> None: + playlist = await self.get_playlist(playlist_id) + if playlist.cover_image_path: + cover_path = Path(playlist.cover_image_path) + try: + await asyncio.to_thread(cover_path.unlink, True) + except OSError: + pass + await self._repo.update_playlist( + playlist_id, cover_image_path=None, + ) + + + @staticmethod + def _validate_cover_id(playlist_id: str) -> None: + if not _SAFE_ID_RE.match(playlist_id): + raise InvalidPlaylistDataError("Invalid playlist ID for cover path") + + def _delete_cover_file(self, playlist_id: str) -> None: + if not _SAFE_ID_RE.match(playlist_id): + return + for f in self._cover_dir.glob(f"{playlist_id}.*"): + try: + f.unlink() + except OSError: + pass diff --git a/backend/services/precache/__init__.py b/backend/services/precache/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/precache/album_phase.py b/backend/services/precache/album_phase.py new file mode 100644 index 0000000..7488266 --- /dev/null +++ b/backend/services/precache/album_phase.py @@ -0,0 +1,122 @@ +"""Album metadata + cover pre-caching phase.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any + +from repositories.protocols import CoverArtRepositoryProtocol +from repositories.coverart_disk_cache import get_cache_filename +from services.cache_status_service import CacheStatusService +from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX + +logger = logging.getLogger(__name__) + + +class AlbumPhase: + def __init__( + self, + cover_repo: CoverArtRepositoryProtocol, + preferences_service: Any, + sync_state_store: Any, + ): + self._cover_repo = cover_repo + self._preferences_service = preferences_service + self._sync_state_store = sync_state_store + + async def precache_album_data( + self, + release_group_ids: list[str], + monitored_mbids: set[str], + status_service: CacheStatusService, + library_album_mbids: dict[str, Any] = None, + offset: int = 0, + ) -> None: + from core.dependencies import get_album_service + logger.info(f"Pre-caching {len(release_group_ids)} new/missing release-groups") + album_service = get_album_service() + + async def cache_rg(rgid: str, index: int) -> tuple[str, bool, bool]: + try: + if not rgid or rgid.startswith('unknown_'): + return (rgid, False, False) + metadata_fetched = False + cover_fetched = False + cache_key = f"{ALBUM_INFO_PREFIX}{rgid}" + cached_info = await album_service._cache.get(cache_key) + if not cached_info: + await status_service.update_progress(index + 1, f"Fetching metadata for {rgid[:8]}...", processed_albums=offset + index + 1) + await album_service.get_album_info(rgid, monitored_mbids=monitored_mbids) + metadata_fetched = True + else: + await status_service.update_progress(index + 1, f"Cached: {rgid[:8]}...", processed_albums=offset + index + 1) + if rgid.lower() in monitored_mbids: + cache_filename = get_cache_filename(f"rg_{rgid}", "500") + file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin" + if not file_path.exists(): + try: + await self._cover_repo.get_release_group_cover(rgid, size="500") + cover_fetched = True + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to cache cover for {rgid}: {e}") + return (rgid, metadata_fetched, cover_fetched) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to pre-cache release-group {rgid}: {e}") + return (rgid, False, False) + + advanced_settings = self._preferences_service.get_advanced_settings() + batch_size = advanced_settings.batch_albums + min_batch = max(1, advanced_settings.batch_albums - 2) + max_batch = min(20, advanced_settings.batch_albums + 7) + metadata_fetched = 0 + covers_fetched = 0 + consecutive_slow_batches = 0 + for i in range(0, len(release_group_ids), batch_size): + if status_service.is_cancelled(): + logger.info("Album pre-caching cancelled by user") + break + batch_start = time.time() + batch = release_group_ids[i:i + batch_size] + tasks = [cache_rg(rg, i + idx) for idx, rg in enumerate(batch)] + results = await asyncio.gather(*tasks, return_exceptions=True) + processed_mbids = [] + for idx, result in enumerate(results): + if isinstance(result, tuple) and len(result) == 3: + rgid, meta, cover = result + if meta: + metadata_fetched += 1 + if cover: + covers_fetched += 1 + if rgid: + processed_mbids.append(rgid) + elif isinstance(result, Exception): + rgid = batch[idx] if idx < len(batch) else 'Unknown' + logger.error(f"Batch error caching album {rgid[:8] if isinstance(rgid, str) else rgid}: {result}") + if isinstance(rgid, str): + processed_mbids.append(rgid) + if processed_mbids: + await self._sync_state_store.mark_items_processed_batch('album', processed_mbids) + await status_service.persist_progress() + batch_duration = time.time() - batch_start + avg_time_per_item = batch_duration / len(batch) if batch else 1.0 + if avg_time_per_item > 1.5: + consecutive_slow_batches += 1 + if consecutive_slow_batches >= 3: + batch_size = max(batch_size - 2, min_batch) + logger.warning(f"Sustained slowness detected, reducing batch size to {batch_size}") + elif batch_size > min_batch: + batch_size = max(batch_size - 1, min_batch) + logger.debug(f"Decreasing batch size to {batch_size} (slow: {avg_time_per_item:.2f}s/item)") + else: + consecutive_slow_batches = 0 + if avg_time_per_item < 0.8 and batch_size < max_batch: + batch_size = min(batch_size + 1, max_batch) + logger.debug(f"Increasing batch size to {batch_size} (fast: {avg_time_per_item:.2f}s/item)") + if (i + batch_size) % 30 == 0 or (i + batch_size) >= len(release_group_ids): + percent = int((min(i + batch_size, len(release_group_ids)) / len(release_group_ids)) * 100) + logger.info(f"Album progress: {min(i + batch_size, len(release_group_ids))}/{len(release_group_ids)} ({percent}%) - metadata: {metadata_fetched}, covers: {covers_fetched} [batch: {batch_size}]") + await asyncio.sleep(advanced_settings.delay_albums) + await status_service.persist_progress(force=True) + logger.info(f"Album pre-caching complete: metadata fetched={metadata_fetched}, covers fetched={covers_fetched}, total processed={len(release_group_ids)}") diff --git a/backend/services/precache/artist_phase.py b/backend/services/precache/artist_phase.py new file mode 100644 index 0000000..af2d002 --- /dev/null +++ b/backend/services/precache/artist_phase.py @@ -0,0 +1,123 @@ +"""Artist metadata + image pre-caching phase.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from repositories.protocols import LidarrRepositoryProtocol, CoverArtRepositoryProtocol +from repositories.coverart_disk_cache import get_cache_filename +from services.cache_status_service import CacheStatusService +from infrastructure.cache.cache_keys import ARTIST_INFO_PREFIX + +logger = logging.getLogger(__name__) + + +class ArtistPhase: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + cover_repo: CoverArtRepositoryProtocol, + preferences_service: Any, + genre_index: Any, + sync_state_store: Any, + ): + self._lidarr_repo = lidarr_repo + self._cover_repo = cover_repo + self._preferences_service = preferences_service + self._genre_index = genre_index + self._sync_state_store = sync_state_store + + async def precache_artist_images( + self, + artists: list[dict], + status_service: CacheStatusService, + library_artist_mbids: set[str] = None, + library_album_mbids: dict[str, Any] = None, + offset: int = 0, + ) -> None: + logger.info(f"Pre-caching metadata+images for {len(artists)} artists") + from core.dependencies import get_artist_service + from infrastructure.validators import is_unknown_mbid + artist_service = get_artist_service() + + async def cache_artist(artist: dict, index: int) -> str: + mbid = artist.get('mbid') + try: + artist_name = artist.get('name', 'Unknown') + if is_unknown_mbid(mbid): + await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1) + return mbid + artist_cache_key = f"{ARTIST_INFO_PREFIX}{mbid}" + cached_artist = await artist_service._cache.get(artist_cache_key) + if not cached_artist: + try: + await artist_service.get_artist_info(mbid, library_artist_mbids, library_album_mbids) + except Exception: # noqa: BLE001 + logger.debug(f"Failed to cache artist metadata for {artist_name}") + else: + logger.debug(f"Artist metadata for {artist_name} already cached, skipping fetch") + cache_filename_250 = get_cache_filename(f"artist_{mbid}_250", "img") + file_path_250 = self._cover_repo.cache_dir / f"{cache_filename_250}.bin" + cache_filename_500 = get_cache_filename(f"artist_{mbid}_500", "img") + file_path_500 = self._cover_repo.cache_dir / f"{cache_filename_500}.bin" + if file_path_250.exists() and file_path_500.exists(): + logger.debug(f"Artist images for {artist_name} already cached, skipping") + await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1) + return mbid + await status_service.update_progress(index + 1, f"Fetching images for {artist_name}", processed_artists=offset + index + 1) + if not file_path_250.exists(): + await self._cover_repo.get_artist_image(mbid, size=250) + if not file_path_500.exists(): + await self._cover_repo.get_artist_image(mbid, size=500) + await status_service.update_progress(index + 1, artist_name, processed_artists=offset + index + 1) + return mbid + except Exception as e: # noqa: BLE001 + logger.warning(f"Failed to cache artist {artist.get('name')} (mbid: {mbid}): {e}", exc_info=True) + await status_service.update_progress(index + 1, f"Failed: {artist.get('name', 'Unknown')}", processed_artists=offset + index + 1) + return mbid + + advanced_settings = self._preferences_service.get_advanced_settings() + batch_size = advanced_settings.batch_artist_images + for i in range(0, len(artists), batch_size): + if status_service.is_cancelled(): + logger.info("Artist pre-caching cancelled by user") + break + batch = artists[i:i + batch_size] + tasks = [cache_artist(artist, i + idx) for idx, artist in enumerate(batch)] + results = await asyncio.gather(*tasks, return_exceptions=True) + processed_mbids = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + artist_name = batch[idx].get('name', 'Unknown') + logger.error(f"Batch error caching artist {artist_name}: {result}") + processed_mbids.append(batch[idx].get('mbid')) + elif result: + processed_mbids.append(result) + if processed_mbids: + await self._sync_state_store.mark_items_processed_batch('artist', processed_mbids) + await status_service.persist_progress() + await asyncio.sleep(advanced_settings.delay_artist) + await status_service.persist_progress(force=True) + logger.info("Artist metadata+image pre-caching complete") + await self._cache_artist_genres(artists) + + async def _cache_artist_genres(self, artists: list[dict]) -> None: + from core.dependencies import get_artist_service + artist_service = get_artist_service() + artist_genres: dict[str, list[str]] = {} + logger.info(f"Extracting genre tags for {len(artists)} library artists") + for artist in artists: + mbid = artist.get('mbid') + if not mbid: + continue + cache_key = f"{ARTIST_INFO_PREFIX}{mbid}" + cached_info = await artist_service._cache.get(cache_key) + if cached_info and hasattr(cached_info, 'tags') and cached_info.tags: + artist_genres[mbid] = cached_info.tags[:10] + if artist_genres: + await self._genre_index.save_artist_genres(artist_genres) + logger.info(f"Cached genres for {len(artist_genres)} artists") + else: + logger.info("No artist genres found to cache") diff --git a/backend/services/precache/audiodb_phase.py b/backend/services/precache/audiodb_phase.py new file mode 100644 index 0000000..8da0157 --- /dev/null +++ b/backend/services/precache/audiodb_phase.py @@ -0,0 +1,276 @@ +"""AudioDB image pre-warming phase.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any, TYPE_CHECKING + +import httpx + +from repositories.protocols import CoverArtRepositoryProtocol +from repositories.coverart_disk_cache import get_cache_filename, VALID_IMAGE_CONTENT_TYPES +from services.cache_status_service import CacheStatusService +from infrastructure.queue.priority_queue import RequestPriority, get_priority_queue +from infrastructure.validators import validate_audiodb_image_url + +if TYPE_CHECKING: + from services.audiodb_image_service import AudioDBImageService + +logger = logging.getLogger(__name__) + +_AUDIODB_PREWARM_INTER_ITEM_DELAY = 2.0 +_AUDIODB_PREWARM_LOG_INTERVAL = 100 + + +class AudioDBPhase: + def __init__( + self, + cover_repo: CoverArtRepositoryProtocol, + preferences_service: Any, + audiodb_image_service: 'AudioDBImageService | None' = None, + ): + self._cover_repo = cover_repo + self._preferences_service = preferences_service + self._audiodb_image_service = audiodb_image_service + + async def check_cache_needs( + self, + artists: list[dict], + albums: list[Any], + ) -> tuple[list[dict], list[Any]]: + from infrastructure.validators import is_unknown_mbid + svc = self._audiodb_image_service + + async def check_artist(artist: dict) -> dict | None: + mbid = artist.get('mbid') + if not mbid or is_unknown_mbid(mbid): + return None + cached = await svc.get_cached_artist_images(mbid) + return None if cached is not None else artist + + async def check_album(album: Any) -> Any | None: + mbid = getattr(album, 'musicbrainz_id', None) if hasattr(album, 'musicbrainz_id') else album.get('mbid') if isinstance(album, dict) else None + if not mbid or is_unknown_mbid(mbid): + return None + cached = await svc.get_cached_album_images(mbid) + return None if cached is not None else album + + artist_results = await asyncio.gather( + *(check_artist(a) for a in artists), return_exceptions=True + ) + album_results = await asyncio.gather( + *(check_album(a) for a in albums), return_exceptions=True + ) + needed_artists = [r for r in artist_results if r is not None and not isinstance(r, Exception)] + needed_albums = [r for r in album_results if r is not None and not isinstance(r, Exception)] + return needed_artists, needed_albums + + async def download_bytes(self, url: str, entity_type: str, mbid: str) -> bool: + try: + if not validate_audiodb_image_url(url): + logger.warning( + "audiodb.prewarm action=rejected_unsafe_url entity_type=%s mbid=%s", + entity_type, mbid[:8], + ) + return False + + if entity_type == "artist": + identifier = f"artist_{mbid}_500" + suffix = "img" + else: + identifier = f"rg_{mbid}" + suffix = "500" + file_path = self._cover_repo.cache_dir / f"{get_cache_filename(identifier, suffix)}.bin" + if file_path.exists(): + return True + + priority_mgr = get_priority_queue() + semaphore = await priority_mgr.acquire_slot(RequestPriority.BACKGROUND_SYNC) + http_client = getattr(self._cover_repo, '_client', None) + async with semaphore: + if http_client is not None: + response = await http_client.get(url, follow_redirects=True) + else: + logger.debug("audiodb.prewarm action=http_client_fallback entity_type=%s mbid=%s", entity_type, mbid[:8]) + async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=5.0)) as client: + response = await client.get(url, headers={"User-Agent": "MusicSeerr/1.0"}, follow_redirects=True) + + if response.status_code != 200: + logger.debug( + "audiodb.prewarm action=byte_download_failed entity_type=%s mbid=%s status=%d", + entity_type, mbid[:8], response.status_code, + ) + return False + + content_type = response.headers.get("content-type", "").split(";")[0].strip().lower() + if content_type not in VALID_IMAGE_CONTENT_TYPES: + logger.debug( + "audiodb.prewarm action=byte_download_invalid_type entity_type=%s mbid=%s content_type=%s", + entity_type, mbid[:8], content_type, + ) + return False + + _MAX_IMAGE_BYTES = 20 * 1024 * 1024 + content_length = response.headers.get("content-length") + if content_length and int(content_length) > _MAX_IMAGE_BYTES: + logger.warning( + "audiodb.prewarm action=byte_download_too_large entity_type=%s mbid=%s size=%s", + entity_type, mbid[:8], content_length, + ) + return False + if len(response.content) > _MAX_IMAGE_BYTES: + logger.warning( + "audiodb.prewarm action=byte_download_too_large entity_type=%s mbid=%s size=%d", + entity_type, mbid[:8], len(response.content), + ) + return False + + disk_cache = getattr(self._cover_repo, '_disk_cache', None) + if disk_cache is None: + file_path.parent.mkdir(parents=True, exist_ok=True) + import aiofiles + async with aiofiles.open(file_path, 'wb') as f: + await f.write(response.content) + return True + + await disk_cache.write( + file_path, response.content, content_type, + extra_meta={"source": "audiodb"}, + is_monitored=True, + ) + return True + except Exception as e: # noqa: BLE001 + logger.warning( + "audiodb.prewarm action=byte_download_error entity_type=%s mbid=%s error=%s", + entity_type, mbid[:8], e, + ) + return False + + def sort_by_cover_priority(self, items: list, entity_type: str) -> list: + def has_cover(item: Any) -> bool: + if entity_type == "artist": + mbid = item.get('mbid') if isinstance(item, dict) else None + if not mbid: + return True + identifier = f"artist_{mbid}_500" + suffix = "img" + else: + mbid = getattr(item, 'musicbrainz_id', None) if hasattr(item, 'musicbrainz_id') else item.get('mbid') if isinstance(item, dict) else None + if not mbid: + return True + identifier = f"rg_{mbid}" + suffix = "500" + file_path = self._cover_repo.cache_dir / f"{get_cache_filename(identifier, suffix)}.bin" + return file_path.exists() + + return sorted(items, key=has_cover) + + async def precache_audiodb_data( + self, + artists: list[dict], + albums: list[Any], + status_service: CacheStatusService, + ) -> None: + if self._audiodb_image_service is None: + await status_service.skip_phase('audiodb_prewarm') + return + + settings = self._preferences_service.get_advanced_settings() + if not settings.audiodb_enabled: + logger.info("AudioDB pre-warming skipped (audiodb_enabled=false)") + await status_service.skip_phase('audiodb_prewarm') + return + + needed_artists, needed_albums = await self.check_cache_needs(artists, albums) + total = len(needed_artists) + len(needed_albums) + if total == 0: + logger.info("AudioDB prewarm: all items already cached") + await status_service.skip_phase('audiodb_prewarm') + return + + original_total = len(artists) + len(albums) + hit_rate = ((original_total - total) / original_total * 100) if original_total > 0 else 100 + logger.info( + "Phase 5 (AudioDB): Pre-warming %d items (%d artists, %d albums) — %.0f%% already cached", + total, len(needed_artists), len(needed_albums), hit_rate, + ) + await status_service.update_phase('audiodb_prewarm', total) + + needed_artists = self.sort_by_cover_priority(needed_artists, "artist") + needed_albums = self.sort_by_cover_priority(needed_albums, "album") + + processed = 0 + bytes_ok = 0 + bytes_fail = 0 + svc = self._audiodb_image_service + + for artist in needed_artists: + if status_service.is_cancelled(): + logger.info("AudioDB pre-warming cancelled during artist phase") + break + if not self._preferences_service.get_advanced_settings().audiodb_enabled: + logger.info("AudioDB disabled during pre-warming, stopping") + break + + mbid = artist.get('mbid') + name = artist.get('name', 'Unknown') + processed += 1 + try: + result = await svc.fetch_and_cache_artist_images(mbid, name, is_monitored=True) + if result and not result.is_negative and result.thumb_url: + if await self.download_bytes(result.thumb_url, "artist", mbid): + bytes_ok += 1 + else: + bytes_fail += 1 + except Exception as e: # noqa: BLE001 + logger.warning("audiodb.prewarm action=artist_error mbid=%s error=%s", mbid[:8] if mbid else '?', e) + + await status_service.update_progress(processed, f"AudioDB: {name}") + + if processed % _AUDIODB_PREWARM_LOG_INTERVAL == 0: + logger.info( + "audiodb.prewarm processed=%d total=%d hit_rate=%.0f%% bytes_ok=%d bytes_fail=%d remaining=%d", + processed, total, hit_rate, bytes_ok, bytes_fail, total - processed, + ) + + await asyncio.sleep(_AUDIODB_PREWARM_INTER_ITEM_DELAY) + + for album in needed_albums: + if status_service.is_cancelled(): + logger.info("AudioDB pre-warming cancelled during album phase") + break + if not self._preferences_service.get_advanced_settings().audiodb_enabled: + logger.info("AudioDB disabled during pre-warming, stopping") + break + + mbid = getattr(album, 'musicbrainz_id', None) if hasattr(album, 'musicbrainz_id') else album.get('mbid') if isinstance(album, dict) else None + artist_name = getattr(album, 'artist_name', None) if hasattr(album, 'artist_name') else album.get('artist_name') if isinstance(album, dict) else None + album_name = getattr(album, 'title', None) if hasattr(album, 'title') else album.get('title') if isinstance(album, dict) else None + processed += 1 + try: + result = await svc.fetch_and_cache_album_images( + mbid, artist_name=artist_name, album_name=album_name, is_monitored=True, + ) + if result and not result.is_negative and result.album_thumb_url: + if await self.download_bytes(result.album_thumb_url, "album", mbid): + bytes_ok += 1 + else: + bytes_fail += 1 + except Exception as e: # noqa: BLE001 + logger.warning("audiodb.prewarm action=album_error mbid=%s error=%s", mbid[:8] if mbid else '?', e) + + await status_service.update_progress(processed, f"AudioDB: {album_name or 'Unknown'}") + + if processed % _AUDIODB_PREWARM_LOG_INTERVAL == 0: + logger.info( + "audiodb.prewarm processed=%d total=%d hit_rate=%.0f%% bytes_ok=%d bytes_fail=%d remaining=%d", + processed, total, hit_rate, bytes_ok, bytes_fail, total - processed, + ) + + await asyncio.sleep(_AUDIODB_PREWARM_INTER_ITEM_DELAY) + + logger.info( + "audiodb.prewarm action=complete processed=%d total=%d bytes_ok=%d bytes_fail=%d", + processed, total, bytes_ok, bytes_fail, + ) diff --git a/backend/services/precache/orchestrator.py b/backend/services/precache/orchestrator.py new file mode 100644 index 0000000..9c789ff --- /dev/null +++ b/backend/services/precache/orchestrator.py @@ -0,0 +1,223 @@ +"""Pre-cache orchestrator — delegates to phase sub-services.""" + +from __future__ import annotations + +import logging +import asyncio +from typing import Any, TYPE_CHECKING + +from repositories.protocols import LidarrRepositoryProtocol, CoverArtRepositoryProtocol +from repositories.coverart_disk_cache import get_cache_filename +from services.cache_status_service import CacheStatusService +from core.exceptions import ExternalServiceError +from infrastructure.cache.cache_keys import ALBUM_INFO_PREFIX +from infrastructure.validators import is_unknown_mbid + +from .artist_phase import ArtistPhase +from .album_phase import AlbumPhase +from .audiodb_phase import AudioDBPhase + +if TYPE_CHECKING: + from infrastructure.persistence import SyncStateStore, GenreIndex, LibraryDB + from services.audiodb_image_service import AudioDBImageService + +logger = logging.getLogger(__name__) + + +class LibraryPrecacheService: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + cover_repo: CoverArtRepositoryProtocol, + preferences_service: Any, + sync_state_store: "SyncStateStore", + genre_index: "GenreIndex", + library_db: "LibraryDB", + artist_discovery_service: Any = None, + audiodb_image_service: 'AudioDBImageService | None' = None, + ): + self._lidarr_repo = lidarr_repo + self._cover_repo = cover_repo + self._preferences_service = preferences_service + self._sync_state_store = sync_state_store + self._artist_discovery_service = artist_discovery_service + self._audiodb_image_service = audiodb_image_service + + self._artist_phase = ArtistPhase(lidarr_repo, cover_repo, preferences_service, genre_index, sync_state_store) + self._album_phase = AlbumPhase(cover_repo, preferences_service, sync_state_store) + self._audiodb_phase = AudioDBPhase(cover_repo, preferences_service, audiodb_image_service) + + # Delegation for backward compat (tests access private methods) + async def _check_audiodb_cache_needs(self, artists, albums): + return await self._audiodb_phase.check_cache_needs(artists, albums) + + async def _precache_audiodb_data(self, artists, albums, status_service): + return await self._audiodb_phase.precache_audiodb_data(artists, albums, status_service) + + async def _download_audiodb_bytes(self, url, entity_type, mbid): + return await self._audiodb_phase.download_bytes(url, entity_type, mbid) + + def _sort_by_cover_priority(self, items, item_type): + return self._audiodb_phase.sort_by_cover_priority(items, item_type) + + async def precache_library_resources(self, artists: list[dict], albums: list[Any], resume: bool = False) -> None: + status_service = CacheStatusService(self._sync_state_store) + task = None + try: + task = asyncio.create_task(self._do_precache(artists, albums, status_service, resume)) + from core.task_registry import TaskRegistry + TaskRegistry.get_instance().register("precache-library", task) + await asyncio.wait_for(task, timeout=1800.0) + except asyncio.TimeoutError: + logger.error("Library pre-cache operation timed out after 30 minutes") + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + logger.info("Pre-cache task successfully cancelled after timeout") + except Exception as e: # noqa: BLE001 + logger.error(f"Error during task cancellation: {e}") + await status_service.complete_sync("Sync timed out after 30 minutes") + raise ExternalServiceError("Library sync timed out - too many items or slow network") + except asyncio.CancelledError: + logger.warning("Pre-cache was cancelled") + await status_service.complete_sync() + raise + except Exception as e: + logger.error(f"Pre-cache failed: {e}") + await status_service.complete_sync(str(e)) + raise + + async def _do_precache(self, artists: list[dict], albums: list[Any], status_service: CacheStatusService, resume: bool = False) -> None: + from core.dependencies import get_album_service + try: + processed_artists: set[str] = set() + processed_albums: set[str] = set() + skip_artists = False + + if resume: + logger.info("Resuming interrupted sync...") + processed_artists = await self._sync_state_store.get_processed_items('artist') + processed_albums = await self._sync_state_store.get_processed_items('album') + + state = await self._sync_state_store.get_sync_state() + if state and state.get('phase') == 'albums': + skip_artists = True + logger.info(f"Resuming from albums phase, {len(processed_albums)} albums already processed") + else: + logger.info(f"Resuming from artists phase, {len(processed_artists)} artists already processed") + + total_artists = len(artists) + total_albums = len(albums) + + logger.info(f"Starting pre-cache for {total_artists} monitored artists and {total_albums} monitored albums") + logger.info("Pre-fetching Lidarr library data...") + album_service = get_album_service() + library_artist_mbids = await self._lidarr_repo.get_artist_mbids() + library_album_mbids = await self._lidarr_repo.get_library_mbids(include_release_ids=True) + logger.info(f"Lidarr data cached: {len(library_artist_mbids)} artists, {len(library_album_mbids)} albums") + + if not skip_artists: + remaining_artists = [a for a in artists if a.get('mbid') not in processed_artists] + logger.info(f"Phase 1: Caching {len(remaining_artists)} artist metadata + images ({len(processed_artists)} already done)") + if remaining_artists: + await status_service.start_sync('artists', len(remaining_artists), total_artists=total_artists, total_albums=total_albums) + await self._artist_phase.precache_artist_images(remaining_artists, status_service, library_artist_mbids, library_album_mbids, len(processed_artists)) + else: + await status_service.start_sync('artists', 0, total_artists=total_artists, total_albums=total_albums) + await status_service.skip_phase('artists') + if status_service.is_cancelled(): + logger.info("Pre-cache cancelled after Phase 1") + return + + if self._artist_discovery_service and not skip_artists: + artist_mbids = [ + a.get('mbid') for a in artists + if a.get('mbid') and not a.get('mbid', '').startswith('unknown_') + ] + if artist_mbids: + logger.info(f"Phase 1.5: Pre-caching discovery data (popular albums/songs/similar) for {len(artist_mbids)} library artists") + await status_service.update_phase('discovery', len(artist_mbids)) + mbid_to_name = { + a.get('mbid'): a.get('name', a.get('mbid', '')[:8]) + for a in artists if a.get('mbid') + } + try: + advanced_settings = self._preferences_service.get_advanced_settings() + precache_delay = advanced_settings.artist_discovery_precache_delay + await self._artist_discovery_service.precache_artist_discovery( + artist_mbids, delay=precache_delay, + status_service=status_service, mbid_to_name=mbid_to_name, + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"Discovery precache failed (non-fatal): {e}") + else: + await status_service.skip_phase('discovery') + elif not skip_artists: + await status_service.skip_phase('discovery') + + if status_service.is_cancelled(): + logger.info("Pre-cache cancelled after Phase 1.5") + return + + monitored_mbids: set[str] = set() + for a in albums: + mbid = getattr(a, 'musicbrainz_id', None) if hasattr(a, 'musicbrainz_id') else a.get('mbid') if isinstance(a, dict) else None + if not is_unknown_mbid(mbid): + monitored_mbids.add(mbid.lower()) + logger.info(f"Phase 2: Collecting {len(monitored_mbids)} monitored album MBIDs (unmonitored albums will NOT be pre-cached)") + deduped_release_groups = list(monitored_mbids) + if status_service.is_cancelled(): + logger.info("Pre-cache cancelled after Phase 2") + return + logger.info(f"Phase 3: Batch-checking which of {len(deduped_release_groups)} release-groups need caching...") + items_needing_metadata = [] + cache_checks = [] + for rgid in deduped_release_groups: + if rgid in processed_albums: + continue + cache_key = f"{ALBUM_INFO_PREFIX}{rgid}" + cache_checks.append((rgid, album_service._cache.get(cache_key))) + cache_results = await asyncio.gather(*[check for _, check in cache_checks]) + for (rgid, _), cached_info in zip(cache_checks, cache_results): + if not cached_info: + items_needing_metadata.append(rgid) + items_needing_covers = [] + cover_paths = [] + for rgid in deduped_release_groups: + if rgid in processed_albums: + continue + if rgid.lower() in monitored_mbids: + cache_filename = get_cache_filename(f"rg_{rgid}", "500") + file_path = self._cover_repo.cache_dir / f"{cache_filename}.bin" + cover_paths.append((rgid, file_path)) + for rgid, file_path in cover_paths: + if not file_path.exists(): + items_needing_covers.append(rgid) + items_to_process = list(set(items_needing_metadata + items_needing_covers)) + already_cached = len(deduped_release_groups) - len(items_to_process) - len(processed_albums) + logger.info( + f"Phase 3: {len(items_to_process)} items need caching " + f"({len(items_needing_metadata)} metadata, {len(items_needing_covers)} covers) - " + f"{already_cached} already cached, {len(processed_albums)} from previous run" + ) + if items_to_process: + await status_service.update_phase('albums', len(items_to_process)) + await self._album_phase.precache_album_data(items_to_process, monitored_mbids, status_service, library_album_mbids, len(processed_albums)) + else: + await status_service.skip_phase('albums') + + if not status_service.is_cancelled(): + try: + await self._audiodb_phase.precache_audiodb_data(artists, albums, status_service) + except Exception as e: # noqa: BLE001 + logger.error(f"AudioDB pre-warming failed (non-fatal): {e}") + + logger.info("Library resource pre-caching complete") + except Exception as e: + logger.error(f"Error during pre-cache: {e}") + raise + finally: + if status_service.is_syncing(): + await status_service.complete_sync() diff --git a/backend/services/preferences_service.py b/backend/services/preferences_service.py new file mode 100644 index 0000000..ae4fa4a --- /dev/null +++ b/backend/services/preferences_service.py @@ -0,0 +1,390 @@ +import logging +import threading +from typing import Optional, TypeVar, Type +from typing import Any + +import msgspec +from api.v1.schemas.settings import ( + UserPreferences, + LidarrSettings, + LidarrConnectionSettings, + JellyfinConnectionSettings, + ListenBrainzConnectionSettings, + YouTubeConnectionSettings, + HomeSettings, + LocalFilesConnectionSettings, + LastFmConnectionSettings, + ScrobbleSettings, + PrimaryMusicSourceSettings, + LASTFM_SECRET_MASK, + NavidromeConnectionSettings, + NAVIDROME_PASSWORD_MASK, +) +from api.v1.schemas.profile import ProfileSettings +from api.v1.schemas.advanced_settings import AdvancedSettings +from core.config import Settings +from core.exceptions import ConfigurationError +from infrastructure.file_utils import atomic_write_json, read_json +from infrastructure.serialization import to_jsonable + +logger = logging.getLogger(__name__) + +T = TypeVar('T', bound=msgspec.Struct) + + +class PreferencesService: + def __init__(self, settings: Settings): + self._settings = settings + self._config_path = settings.config_file_path + self._config_cache: Optional[dict] = None + self._cache_lock = threading.Lock() + + def _load_config(self) -> dict: + with self._cache_lock: + if self._config_cache is not None: + return self._config_cache + + if not self._config_path.exists(): + self._config_cache = {} + return self._config_cache + + try: + loaded = read_json(self._config_path, default={}) + self._config_cache = loaded if isinstance(loaded, dict) else {} + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to load config: {e}") + self._config_cache = {} + + return self._config_cache + + def _save_config(self, config: dict) -> None: + with self._cache_lock: + self._config_path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(self._config_path, config) + self._config_cache = config + + def _get_section(self, key: str, model: Type[T], default_factory: Optional[callable] = None) -> T: + config = self._load_config() + data = config.get(key, {}) + try: + if not (isinstance(model, type) and issubclass(model, msgspec.Struct)): + raise TypeError(f"Preferences section model must be msgspec.Struct, got {model!r}") + + if data: + return msgspec.convert(data, type=model) + return default_factory() if default_factory else model() + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to parse {key}: {e}") + return default_factory() if default_factory else model() + + def _save_section(self, key: str, value: Any) -> None: + config = self._load_config().copy() + config[key] = to_jsonable(value) + self._save_config(config) + + def get_preferences(self) -> UserPreferences: + return self._get_section("user_preferences", UserPreferences) + + def save_preferences(self, preferences: UserPreferences) -> None: + try: + self._save_section("user_preferences", preferences) + logger.info(f"Saved preferences to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save preferences: {e}") + raise ConfigurationError(f"Failed to save preferences: {e}") + + def get_lidarr_settings(self) -> LidarrSettings: + return self._get_section("lidarr_settings", LidarrSettings) + + def save_lidarr_settings(self, lidarr_settings: LidarrSettings) -> None: + try: + self._save_section("lidarr_settings", lidarr_settings) + logger.info(f"Saved Lidarr settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save Lidarr settings: {e}") + raise ConfigurationError(f"Failed to save Lidarr settings: {e}") + + def get_advanced_settings(self) -> AdvancedSettings: + return self._get_section("advanced_settings", AdvancedSettings) + + def save_advanced_settings(self, advanced_settings: AdvancedSettings) -> None: + try: + self._save_section("advanced_settings", advanced_settings) + logger.info(f"Saved advanced settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save advanced settings: {e}") + raise ConfigurationError(f"Failed to save advanced settings: {e}") + + def get_lidarr_connection(self) -> LidarrConnectionSettings: + config = self._load_config() + return LidarrConnectionSettings( + lidarr_url=config.get("lidarr_url", self._settings.lidarr_url), + lidarr_api_key=config.get("lidarr_api_key", self._settings.lidarr_api_key), + quality_profile_id=config.get("quality_profile_id", self._settings.quality_profile_id), + metadata_profile_id=config.get("metadata_profile_id", self._settings.metadata_profile_id), + root_folder_path=config.get("root_folder_path", self._settings.root_folder_path), + ) + + def save_lidarr_connection(self, settings: LidarrConnectionSettings) -> None: + try: + config = self._load_config().copy() + config.update({ + "lidarr_url": settings.lidarr_url, + "lidarr_api_key": settings.lidarr_api_key, + "quality_profile_id": settings.quality_profile_id, + "metadata_profile_id": settings.metadata_profile_id, + "root_folder_path": settings.root_folder_path, + }) + self._save_config(config) + + self._settings.lidarr_url = settings.lidarr_url + self._settings.lidarr_api_key = settings.lidarr_api_key + self._settings.quality_profile_id = settings.quality_profile_id + self._settings.metadata_profile_id = settings.metadata_profile_id + self._settings.root_folder_path = settings.root_folder_path + + logger.info(f"Saved Lidarr connection settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save Lidarr connection settings: {e}") + raise ConfigurationError(f"Failed to save Lidarr connection settings: {e}") + + def get_jellyfin_connection(self) -> JellyfinConnectionSettings: + config = self._load_config() + jellyfin_data = config.get("jellyfin_settings", {}) + return JellyfinConnectionSettings( + jellyfin_url=jellyfin_data.get("jellyfin_url", config.get("jellyfin_url", self._settings.jellyfin_url)), + api_key=jellyfin_data.get("api_key", ""), + user_id=jellyfin_data.get("user_id", ""), + enabled=jellyfin_data.get("enabled", False), + ) + + def save_jellyfin_connection(self, settings: JellyfinConnectionSettings) -> None: + try: + config = self._load_config().copy() + config["jellyfin_url"] = settings.jellyfin_url + config["jellyfin_settings"] = { + "jellyfin_url": settings.jellyfin_url, + "api_key": settings.api_key, + "user_id": settings.user_id, + "enabled": settings.enabled, + } + self._save_config(config) + + self._settings.jellyfin_url = settings.jellyfin_url + + logger.info(f"Saved Jellyfin connection settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save Jellyfin connection settings: {e}") + raise ConfigurationError(f"Failed to save Jellyfin connection settings: {e}") + + def get_navidrome_connection(self) -> NavidromeConnectionSettings: + config = self._load_config() + nd_data = config.get("navidrome_settings", {}) + password = nd_data.get("password", "") + return NavidromeConnectionSettings( + navidrome_url=nd_data.get("navidrome_url", ""), + username=nd_data.get("username", ""), + password=NAVIDROME_PASSWORD_MASK if password else "", + enabled=nd_data.get("enabled", False), + ) + + def get_navidrome_connection_raw(self) -> NavidromeConnectionSettings: + config = self._load_config() + nd_data = config.get("navidrome_settings", {}) + return NavidromeConnectionSettings( + navidrome_url=nd_data.get("navidrome_url", ""), + username=nd_data.get("username", ""), + password=nd_data.get("password", ""), + enabled=nd_data.get("enabled", False), + ) + + def save_navidrome_connection(self, settings: NavidromeConnectionSettings) -> None: + try: + config = self._load_config().copy() + current_data = config.get("navidrome_settings", {}) + + password = settings.password + if password == NAVIDROME_PASSWORD_MASK: + password = current_data.get("password", "") + + config["navidrome_settings"] = { + "navidrome_url": settings.navidrome_url, + "username": settings.username, + "password": password, + "enabled": settings.enabled, + } + self._save_config(config) + logger.info("Saved Navidrome connection settings to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save Navidrome connection settings: %s", e) + raise ConfigurationError(f"Failed to save Navidrome connection settings: {e}") + + def get_listenbrainz_connection(self) -> ListenBrainzConnectionSettings: + config = self._load_config() + lb_data = config.get("listenbrainz_settings", {}) + return ListenBrainzConnectionSettings( + username=lb_data.get("username", ""), + user_token=lb_data.get("user_token", ""), + enabled=lb_data.get("enabled", False), + ) + + def save_listenbrainz_connection(self, settings: ListenBrainzConnectionSettings) -> None: + try: + config = self._load_config().copy() + config["listenbrainz_settings"] = { + "username": settings.username, + "user_token": settings.user_token, + "enabled": settings.enabled, + } + self._save_config(config) + + logger.info(f"Saved ListenBrainz connection settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save ListenBrainz connection settings: {e}") + raise ConfigurationError(f"Failed to save ListenBrainz connection settings: {e}") + + def get_youtube_connection(self) -> YouTubeConnectionSettings: + config = self._load_config() + yt_data = config.get("youtube_settings", {}) + api_key = str(yt_data.get("api_key") or "") + enabled = yt_data.get("enabled", False) + # Auto-migrate: existing setups with enabled+api_key get api_enabled=True + if "api_enabled" not in yt_data and enabled and api_key.strip(): + api_enabled = True + else: + api_enabled = yt_data.get("api_enabled", False) + return YouTubeConnectionSettings( + api_key=api_key, + enabled=enabled, + api_enabled=api_enabled, + daily_quota_limit=yt_data.get("daily_quota_limit", 80), + ) + + def save_youtube_connection(self, settings: YouTubeConnectionSettings) -> None: + try: + config = self._load_config().copy() + config["youtube_settings"] = { + "api_key": settings.api_key.strip(), + "enabled": settings.enabled, + "api_enabled": settings.api_enabled, + "daily_quota_limit": settings.daily_quota_limit, + } + self._save_config(config) + logger.info(f"Saved YouTube connection settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save YouTube connection settings: {e}") + raise ConfigurationError(f"Failed to save YouTube connection settings: {e}") + + def get_home_settings(self) -> HomeSettings: + return self._get_section("home_settings", HomeSettings) + + def save_home_settings(self, settings: HomeSettings) -> None: + try: + self._save_section("home_settings", settings) + logger.info(f"Saved home settings to {self._config_path}") + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to save home settings: {e}") + raise ConfigurationError(f"Failed to save home settings: {e}") + + def get_local_files_connection(self) -> LocalFilesConnectionSettings: + return self._get_section("local_files_settings", LocalFilesConnectionSettings) + + def save_local_files_connection(self, settings: LocalFilesConnectionSettings) -> None: + try: + self._save_section("local_files_settings", settings) + logger.info("Saved local files settings to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save local files settings: %s", e) + raise ConfigurationError(f"Failed to save local files settings: {e}") + + def get_lastfm_connection(self) -> LastFmConnectionSettings: + return self._get_section("lastfm_settings", LastFmConnectionSettings) + + def save_lastfm_connection(self, settings: LastFmConnectionSettings) -> None: + try: + current = self.get_lastfm_connection() + + api_key = settings.api_key.strip() + shared_secret = settings.shared_secret + if shared_secret.startswith(LASTFM_SECRET_MASK): + shared_secret = current.shared_secret + else: + shared_secret = shared_secret.strip() + + session_key = settings.session_key + if session_key.startswith(LASTFM_SECRET_MASK): + session_key = current.session_key + else: + session_key = session_key.strip() + + username = settings.username.strip() + enabled = settings.enabled + if not api_key or not shared_secret: + enabled = False + session_key = "" + username = "" + + resolved = LastFmConnectionSettings( + api_key=api_key, + shared_secret=shared_secret, + session_key=session_key, + username=username, + enabled=enabled, + ) + self._save_section("lastfm_settings", resolved) + logger.info("Saved Last.fm connection settings to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save Last.fm connection settings: %s", e) + raise ConfigurationError(f"Failed to save Last.fm connection settings: {e}") + + def is_lastfm_enabled(self) -> bool: + settings = self.get_lastfm_connection() + return settings.enabled and bool(settings.api_key) and bool(settings.shared_secret) + + def get_scrobble_settings(self) -> ScrobbleSettings: + return self._get_section("scrobble_settings", ScrobbleSettings) + + def save_scrobble_settings(self, settings: ScrobbleSettings) -> None: + try: + self._save_section("scrobble_settings", settings) + logger.info("Saved scrobble settings to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save scrobble settings: %s", e) + raise ConfigurationError(f"Failed to save scrobble settings: {e}") + + def get_primary_music_source(self) -> PrimaryMusicSourceSettings: + return self._get_section("primary_music_source", PrimaryMusicSourceSettings) + + def save_primary_music_source(self, settings: PrimaryMusicSourceSettings) -> None: + try: + self._save_section("primary_music_source", settings) + logger.info("Saved primary music source to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save primary music source: %s", e) + raise ConfigurationError(f"Failed to save primary music source: {e}") + + def get_profile_settings(self) -> ProfileSettings: + return self._get_section("profile_settings", ProfileSettings) + + def save_profile_settings(self, settings: ProfileSettings) -> None: + try: + self._save_section("profile_settings", settings) + logger.info("Saved profile settings to %s", self._config_path) + except Exception as e: # noqa: BLE001 + logger.error("Failed to save profile settings: %s", e) + raise ConfigurationError(f"Failed to save profile settings: {e}") + + def get_setting(self, key: str) -> Any: + config = self._load_config() + internal = config.get("_internal", {}) + return internal.get(key) + + def save_setting(self, key: str, value: Any) -> None: + config = self._load_config().copy() + internal = config.get("_internal", {}).copy() + if value is None: + internal.pop(key, None) + else: + internal[key] = value + config["_internal"] = internal + self._save_config(config) diff --git a/backend/services/request_service.py b/backend/services/request_service.py new file mode 100644 index 0000000..77d2658 --- /dev/null +++ b/backend/services/request_service.py @@ -0,0 +1,73 @@ +import logging +from repositories.protocols import LidarrRepositoryProtocol +from infrastructure.queue.request_queue import RequestQueue +from infrastructure.persistence.request_history import RequestHistoryStore +from api.v1.schemas.request import QueueStatusResponse, RequestResponse +from core.exceptions import ExternalServiceError +from services.request_utils import extract_cover_url + +logger = logging.getLogger(__name__) + + +class RequestService: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + request_queue: RequestQueue, + request_history: RequestHistoryStore, + ): + self._lidarr_repo = lidarr_repo + self._request_queue = request_queue + self._request_history = request_history + + async def request_album(self, musicbrainz_id: str, artist: str | None = None, album: str | None = None, year: int | None = None) -> RequestResponse: + if not self._lidarr_repo.is_configured(): + raise ExternalServiceError("Lidarr is not configured — set a Lidarr API key in Settings to request albums.") + try: + result = await self._request_queue.add(musicbrainz_id) + + payload = result.get("payload", {}) + lidarr_album_id = None + cover_url = None + artist_mbid = None + resolved_artist = artist or "Unknown" + resolved_album = album or "Unknown" + + if payload and isinstance(payload, dict): + lidarr_album_id = payload.get("id") + resolved_album = payload.get("title") or resolved_album + cover_url = extract_cover_url(payload) + + artist_data = payload.get("artist", {}) + if artist_data: + resolved_artist = artist_data.get("artistName") or resolved_artist + artist_mbid = artist_data.get("foreignArtistId") + + try: + await self._request_history.async_record_request( + musicbrainz_id=musicbrainz_id, + artist_name=resolved_artist, + album_title=resolved_album, + year=year, + cover_url=cover_url, + artist_mbid=artist_mbid, + lidarr_album_id=lidarr_album_id, + ) + except Exception as e: # noqa: BLE001 + logger.error("Failed to persist request history for %s: %s", musicbrainz_id, e) + + return RequestResponse( + success=True, + message=result["message"], + lidarr_response=payload, + ) + except Exception as e: # noqa: BLE001 + logger.error("Failed to request album %s: %s", musicbrainz_id, e) + raise ExternalServiceError(f"Failed to request album: {e}") + + def get_queue_status(self) -> QueueStatusResponse: + status = self._request_queue.get_status() + return QueueStatusResponse( + queue_size=status["queue_size"], + processing=status["processing"] + ) diff --git a/backend/services/request_utils.py b/backend/services/request_utils.py new file mode 100644 index 0000000..db51e09 --- /dev/null +++ b/backend/services/request_utils.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Optional + +from infrastructure.cover_urls import release_group_cover_url + + +_FAILED_STATES = {"downloadFailed", "downloadFailedPending", "importFailed"} +_IMPORT_BLOCKED_STATES = {"importBlocked", "importPending"} + +_DOWNLOAD_STATE_MAP: dict[str, str] = { + "downloading": "downloading", + "importing": "importing", + "imported": "imported", + "paused": "paused", + "downloadClientUnavailable": "downloadClientUnavailable", + "queued": "queued", +} + + +def resolve_display_status(download_state: Optional[str]) -> str: + if download_state in _FAILED_STATES: + return "importFailed" + if download_state in _IMPORT_BLOCKED_STATES: + return "importBlocked" + return _DOWNLOAD_STATE_MAP.get(download_state or "", "pending") + + +def parse_eta(eta_str: Optional[str]) -> Optional[datetime]: + if not eta_str: + return None + try: + return datetime.fromisoformat(eta_str.replace("Z", "+00:00")) + except (ValueError, TypeError): + return None + + +def extract_cover_url(album_data: dict) -> Optional[str]: + canonical_cover_url = release_group_cover_url(album_data.get("foreignAlbumId"), size=500) + if canonical_cover_url: + return canonical_cover_url + + images = album_data.get("images", []) + for img in images: + if img.get("coverType", "").lower() == "cover": + return img.get("remoteUrl") or img.get("url") + if images: + return images[0].get("remoteUrl") or images[0].get("url") + return None diff --git a/backend/services/requests_page_service.py b/backend/services/requests_page_service.py new file mode 100644 index 0000000..68b4ad4 --- /dev/null +++ b/backend/services/requests_page_service.py @@ -0,0 +1,477 @@ +import logging +import math +import time as _time +from collections.abc import Callable, Coroutine +from datetime import datetime, timezone +from typing import Any, Optional + +from api.v1.schemas.requests_page import ( + ActiveRequestItem, + ActiveRequestsResponse, + CancelRequestResponse, + RequestHistoryItem, + RequestHistoryResponse, + RetryRequestResponse, + StatusMessage, +) +from infrastructure.cover_urls import prefer_release_group_cover_url +from infrastructure.persistence.request_history import RequestHistoryRecord, RequestHistoryStore +from repositories.protocols import LidarrRepositoryProtocol +from services.request_utils import extract_cover_url, parse_eta, resolve_display_status + +logger = logging.getLogger(__name__) + +_CANCELLABLE_STATUSES = {"pending", "downloading"} +_RETRYABLE_STATUSES = {"failed", "cancelled", "incomplete"} +_CLEARABLE_STATUSES = {"imported", "incomplete", "failed", "cancelled"} + +_QUEUE_CACHE_TTL = 10 +_LIBRARY_MBIDS_CACHE_TTL = 30 + + +class RequestsPageService: + def __init__( + self, + lidarr_repo: LidarrRepositoryProtocol, + request_history: RequestHistoryStore, + library_mbids_fn: Callable[..., Coroutine[Any, Any, set[str]]], + on_import_callback: Callable[[RequestHistoryRecord], Coroutine[Any, Any, None]] | None = None, + ): + self._lidarr_repo = lidarr_repo + self._request_history = request_history + self._library_mbids_fn = library_mbids_fn + self._on_import_callback = on_import_callback + self._queue_cache: list[dict] | None = None + self._queue_cache_time: float = 0 + self._library_mbids_cache: set[str] | None = None + self._library_mbids_cache_time: float = 0 + + async def get_active_requests(self) -> ActiveRequestsResponse: + active_records = await self._request_history.async_get_active_requests() + if not active_records: + return ActiveRequestsResponse(items=[], count=0) + + queue_by_mbid = await self._load_queue_map( + {r.musicbrainz_id for r in active_records} + ) + library_mbids = await self._fetch_library_mbids() + + items: list[ActiveRequestItem] = [] + for record in active_records: + queue_item = queue_by_mbid.get(record.musicbrainz_id) + + if queue_item: + await self._sync_active_record(record, queue_item) + items.append(self._build_active_item_from_queue(record, queue_item)) + else: + completed = await self._check_if_completed(record, library_mbids) + if completed: + continue + items.append(self._build_pending_item(record)) + + return ActiveRequestsResponse(items=items, count=len(items)) + + async def get_request_history( + self, + page: int = 1, + page_size: int = 20, + status_filter: Optional[str] = None, + sort: Optional[str] = None, + ) -> RequestHistoryResponse: + records, total = await self._request_history.async_get_history( + page=page, page_size=page_size, status_filter=status_filter, sort=sort + ) + + library_mbids = await self._fetch_library_mbids() + + items = [ + RequestHistoryItem( + musicbrainz_id=r.musicbrainz_id, + artist_name=r.artist_name, + album_title=r.album_title, + artist_mbid=r.artist_mbid, + year=r.year, + cover_url=r.cover_url, + requested_at=datetime.fromisoformat(r.requested_at), + completed_at=( + datetime.fromisoformat(r.completed_at) + if r.completed_at + else None + ), + status=r.status, + in_library=r.musicbrainz_id.lower() in library_mbids, + ) + for r in records + ] + + total_pages = max(1, math.ceil(total / page_size)) + + return RequestHistoryResponse( + items=items, + total=total, + page=page, + page_size=page_size, + total_pages=total_pages, + ) + + async def cancel_request( + self, musicbrainz_id: str + ) -> CancelRequestResponse: + record = await self._request_history.async_get_record(musicbrainz_id) + if not record: + return CancelRequestResponse( + success=False, message="Request not found" + ) + + if record.status not in _CANCELLABLE_STATUSES: + return CancelRequestResponse( + success=False, + message=f"Cannot cancel request with status '{record.status}'", + ) + + try: + queue_items = await self._get_cached_queue() + except Exception as e: # noqa: BLE001 + logger.error("Failed to fetch queue for cancel: %s", e) + return CancelRequestResponse( + success=False, message="Failed to reach Lidarr" + ) + + queue_id = None + for item in queue_items: + album_data = item.get("album", {}) + if album_data.get("foreignAlbumId", "").lower() == musicbrainz_id.lower(): + queue_id = item.get("id") + break + + if queue_id: + removed = await self._lidarr_repo.remove_queue_item(queue_id) + if not removed: + return CancelRequestResponse( + success=False, message="Couldn't remove the item from the download queue" + ) + self._invalidate_queue_cache() + else: + library_mbids = await self._fetch_library_mbids() + if musicbrainz_id.lower() in library_mbids: + return CancelRequestResponse( + success=False, + message="Album already imported, cannot cancel", + ) + + now_iso = datetime.now(timezone.utc).isoformat() + await self._request_history.async_update_status( + musicbrainz_id, "cancelled", completed_at=now_iso + ) + + return CancelRequestResponse( + success=True, + message=f"Cancelled download of {record.album_title}", + ) + + async def retry_request( + self, musicbrainz_id: str + ) -> RetryRequestResponse: + record = await self._request_history.async_get_record(musicbrainz_id) + if not record: + return RetryRequestResponse( + success=False, message="Request not found" + ) + + if record.status not in _RETRYABLE_STATUSES: + return RetryRequestResponse( + success=False, + message=f"Cannot retry request with status '{record.status}'", + ) + + if record.lidarr_album_id: + result = await self._lidarr_repo.trigger_album_search( + [record.lidarr_album_id] + ) + if result: + await self._request_history.async_update_status(musicbrainz_id, "pending") + return RetryRequestResponse( + success=True, + message=f"Retrying search for {record.album_title}", + ) + + # Search failed or no Lidarr album ID — fall through to add_album + # which handles the "album already exists" case gracefully via its + # action="exists" path. + try: + add_result = await self._lidarr_repo.add_album(musicbrainz_id) + payload = add_result.get("payload", {}) + if payload and isinstance(payload, dict): + new_id = payload.get("id") + if new_id: + await self._request_history.async_update_lidarr_album_id( + musicbrainz_id, new_id + ) + await self._request_history.async_update_status(musicbrainz_id, "pending") + return RetryRequestResponse( + success=True, + message=f"Re-requested {record.album_title}", + ) + except Exception as e: # noqa: BLE001 + logger.error("Retry failed for %s: %s", musicbrainz_id, e) + return RetryRequestResponse( + success=False, message=f"Retry failed: {e}" + ) + + async def clear_history_item(self, musicbrainz_id: str) -> bool: + record = await self._request_history.async_get_record(musicbrainz_id) + if not record or record.status not in _CLEARABLE_STATUSES: + return False + return await self._request_history.async_delete_record(musicbrainz_id) + + async def get_active_count(self) -> int: + return await self._request_history.async_get_active_count() + + async def sync_request_statuses(self) -> None: + active_records = await self._request_history.async_get_active_requests() + if not active_records: + return + + try: + queue_items = await self._get_cached_queue() + except Exception as e: # noqa: BLE001 + logger.warning("Status sync failed - cannot reach Lidarr: %s", e) + return + + queue_mbids: set[str] = set() + for item in queue_items: + album_data = item.get("album", {}) + mbid = album_data.get("foreignAlbumId") + if mbid: + queue_mbids.add(mbid.lower()) + + library_mbids = await self._fetch_library_mbids() + + for record in active_records: + if record.musicbrainz_id.lower() in queue_mbids: + if record.status != "downloading": + await self._request_history.async_update_status( + record.musicbrainz_id, "downloading" + ) + else: + await self._check_if_completed(record, library_mbids) + + + async def _fetch_library_mbids(self) -> set[str]: + now = _time.monotonic() + if self._library_mbids_cache is not None and (now - self._library_mbids_cache_time) < _LIBRARY_MBIDS_CACHE_TTL: + return self._library_mbids_cache + try: + result = await self._library_mbids_fn() + self._library_mbids_cache = result + self._library_mbids_cache_time = now + return result + except Exception: # noqa: BLE001 + if self._library_mbids_cache is not None: + return self._library_mbids_cache + return set() + + async def _get_cached_queue(self) -> list[dict]: + now = _time.monotonic() + if self._queue_cache is not None and (now - self._queue_cache_time) < _QUEUE_CACHE_TTL: + return self._queue_cache + try: + queue_items = await self._lidarr_repo.get_queue_details() + self._queue_cache = queue_items + self._queue_cache_time = now + return queue_items + except Exception as e: # noqa: BLE001 + logger.warning("Failed to fetch Lidarr queue: %s", e) + return self._queue_cache or [] + + def _invalidate_queue_cache(self) -> None: + self._queue_cache = None + self._queue_cache_time = 0 + + async def _load_queue_map( + self, active_mbids: set[str] + ) -> dict[str, dict]: + queue_items = await self._get_cached_queue() + + normalized_active = {m.lower() for m in active_mbids} + queue_by_mbid: dict[str, dict] = {} + for item in queue_items: + album_data = item.get("album", {}) + mbid = album_data.get("foreignAlbumId") + if mbid and mbid.lower() in normalized_active: + queue_by_mbid[mbid] = item + return queue_by_mbid + + async def _sync_active_record( + self, + record: RequestHistoryRecord, + queue_item: dict, + ) -> None: + if record.status != "downloading": + await self._request_history.async_update_status( + record.musicbrainz_id, "downloading" + ) + + if not record.cover_url: + album_data = queue_item.get("album", {}) + cover_url = extract_cover_url(album_data) + if cover_url: + await self._request_history.async_update_cover_url( + record.musicbrainz_id, cover_url + ) + record.cover_url = cover_url + + @staticmethod + def _build_active_item_from_queue( + record: RequestHistoryRecord, + queue_item: dict, + ) -> ActiveRequestItem: + album_data = queue_item.get("album", {}) + artist_data = album_data.get("artist", {}) or queue_item.get("artist", {}) + + cover_url = prefer_release_group_cover_url( + record.musicbrainz_id, + record.cover_url or extract_cover_url(album_data), + size=500, + ) + artist_mbid = record.artist_mbid or artist_data.get("foreignArtistId") + + size = queue_item.get("size") + sizeleft = queue_item.get("sizeleft") + progress = ( + round((size - (sizeleft or 0)) / size * 100, 1) + if size and size > 0 + else None + ) + + eta = parse_eta(queue_item.get("estimatedCompletionTime")) + + status_messages = [ + StatusMessage( + title=msg.get("title"), + messages=msg.get("messages") or [], + ) + for msg in (queue_item.get("statusMessages") or []) + ] or None + + download_state = queue_item.get("trackedDownloadState") + display_status = resolve_display_status(download_state) + + quality_data = queue_item.get("quality", {}) + quality_name = None + if isinstance(quality_data, dict): + quality_obj = quality_data.get("quality", {}) + if isinstance(quality_obj, dict): + quality_name = quality_obj.get("name") + + return ActiveRequestItem( + musicbrainz_id=record.musicbrainz_id, + artist_name=record.artist_name, + album_title=record.album_title, + artist_mbid=artist_mbid, + year=record.year, + cover_url=cover_url, + requested_at=datetime.fromisoformat(record.requested_at), + status=display_status, + progress=progress, + eta=eta, + size=size, + size_remaining=sizeleft, + download_status=queue_item.get("trackedDownloadStatus"), + download_state=download_state, + status_messages=status_messages, + error_message=queue_item.get("errorMessage"), + lidarr_queue_id=queue_item.get("id"), + quality=quality_name, + protocol=queue_item.get("protocol"), + download_client=queue_item.get("downloadClient"), + ) + + @staticmethod + def _build_pending_item(record: RequestHistoryRecord) -> ActiveRequestItem: + return ActiveRequestItem( + musicbrainz_id=record.musicbrainz_id, + artist_name=record.artist_name, + album_title=record.album_title, + artist_mbid=record.artist_mbid, + year=record.year, + cover_url=prefer_release_group_cover_url( + record.musicbrainz_id, + record.cover_url, + size=500, + ), + requested_at=datetime.fromisoformat(record.requested_at), + status=record.status, + progress=None, + eta=None, + size=None, + size_remaining=None, + download_status=None, + download_state=None, + status_messages=None, + lidarr_queue_id=None, + ) + + async def _check_if_completed( + self, + record: RequestHistoryRecord, + library_mbids: set[str], + ) -> bool: + now_iso = datetime.now(timezone.utc).isoformat() + + if record.musicbrainz_id.lower() in library_mbids: + await self._request_history.async_update_status( + record.musicbrainz_id, "imported", completed_at=now_iso + ) + await self._notify_import(record) + return True + + if record.lidarr_album_id: + try: + history = await self._lidarr_repo.get_history_for_album( + record.lidarr_album_id + ) + for event in history: + event_type = event.get("eventType", "") + if event_type in ( + "downloadImported", + "trackFileImported", + ): + await self._request_history.async_update_status( + record.musicbrainz_id, + "imported", + completed_at=now_iso, + ) + await self._notify_import(record) + return True + if event_type == "albumImportIncomplete": + logger.info( + "Partial import detected for %s", + record.musicbrainz_id, + ) + await self._request_history.async_update_status( + record.musicbrainz_id, + "incomplete", + completed_at=now_iso, + ) + return True + if event_type == "downloadFailed": + await self._request_history.async_update_status( + record.musicbrainz_id, + "failed", + completed_at=now_iso, + ) + return True + except Exception as e: # noqa: BLE001 + logger.debug("Lidarr history check failed for %s: %s", record.musicbrainz_id, e) + + return False + + async def _notify_import(self, record: RequestHistoryRecord) -> None: + self._library_mbids_cache = None + self._library_mbids_cache_time = 0 + if self._on_import_callback: + try: + await self._on_import_callback(record) + except Exception as e: # noqa: BLE001 + logger.warning("Import callback failed for %s: %s", record.musicbrainz_id, e) diff --git a/backend/services/scrobble_service.py b/backend/services/scrobble_service.py new file mode 100644 index 0000000..74483b4 --- /dev/null +++ b/backend/services/scrobble_service.py @@ -0,0 +1,220 @@ +import asyncio +import logging +import time +from typing import Any + +from api.v1.schemas.scrobble import ( + NowPlayingRequest, + ScrobbleRequest, + ScrobbleResponse, + ServiceResult, +) +from repositories.protocols import LastFmRepositoryProtocol, ListenBrainzRepositoryProtocol +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +DEDUP_TTL_SECONDS = 3600 +DEDUP_MAX_ENTRIES = 200 +MIN_TRACK_DURATION_MS = 30_000 + + +class ScrobbleService: + def __init__( + self, + lastfm_repo: LastFmRepositoryProtocol, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + preferences_service: PreferencesService, + ): + self._lastfm_repo = lastfm_repo + self._listenbrainz_repo = listenbrainz_repo + self._preferences_service = preferences_service + self._dedup_cache: dict[str, float] = {} + + def _dedup_key(self, artist: str, track: str, timestamp: int) -> str: + return f"{artist.lower()}::{track.lower()}::{timestamp}" + + def _is_duplicate(self, key: str) -> bool: + entry_time = self._dedup_cache.get(key) + if entry_time is None: + return False + return (time.time() - entry_time) < DEDUP_TTL_SECONDS + + def _record_dedup(self, key: str) -> None: + self._dedup_cache[key] = time.time() + if len(self._dedup_cache) > DEDUP_MAX_ENTRIES: + now = time.time() + expired = [ + k for k, v in self._dedup_cache.items() + if (now - v) >= DEDUP_TTL_SECONDS + ] + for k in expired: + del self._dedup_cache[k] + if len(self._dedup_cache) > DEDUP_MAX_ENTRIES: + oldest = sorted(self._dedup_cache, key=self._dedup_cache.get) # type: ignore[arg-type] + for k in oldest[: len(self._dedup_cache) - DEDUP_MAX_ENTRIES]: + del self._dedup_cache[k] + + def _is_lastfm_enabled(self) -> bool: + scrobble = self._preferences_service.get_scrobble_settings() + if not scrobble.scrobble_to_lastfm: + return False + lastfm = self._preferences_service.get_lastfm_connection() + return ( + lastfm.enabled + and bool(lastfm.api_key) + and bool(lastfm.shared_secret) + and bool(lastfm.session_key) + ) + + def _is_listenbrainz_enabled(self) -> bool: + scrobble = self._preferences_service.get_scrobble_settings() + if not scrobble.scrobble_to_listenbrainz: + return False + lb = self._preferences_service.get_listenbrainz_connection() + return lb.enabled and bool(lb.user_token) + + async def report_now_playing( + self, request: NowPlayingRequest + ) -> ScrobbleResponse: + tasks: dict[str, Any] = {} + duration_sec = request.duration_ms // 1000 if request.duration_ms > 0 else 0 + + if self._is_lastfm_enabled(): + tasks["lastfm"] = self._lastfm_repo.update_now_playing( + artist=request.artist_name, + track=request.track_name, + album=request.album_name, + duration=duration_sec, + mbid=request.mbid, + ) + + if self._is_listenbrainz_enabled(): + tasks["listenbrainz"] = self._listenbrainz_repo.submit_now_playing( + artist_name=request.artist_name, + track_name=request.track_name, + release_name=request.album_name, + duration_ms=request.duration_ms, + ) + + if not tasks: + return ScrobbleResponse(accepted=False, services={}) + + results_list = await asyncio.gather( + *tasks.values(), return_exceptions=True + ) + services: dict[str, ServiceResult] = {} + any_success = False + + for service_name, result in zip(tasks.keys(), results_list): + if isinstance(result, BaseException): + logger.warning( + "Now playing report failed for %s: %s", + service_name, + result, + ) + services[service_name] = ServiceResult( + success=False, error=str(result) + ) + else: + services[service_name] = ServiceResult(success=True) + any_success = True + + logger.info( + "Now playing reported", + extra={ + "artist": request.artist_name, + "track": request.track_name, + "services": { + k: {"success": v.success, "error": v.error} + for k, v in services.items() + }, + }, + ) + return ScrobbleResponse(accepted=any_success, services=services) + + async def submit_scrobble( + self, request: ScrobbleRequest + ) -> ScrobbleResponse: + if 0 < request.duration_ms < MIN_TRACK_DURATION_MS: + logger.debug( + "Skipping scrobble for short track (%dms): %s - %s", + request.duration_ms, + request.artist_name, + request.track_name, + ) + return ScrobbleResponse(accepted=False, services={}) + + dedup = self._dedup_key( + request.artist_name, request.track_name, request.timestamp + ) + if self._is_duplicate(dedup): + logger.debug( + "Duplicate scrobble skipped: %s - %s at %d", + request.artist_name, + request.track_name, + request.timestamp, + ) + return ScrobbleResponse(accepted=True, services={}) + + tasks: dict[str, Any] = {} + duration_sec = request.duration_ms // 1000 if request.duration_ms > 0 else 0 + + if self._is_lastfm_enabled(): + tasks["lastfm"] = self._lastfm_repo.scrobble( + artist=request.artist_name, + track=request.track_name, + timestamp=request.timestamp, + album=request.album_name, + duration=duration_sec, + mbid=request.mbid, + ) + + if self._is_listenbrainz_enabled(): + tasks["listenbrainz"] = self._listenbrainz_repo.submit_single_listen( + artist_name=request.artist_name, + track_name=request.track_name, + listened_at=request.timestamp, + release_name=request.album_name, + duration_ms=request.duration_ms, + ) + + if not tasks: + return ScrobbleResponse(accepted=False, services={}) + + results_list = await asyncio.gather( + *tasks.values(), return_exceptions=True + ) + services: dict[str, ServiceResult] = {} + any_success = False + + for service_name, result in zip(tasks.keys(), results_list): + if isinstance(result, BaseException): + logger.warning( + "Scrobble submission failed for %s: %s", + service_name, + result, + ) + services[service_name] = ServiceResult( + success=False, error=str(result) + ) + else: + services[service_name] = ServiceResult(success=True) + any_success = True + + if any_success: + self._record_dedup(dedup) + + logger.info( + "Scrobble submitted", + extra={ + "artist": request.artist_name, + "track": request.track_name, + "timestamp": request.timestamp, + "services": { + k: {"success": v.success, "error": v.error} + for k, v in services.items() + }, + }, + ) + return ScrobbleResponse(accepted=any_success, services=services) diff --git a/backend/services/search_enrichment_service.py b/backend/services/search_enrichment_service.py new file mode 100644 index 0000000..557e517 --- /dev/null +++ b/backend/services/search_enrichment_service.py @@ -0,0 +1,239 @@ +import asyncio +import logging +from typing import Optional +from api.v1.schemas.search import ( + ArtistEnrichment, + AlbumEnrichment, + EnrichmentBatchRequest, + EnrichmentResponse, + EnrichmentSource, +) +from repositories.protocols import ( + MusicBrainzRepositoryProtocol, + ListenBrainzRepositoryProtocol, + LastFmRepositoryProtocol, +) +from services.preferences_service import PreferencesService + +logger = logging.getLogger(__name__) + +MAX_ENRICHMENT = 10 + + +class SearchEnrichmentService: + def __init__( + self, + mb_repo: MusicBrainzRepositoryProtocol, + lb_repo: ListenBrainzRepositoryProtocol, + preferences_service: PreferencesService, + lastfm_repo: Optional[LastFmRepositoryProtocol] = None, + ): + self._mb_repo = mb_repo + self._lb_repo = lb_repo + self._preferences_service = preferences_service + self._lastfm_repo = lastfm_repo + + def _is_listenbrainz_enabled(self) -> bool: + lb_settings = self._preferences_service.get_listenbrainz_connection() + return lb_settings.enabled and bool(lb_settings.username) + + def _is_lastfm_enabled(self) -> bool: + try: + lfm_settings = self._preferences_service.get_lastfm_connection() + return lfm_settings.enabled and bool(lfm_settings.api_key) + except Exception: # noqa: BLE001 + return False + + def _get_enrichment_source(self) -> EnrichmentSource: + lb_enabled = self._is_listenbrainz_enabled() + lfm_enabled = self._is_lastfm_enabled() and self._lastfm_repo is not None + + if not lb_enabled and not lfm_enabled: + return "none" + + try: + primary = self._preferences_service.get_primary_music_source() + preferred = primary.source + except Exception: # noqa: BLE001 + preferred = "listenbrainz" + + if preferred == "lastfm" and lfm_enabled: + return "lastfm" + if preferred == "listenbrainz" and lb_enabled: + return "listenbrainz" + if lb_enabled: + return "listenbrainz" + if lfm_enabled: + return "lastfm" + return "none" + + async def enrich( + self, + artist_mbids: list[str], + album_mbids: list[str], + ) -> EnrichmentResponse: + source = self._get_enrichment_source() + + artist_mbids = artist_mbids[:MAX_ENRICHMENT] + album_mbids = album_mbids[:MAX_ENRICHMENT] + + artist_tasks = [ + self._enrich_artist(mbid, source) + for mbid in artist_mbids + ] + + album_listen_counts: dict[str, int] = {} + if source == "listenbrainz" and album_mbids: + try: + album_listen_counts = await self._lb_repo.get_release_group_popularity_batch( + album_mbids + ) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get album popularity batch: {e}") + + artist_results = await asyncio.gather(*artist_tasks, return_exceptions=True) + + artists: list[ArtistEnrichment] = [] + for result in artist_results: + if isinstance(result, Exception): + logger.debug(f"Artist enrichment failed: {result}") + continue + if result: + artists.append(result) + + albums: list[AlbumEnrichment] = [] + for mbid in album_mbids: + albums.append(AlbumEnrichment( + musicbrainz_id=mbid, + track_count=None, + listen_count=album_listen_counts.get(mbid), + )) + + return EnrichmentResponse( + artists=artists, + albums=albums, + source=source, + ) + + async def enrich_batch(self, request: EnrichmentBatchRequest) -> EnrichmentResponse: + source = self._get_enrichment_source() + + artist_requests = request.artists[:MAX_ENRICHMENT] + album_requests = request.albums[:MAX_ENRICHMENT] + + artist_tasks = [ + self._enrich_artist(req.musicbrainz_id, source, name=req.name) + for req in artist_requests + ] + + album_tasks: list[asyncio.Task[AlbumEnrichment]] = [] + album_listen_counts: dict[str, int] = {} + + if source == "listenbrainz" and album_requests: + mbids = [r.musicbrainz_id for r in album_requests] + try: + album_listen_counts = await self._lb_repo.get_release_group_popularity_batch(mbids) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get album popularity batch: {e}") + elif source == "lastfm" and album_requests and self._lastfm_repo: + album_tasks = [ + self._enrich_album_lastfm(req.musicbrainz_id, req.artist_name, req.album_name) + for req in album_requests + ] + + artist_results = await asyncio.gather(*artist_tasks, return_exceptions=True) + + artists: list[ArtistEnrichment] = [] + for result in artist_results: + if isinstance(result, Exception): + logger.debug(f"Artist enrichment failed: {result}") + continue + if result: + artists.append(result) + + albums: list[AlbumEnrichment] = [] + if album_tasks: + album_results = await asyncio.gather(*album_tasks, return_exceptions=True) + for result in album_results: + if isinstance(result, Exception): + logger.debug(f"Album enrichment failed: {result}") + continue + if result: + albums.append(result) + else: + for req in album_requests: + albums.append(AlbumEnrichment( + musicbrainz_id=req.musicbrainz_id, + track_count=None, + listen_count=album_listen_counts.get(req.musicbrainz_id), + )) + + return EnrichmentResponse( + artists=artists, + albums=albums, + source=source, + ) + + async def _enrich_artist( + self, + mbid: str, + source: EnrichmentSource, + name: str = "", + ) -> Optional[ArtistEnrichment]: + release_count: Optional[int] = None + listen_count: Optional[int] = None + + try: + _, total_count = await self._mb_repo.get_artist_release_groups( + artist_mbid=mbid, + offset=0, + limit=1, + ) + release_count = total_count + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get release count for artist {mbid}: {e}") + + if source == "listenbrainz": + try: + top_releases = await self._lb_repo.get_artist_top_release_groups(mbid, count=5) + if top_releases: + listen_count = sum(r.listen_count for r in top_releases) + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get LB popularity for artist {mbid}: {e}") + elif source == "lastfm" and self._lastfm_repo and name: + try: + info = await self._lastfm_repo.get_artist_info(artist=name, mbid=mbid) + if info and info.listeners is not None: + listen_count = info.listeners + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get Last.fm info for artist {name}: {e}") + + return ArtistEnrichment( + musicbrainz_id=mbid, + release_group_count=release_count, + listen_count=listen_count, + ) + + async def _enrich_album_lastfm( + self, + mbid: str, + artist_name: str, + album_name: str, + ) -> AlbumEnrichment: + listen_count: Optional[int] = None + + if self._lastfm_repo and artist_name and album_name: + try: + info = await self._lastfm_repo.get_album_info( + artist=artist_name, album=album_name, mbid=mbid + ) + if info and info.playcount is not None: + listen_count = info.playcount + except Exception as e: # noqa: BLE001 + logger.debug(f"Failed to get Last.fm info for album {album_name}: {e}") + + return AlbumEnrichment( + musicbrainz_id=mbid, + track_count=None, + listen_count=listen_count, + ) diff --git a/backend/services/search_service.py b/backend/services/search_service.py new file mode 100644 index 0000000..5a30f64 --- /dev/null +++ b/backend/services/search_service.py @@ -0,0 +1,322 @@ +import asyncio +import logging +import re +import time +import unicodedata +from math import ceil +from typing import Optional, TYPE_CHECKING +from api.v1.schemas.search import SearchResult, SearchResponse, SuggestResult, SuggestResponse +from repositories.protocols import MusicBrainzRepositoryProtocol, LidarrRepositoryProtocol, CoverArtRepositoryProtocol +from services.preferences_service import PreferencesService +from infrastructure.http.deduplication import deduplicate + +if TYPE_CHECKING: + from services.audiodb_image_service import AudioDBImageService + from services.audiodb_browse_queue import AudioDBBrowseQueue + +logger = logging.getLogger(__name__) + +COVER_PREFETCH_LIMIT = 12 +SEARCH_CACHE_TTL = 90 +SEARCH_CACHE_MAX_SIZE = 200 +TOP_RESULT_SCORE_THRESHOLD = 90 + + +class SearchService: + _search_cache: dict[str, tuple[float, SearchResponse]] = {} + + def __init__( + self, + mb_repo: MusicBrainzRepositoryProtocol, + lidarr_repo: LidarrRepositoryProtocol, + coverart_repo: CoverArtRepositoryProtocol, + preferences_service: PreferencesService, + audiodb_image_service: "AudioDBImageService | None" = None, + audiodb_browse_queue: "AudioDBBrowseQueue | None" = None, + ): + self._mb_repo = mb_repo + self._lidarr_repo = lidarr_repo + self._coverart_repo = coverart_repo + self._preferences_service = preferences_service + self._audiodb_image_service = audiodb_image_service + self._audiodb_browse_queue = audiodb_browse_queue + + async def _safe_gather(self, *tasks): + results = await asyncio.gather(*tasks, return_exceptions=True) + return [r if not isinstance(r, Exception) else None for r in results] + + @staticmethod + def _normalize_tokens(text: str) -> set[str]: + """Strip diacritics and non-alphanumeric chars, then tokenize.""" + nfkd = unicodedata.normalize("NFKD", text.lower()) + stripped = "".join(c for c in nfkd if not unicodedata.combining(c)) + cleaned = re.sub(r"[^a-z0-9\s]", "", stripped) + return set(cleaned.split()) + + @staticmethod + def _tokens_match(query_tokens: set[str], title_tokens: set[str]) -> bool: + """Check token overlap allowing prefix matching for partial queries.""" + min_prefix = 2 + if all( + any(qt == tt or (len(qt) >= min_prefix and tt.startswith(qt)) for tt in title_tokens) + for qt in query_tokens + ): + return True + if all( + any(tt == qt or (len(tt) >= min_prefix and qt.startswith(tt)) for qt in query_tokens) + for tt in title_tokens + ): + return True + return False + + @staticmethod + def _detect_top_result(results: list[SearchResult], query: str) -> SearchResult | None: + if not results: + return None + best = results[0] + if best.score < TOP_RESULT_SCORE_THRESHOLD: + return None + query_tokens = SearchService._normalize_tokens(query) + title_tokens = SearchService._normalize_tokens(best.title) + if not query_tokens or not title_tokens: + return None + if SearchService._tokens_match(query_tokens, title_tokens): + return best + return None + + async def _apply_audiodb_search_overlay(self, results: list[SearchResult]) -> None: + if self._audiodb_image_service is None: + return + + tasks = [] + task_indices = [] + for i, item in enumerate(results): + if not item.musicbrainz_id: + continue + if item.type == "artist": + tasks.append(self._audiodb_image_service.get_cached_artist_images(item.musicbrainz_id)) + task_indices.append(i) + elif item.type == "album": + tasks.append(self._audiodb_image_service.get_cached_album_images(item.musicbrainz_id)) + task_indices.append(i) + + if not tasks: + return + + images_results = await asyncio.gather(*tasks, return_exceptions=True) + + for idx, images in zip(task_indices, images_results): + item = results[idx] + if isinstance(images, Exception): + logger.warning("AudioDB search overlay failed for %s %s: %s", item.type, item.musicbrainz_id[:8], images) + continue + try: + if item.type == "artist": + if images and not images.is_negative: + if not item.thumb_url and images.thumb_url: + item.thumb_url = images.thumb_url + if not item.fanart_url and images.fanart_url: + item.fanart_url = images.fanart_url + if not item.banner_url and images.banner_url: + item.banner_url = images.banner_url + elif images is None and self._audiodb_browse_queue: + settings = self._preferences_service.get_advanced_settings() + if settings.audiodb_enabled: + await self._audiodb_browse_queue.enqueue( + "artist", item.musicbrainz_id, name=item.title, + ) + elif item.type == "album": + if images and not images.is_negative: + if not item.album_thumb_url and images.album_thumb_url: + item.album_thumb_url = images.album_thumb_url + elif images is None and self._audiodb_browse_queue: + settings = self._preferences_service.get_advanced_settings() + if settings.audiodb_enabled: + await self._audiodb_browse_queue.enqueue( + "album", item.musicbrainz_id, + name=item.title, + artist_name=item.artist, + ) + except Exception as e: # noqa: BLE001 + logger.warning("AudioDB search overlay apply failed for %s %s: %s", item.type, item.musicbrainz_id[:8], e) + + @deduplicate(lambda self, query, limit_artists=10, limit_albums=10, buckets=None: f"search:{query}:{limit_artists}:{limit_albums}:{buckets}") + async def search( + self, + query: str, + limit_artists: int = 10, + limit_albums: int = 10, + buckets: Optional[list[str]] = None + ) -> SearchResponse: + cache_key = f"{query.strip().lower()}:{limit_artists}:{limit_albums}:{','.join(sorted(buckets)) if buckets else ''}" + now = time.monotonic() + cached = self._search_cache.get(cache_key) + if cached and (now - cached[0]) < SEARCH_CACHE_TTL: + return cached[1] + + prefs = self._preferences_service.get_preferences() + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + + limits = {} + if not buckets or "artists" in buckets: + limits["artists"] = limit_artists + if not buckets or "albums" in buckets: + limits["albums"] = limit_albums + + try: + grouped, library_mbids_raw, queue_items_raw = await self._safe_gather( + self._mb_repo.search_grouped( + query, + limits=limits, + buckets=buckets, + included_secondary_types=included_secondary_types + ), + self._lidarr_repo.get_library_mbids(include_release_ids=True), + self._lidarr_repo.get_queue(), + ) + except Exception as e: # noqa: BLE001 + logger.error(f"Search gather failed unexpectedly: {e}") + grouped, library_mbids_raw, queue_items_raw = None, None, None + + if grouped is None: + logger.warning("MusicBrainz search returned no results or failed") + grouped = grouped or {"artists": [], "albums": []} + library_mbids = library_mbids_raw or set() + + if queue_items_raw: + queued_mbids = {item.musicbrainz_id.lower() for item in queue_items_raw if item.musicbrainz_id} + else: + queued_mbids = set() + + for item in grouped.get("albums", []): + mbid_lower = (item.musicbrainz_id or "").lower() + item.in_library = mbid_lower in library_mbids + item.requested = mbid_lower in queued_mbids and not item.in_library + + all_results = grouped.get("artists", []) + grouped.get("albums", []) + await self._apply_audiodb_search_overlay(all_results) + + top_artist = self._detect_top_result(grouped.get("artists", []), query) + top_album = self._detect_top_result(grouped.get("albums", []), query) + + response = SearchResponse( + artists=grouped.get("artists", []), + albums=grouped.get("albums", []), + top_artist=top_artist, + top_album=top_album, + ) + self._search_cache[cache_key] = (now, response) + if len(self._search_cache) > SEARCH_CACHE_MAX_SIZE: + expired = [k for k, (ts, _) in self._search_cache.items() if (now - ts) >= SEARCH_CACHE_TTL] + for k in expired: + del self._search_cache[k] + if len(self._search_cache) > SEARCH_CACHE_MAX_SIZE: + oldest_key = min(self._search_cache, key=lambda k: self._search_cache[k][0]) + del self._search_cache[oldest_key] + return response + + def schedule_cover_prefetch(self, albums: list[SearchResult]) -> list[str]: + return [ + item.musicbrainz_id + for item in albums[:COVER_PREFETCH_LIMIT] + if item.musicbrainz_id + ] + + @deduplicate(lambda self, bucket, query, limit=50, offset=0: f"search_bucket:{bucket}:{query}:{limit}:{offset}") + async def search_bucket( + self, + bucket: str, + query: str, + limit: int = 50, + offset: int = 0 + ) -> tuple[list[SearchResult], SearchResult | None]: + prefs = self._preferences_service.get_preferences() + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + + if bucket == "artists": + results = await self._mb_repo.search_artists(query, limit=limit, offset=offset) + elif bucket == "albums": + results = await self._mb_repo.search_albums( + query, + limit=limit, + offset=offset, + included_secondary_types=included_secondary_types + ) + else: + return [], None + + if bucket == "albums": + library_mbids_raw, queue_items_raw = await self._safe_gather( + self._lidarr_repo.get_library_mbids(include_release_ids=True), + self._lidarr_repo.get_queue(), + ) + library_mbids = library_mbids_raw or set() + if queue_items_raw: + queued_mbids = {item.musicbrainz_id.lower() for item in queue_items_raw if item.musicbrainz_id} + else: + queued_mbids = set() + + for item in results: + mbid_lower = (item.musicbrainz_id or "").lower() + item.in_library = mbid_lower in library_mbids + item.requested = mbid_lower in queued_mbids and not item.in_library + + await self._apply_audiodb_search_overlay(results) + + top_result = self._detect_top_result(results, query) if offset == 0 else None + return results, top_result + + @deduplicate(lambda self, query, limit=5: f"suggest:{query.strip().lower()}:{limit}") + async def suggest(self, query: str, limit: int = 5) -> SuggestResponse: + query = query.strip() + if len(query) < 2: + return SuggestResponse() + + prefs = self._preferences_service.get_preferences() + included_secondary_types = set(t.lower() for t in prefs.secondary_types) + bucket_limit = ceil(limit * 0.6) + + try: + grouped = await self._mb_repo.search_grouped( + query, + limits={"artists": bucket_limit, "albums": bucket_limit}, + included_secondary_types=included_secondary_types, + ) + except Exception as e: # noqa: BLE001 + logger.warning("MusicBrainz suggest failed (query_len=%d): %s", len(query), type(e).__name__) + return SuggestResponse() + + grouped = grouped or {"artists": [], "albums": []} + + library_mbids_raw, queue_items_raw = await self._safe_gather( + self._lidarr_repo.get_library_mbids(include_release_ids=True), + self._lidarr_repo.get_queue(), + ) + library_mbids = library_mbids_raw or set() + if queue_items_raw: + queued_mbids = {item.musicbrainz_id.lower() for item in queue_items_raw if item.musicbrainz_id} + else: + queued_mbids = set() + + for item in grouped.get("albums", []): + mbid_lower = (item.musicbrainz_id or "").lower() + item.in_library = mbid_lower in library_mbids + item.requested = mbid_lower in queued_mbids and not item.in_library + + suggestions: list[SuggestResult] = [] + for item in grouped.get("artists", []) + grouped.get("albums", []): + suggestions.append(SuggestResult( + type=item.type, + title=item.title, + artist=item.artist, + year=item.year, + musicbrainz_id=item.musicbrainz_id, + in_library=item.in_library, + requested=item.requested, + disambiguation=item.disambiguation, + score=item.score, + )) + + type_order = {"artist": 0, "album": 1} + suggestions.sort(key=lambda s: (-s.score, type_order.get(s.type, 2), s.title.lower())) + return SuggestResponse(results=suggestions[:limit]) diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py new file mode 100644 index 0000000..3c4a1c7 --- /dev/null +++ b/backend/services/settings_service.py @@ -0,0 +1,619 @@ +import logging + +import msgspec + +from api.v1.schemas.settings import ( + LidarrConnectionSettings, + JellyfinConnectionSettings, + ListenBrainzConnectionSettings, + NavidromeConnectionSettings, + YouTubeConnectionSettings, + LastFmConnectionSettings, + LidarrVerifyResponse, + LidarrMetadataProfilePreferences, + UserPreferences, + LidarrProfileSummary, + LidarrRootFolderSummary, + NAVIDROME_PASSWORD_MASK, + LASTFM_SECRET_MASK, +) +from core.config import Settings, get_settings +from core.exceptions import ExternalServiceError +from infrastructure.cache.cache_keys import ( + ARTIST_INFO_PREFIX, + ALBUM_INFO_PREFIX, + JELLYFIN_PREFIX, + LOCAL_FILES_PREFIX, + SOURCE_RESOLUTION_PREFIX, + musicbrainz_prefixes, + listenbrainz_prefixes, + lastfm_prefixes, + home_prefixes, +) +from infrastructure.cache.memory_cache import InMemoryCache, CacheInterface +from infrastructure.http.client import get_http_client +from repositories.jellyfin_models import JellyfinUser + +logger = logging.getLogger(__name__) + + +class JellyfinVerifyResult(msgspec.Struct): + success: bool + message: str + users: list[JellyfinUser] | None = None + + +class ListenBrainzVerifyResult(msgspec.Struct): + valid: bool + message: str + + +class NavidromeVerifyResult(msgspec.Struct): + valid: bool + message: str + + +class YouTubeVerifyResult(msgspec.Struct): + valid: bool + message: str + + +class LastFmVerifyResult(msgspec.Struct): + valid: bool + message: str + + +class SettingsService: + def __init__(self, preferences_service, cache: CacheInterface): + self._preferences_service = preferences_service + self._cache = cache + + async def verify_lidarr(self, settings: LidarrConnectionSettings) -> LidarrVerifyResponse: + try: + from infrastructure.validators import validate_service_url + validate_service_url(settings.lidarr_url, label="Lidarr URL") + + from repositories.lidarr import LidarrRepository + from repositories.lidarr.base import reset_lidarr_circuit_breaker + + reset_lidarr_circuit_breaker() + + app_settings = get_settings() + http_client = get_http_client(app_settings) + + temp_settings = Settings( + lidarr_url=settings.lidarr_url, + lidarr_api_key=settings.lidarr_api_key, + quality_profile_id=app_settings.quality_profile_id, + metadata_profile_id=app_settings.metadata_profile_id, + ) + temp_cache = InMemoryCache(max_entries=100) + + temp_repo = LidarrRepository( + settings=temp_settings, + http_client=http_client, + cache=temp_cache + ) + + status = await temp_repo.get_status() + + if status.status != "ok": + return LidarrVerifyResponse( + success=False, + message=status.message or "Couldn't connect", + quality_profiles=[], + metadata_profiles=[], + root_folders=[] + ) + + quality_profiles_raw = await temp_repo.get_quality_profiles() + quality_profiles = [ + LidarrProfileSummary(id=int(p.get("id", 0)), name=str(p.get("name", "Unknown"))) + for p in quality_profiles_raw + ] + + metadata_profiles_raw = await temp_repo.get_metadata_profiles() + metadata_profiles = [ + LidarrProfileSummary(id=int(p.get("id", 0)), name=str(p.get("name", "Unknown"))) + for p in metadata_profiles_raw + ] + + root_folders_raw = await temp_repo.get_root_folders() + root_folders = [ + LidarrRootFolderSummary(id=str(r.get("id", "")), path=str(r.get("path", ""))) + for r in root_folders_raw + ] + + return LidarrVerifyResponse( + success=True, + message="Connected to Lidarr", + quality_profiles=quality_profiles, + metadata_profiles=metadata_profiles, + root_folders=root_folders + ) + except ExternalServiceError as e: + logger.warning(f"Lidarr connection test failed: {e}") + return LidarrVerifyResponse( + success=False, + message="Couldn't reach Lidarr", + quality_profiles=[], + metadata_profiles=[], + root_folders=[] + ) + except Exception as e: + logger.exception(f"Failed to verify Lidarr connection: {e}") + return LidarrVerifyResponse( + success=False, + message="Couldn't finish the connection test", + quality_profiles=[], + metadata_profiles=[], + root_folders=[] + ) + + async def verify_jellyfin(self, settings: JellyfinConnectionSettings) -> JellyfinVerifyResult: + try: + from infrastructure.validators import validate_service_url + validate_service_url(settings.jellyfin_url, label="Jellyfin URL") + + from repositories.jellyfin_repository import JellyfinRepository + + JellyfinRepository.reset_circuit_breaker() + + app_settings = get_settings() + http_client = get_http_client(app_settings) + temp_cache = InMemoryCache(max_entries=100) + + temp_repo = JellyfinRepository(http_client=http_client, cache=temp_cache) + temp_repo.configure( + base_url=settings.jellyfin_url, + api_key=settings.api_key, + user_id=settings.user_id + ) + + success, message = await temp_repo.validate_connection() + + users = [] + if success: + jf_users = await temp_repo.fetch_users_direct() + users = [JellyfinUser(id=u.id, name=u.name) for u in jf_users] + + return JellyfinVerifyResult(success=success, message=message, users=users) + except Exception as e: + logger.exception(f"Failed to verify Jellyfin connection: {e}") + return JellyfinVerifyResult( + success=False, + message="Couldn't finish the connection test" + ) + + async def verify_listenbrainz(self, settings: ListenBrainzConnectionSettings) -> ListenBrainzVerifyResult: + try: + from repositories.listenbrainz_repository import ListenBrainzRepository + + ListenBrainzRepository.reset_circuit_breaker() + + app_settings = get_settings() + http_client = get_http_client(app_settings) + temp_cache = InMemoryCache(max_entries=100) + + temp_repo = ListenBrainzRepository(http_client=http_client, cache=temp_cache) + temp_repo.configure( + username=settings.username, + user_token=settings.user_token + ) + + if settings.user_token: + valid, message = await temp_repo.validate_token() + else: + valid, message = await temp_repo.validate_username(settings.username) + + return ListenBrainzVerifyResult(valid=valid, message=message) + except Exception as e: + logger.exception(f"Failed to verify ListenBrainz connection: {e}") + return ListenBrainzVerifyResult( + valid=False, + message="Couldn't finish the connection test" + ) + + async def clear_caches_for_preference_change(self) -> int: + total = 0 + total += await self._cache.clear_prefix(ARTIST_INFO_PREFIX) + total += await self._cache.clear_prefix(ALBUM_INFO_PREFIX) + for prefix in musicbrainz_prefixes(): + total += await self._cache.clear_prefix(prefix) + logger.info(f"Cleared {total} cache entries for preference change") + return total + + async def clear_home_cache(self) -> int: + total = 0 + for prefix in home_prefixes(): + total += await self._cache.clear_prefix(prefix) + total += await self._cache.clear_prefix(JELLYFIN_PREFIX) + for prefix in listenbrainz_prefixes(): + total += await self._cache.clear_prefix(prefix) + for prefix in lastfm_prefixes(): + total += await self._cache.clear_prefix(prefix) + logger.info(f"Cleared {total} home/discover/integration cache entries") + return total + + async def clear_local_files_cache(self) -> int: + cleared = await self._cache.clear_prefix(LOCAL_FILES_PREFIX) + logger.info(f"Cleared {cleared} local files cache entries") + return cleared + + async def clear_source_resolution_cache(self) -> int: + cleared = await self._cache.clear_prefix(SOURCE_RESOLUTION_PREFIX) + logger.info(f"Cleared {cleared} source-resolution cache entries") + return cleared + + # Lifecycle methods — one per integration settings change + + async def on_jellyfin_settings_changed(self) -> None: + """Full cache/state reset when Jellyfin settings change.""" + from repositories.jellyfin_repository import JellyfinRepository + from core.dependencies import ( + get_jellyfin_repository, get_jellyfin_playback_service, + get_jellyfin_library_service, get_home_service, + get_home_charts_service, get_mbid_store, + ) + JellyfinRepository.reset_circuit_breaker() + get_jellyfin_repository.cache_clear() + get_jellyfin_playback_service.cache_clear() + get_jellyfin_library_service.cache_clear() + get_home_service.cache_clear() + get_home_charts_service.cache_clear() + mbid_store = get_mbid_store() + await mbid_store.clear_jellyfin_mbid_index() + await self.clear_home_cache() + await self.clear_source_resolution_cache() + logger.info("Jellyfin settings change: all caches/singletons reset") + + async def on_navidrome_settings_changed(self, enabled: bool = False) -> None: + """Full cache/state reset when Navidrome settings change.""" + from repositories.navidrome_repository import NavidromeRepository + from core.dependencies import ( + get_navidrome_repository, get_navidrome_library_service, + get_navidrome_playback_service, get_home_service, + get_home_charts_service, get_mbid_store, + ) + NavidromeRepository.reset_circuit_breaker() + get_navidrome_repository.cache_clear() + get_navidrome_library_service.cache_clear() + get_navidrome_playback_service.cache_clear() + get_home_service.cache_clear() + get_home_charts_service.cache_clear() + mbid_store = get_mbid_store() + await mbid_store.clear_navidrome_mbid_indexes() + new_repo = get_navidrome_repository() + await new_repo.clear_cache() + await self.clear_home_cache() + await self.clear_source_resolution_cache() + if enabled: + import asyncio + from core.tasks import warm_navidrome_mbid_cache + from core.task_registry import TaskRegistry + registry = TaskRegistry.get_instance() + if not registry.is_running("navidrome-mbid-warmup"): + _nav_task = asyncio.create_task(warm_navidrome_mbid_cache()) + try: + registry.register("navidrome-mbid-warmup", _nav_task) + except RuntimeError: + pass + logger.info("Navidrome settings change: all caches/singletons reset") + + async def on_lastfm_settings_changed(self) -> None: + """Full cache/state reset when Last.fm settings change.""" + from repositories.lastfm_repository import LastFmRepository + from core.dependencies import ( + get_lastfm_repository, get_lastfm_auth_service, + clear_lastfm_dependent_caches, + ) + LastFmRepository.reset_circuit_breaker() + get_lastfm_repository.cache_clear() + get_lastfm_auth_service.cache_clear() + clear_lastfm_dependent_caches() + await self.clear_home_cache() + logger.info("Last.fm settings change: all caches/singletons reset") + + async def on_listenbrainz_settings_changed(self) -> None: + """Full cache/state reset when ListenBrainz settings change.""" + from repositories.listenbrainz_repository import ListenBrainzRepository + from core.dependencies import clear_listenbrainz_dependent_caches + ListenBrainzRepository.reset_circuit_breaker() + clear_listenbrainz_dependent_caches() + await self.clear_home_cache() + logger.info("ListenBrainz settings change: all caches/singletons reset") + + async def on_youtube_settings_changed(self) -> None: + """Reset YouTube singleton and clear home caches when settings change.""" + from core.dependencies import get_youtube_repo + get_youtube_repo.cache_clear() + await self.clear_home_cache() + logger.info("YouTube settings change: singleton reset, home caches cleared") + + async def on_coverart_settings_changed(self) -> None: + """Reset coverart singleton when settings change.""" + from core.dependencies import get_coverart_repository + get_coverart_repository.cache_clear() + logger.info("Coverart settings change: singleton reset") + + async def on_local_files_settings_changed(self) -> None: + """Full cache reset when local files settings change.""" + await self.clear_local_files_cache() + await self.clear_source_resolution_cache() + logger.info("Local files settings change: caches reset") + + async def on_lidarr_settings_changed(self) -> None: + """Full cache reset when Lidarr settings change.""" + from core.dependencies import get_library_db, get_home_service + from infrastructure.cache.cache_keys import LIDARR_PREFIX + + await self.clear_home_cache() + await self._cache.clear_prefix(LIDARR_PREFIX) + + library_db = get_library_db() + await library_db.clear() + + try: + home_service = get_home_service() + home_service.clear_genre_disk_cache() + except Exception: # noqa: BLE001 + logger.debug("Genre disk cache cleanup skipped (home service unavailable)") + + logger.info("Lidarr settings change: home, lidarr, library and genre caches reset") + + def _create_lidarr_repo(self) -> "LidarrRepository": + from repositories.lidarr import LidarrRepository + + app_settings = get_settings() + if not app_settings.lidarr_url or not app_settings.lidarr_api_key: + raise ExternalServiceError("Lidarr is not configured") + + http_client = get_http_client(app_settings) + temp_cache = InMemoryCache(max_entries=100) + return LidarrRepository( + settings=app_settings, + http_client=http_client, + cache=temp_cache, + ) + + @staticmethod + def _lidarr_profile_to_preferences(profile: dict) -> LidarrMetadataProfilePreferences: + primary = [ + item["albumType"]["name"].lower() + for item in profile.get("primaryAlbumTypes", []) + if item.get("allowed") + ] + secondary = [ + item["albumType"]["name"].lower() + for item in profile.get("secondaryAlbumTypes", []) + if item.get("allowed") + ] + statuses = [ + item["releaseStatus"]["name"].lower() + for item in profile.get("releaseStatuses", []) + if item.get("allowed") + ] + return LidarrMetadataProfilePreferences( + profile_id=profile["id"], + profile_name=profile.get("name", "Unknown"), + primary_types=primary, + secondary_types=secondary, + release_statuses=statuses, + ) + + @staticmethod + def _apply_preferences_to_profile( + profile: dict, preferences: UserPreferences + ) -> dict: + for item in profile.get("primaryAlbumTypes", []): + name = item["albumType"]["name"].lower() + item["allowed"] = name in preferences.primary_types + for item in profile.get("secondaryAlbumTypes", []): + name = item["albumType"]["name"].lower() + item["allowed"] = name in preferences.secondary_types + for item in profile.get("releaseStatuses", []): + name = item["releaseStatus"]["name"].lower() + item["allowed"] = name in preferences.release_statuses + return profile + + def _resolve_profile_id(self, profile_id: int | None) -> int: + if profile_id is not None: + return profile_id + connection = self._preferences_service.get_lidarr_connection() + return connection.metadata_profile_id + + async def list_lidarr_metadata_profiles( + self, + ) -> list[dict]: + repo = self._create_lidarr_repo() + try: + profiles = await repo.get_metadata_profiles() + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to list Lidarr metadata profiles: {e}") + raise ExternalServiceError( + f"Failed to list Lidarr metadata profiles: {e}" + ) + return [{"id": p["id"], "name": p["name"]} for p in profiles] + + async def get_lidarr_metadata_profile_preferences( + self, + profile_id: int | None = None, + ) -> LidarrMetadataProfilePreferences: + resolved_id = self._resolve_profile_id(profile_id) + + repo = self._create_lidarr_repo() + try: + profile = await repo.get_metadata_profile(resolved_id) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch Lidarr metadata profile {resolved_id}: {e}") + raise ExternalServiceError( + f"Failed to fetch Lidarr metadata profile: {e}" + ) + + return self._lidarr_profile_to_preferences(profile) + + async def update_lidarr_metadata_profile( + self, + preferences: UserPreferences, + profile_id: int | None = None, + ) -> LidarrMetadataProfilePreferences: + resolved_id = self._resolve_profile_id(profile_id) + + repo = self._create_lidarr_repo() + try: + profile = await repo.get_metadata_profile(resolved_id) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to fetch Lidarr metadata profile {resolved_id}: {e}") + raise ExternalServiceError( + f"Failed to fetch Lidarr metadata profile: {e}" + ) + + updated_profile = self._apply_preferences_to_profile(profile, preferences) + + validations = [ + ( + "primaryAlbumTypes", + "primary album type", + "e.g. Album", + ), + ( + "secondaryAlbumTypes", + "secondary album type", + "e.g. Studio", + ), + ( + "releaseStatuses", + "release status", + "e.g. Official", + ), + ] + for key, label, example in validations: + if not any(item.get("allowed") for item in updated_profile.get(key, [])): + raise ExternalServiceError( + f"Lidarr requires at least one {label} to be enabled. " + f"Please enable at least one ({example}) before syncing." + ) + + try: + result = await repo.update_metadata_profile(resolved_id, updated_profile) + except Exception as e: # noqa: BLE001 + logger.error(f"Failed to update Lidarr metadata profile {resolved_id}: {e}") + raise ExternalServiceError( + f"Failed to update Lidarr metadata profile: {e}" + ) + + logger.info(f"Updated Lidarr metadata profile '{result.get('name')}' (ID: {resolved_id})") + return self._lidarr_profile_to_preferences(result) + + # Verify methods — Navidrome, YouTube, Last.fm + + async def verify_navidrome( + self, settings: NavidromeConnectionSettings + ) -> NavidromeVerifyResult: + try: + from infrastructure.validators import validate_service_url + validate_service_url(settings.navidrome_url, label="Navidrome URL") + + from repositories.navidrome_repository import NavidromeRepository + + NavidromeRepository.reset_circuit_breaker() + + app_settings = get_settings() + http_client = get_http_client(app_settings) + temp_cache = InMemoryCache(max_entries=100) + + temp_repo = NavidromeRepository(http_client=http_client, cache=temp_cache) + + password = settings.password + if password == NAVIDROME_PASSWORD_MASK: + raw = self._preferences_service.get_navidrome_connection_raw() + password = raw.password + + temp_repo.configure( + url=settings.navidrome_url, + username=settings.username, + password=password, + ) + + ok = await temp_repo.ping() + if ok: + return NavidromeVerifyResult( + valid=True, message="Connected to Navidrome successfully" + ) + return NavidromeVerifyResult( + valid=False, + message="Navidrome didn't respond. Check the URL and credentials.", + ) + except Exception as e: + logger.exception("Failed to verify Navidrome connection: %s", e) + return NavidromeVerifyResult( + valid=False, + message="Couldn't finish the connection test", + ) + + async def verify_youtube( + self, settings: YouTubeConnectionSettings + ) -> YouTubeVerifyResult: + try: + from repositories.youtube import YouTubeRepository + + app_settings = get_settings() + http_client = get_http_client(app_settings) + temp_repo = YouTubeRepository( + http_client=http_client, + api_key=settings.api_key.strip(), + daily_quota_limit=settings.daily_quota_limit, + ) + valid, message = await temp_repo.verify_api_key(settings.api_key.strip()) + return YouTubeVerifyResult(valid=valid, message=message) + except Exception as e: + logger.exception("Failed to verify YouTube connection: %s", e) + return YouTubeVerifyResult( + valid=False, + message="Couldn't finish the connection test", + ) + + async def verify_lastfm( + self, settings: LastFmConnectionSettings + ) -> LastFmVerifyResult: + try: + from repositories.lastfm_repository import LastFmRepository + + app_settings = get_settings() + http_client = get_http_client(app_settings) + + current = self._preferences_service.get_lastfm_connection() + shared_secret = settings.shared_secret + if shared_secret.startswith(LASTFM_SECRET_MASK): + shared_secret = current.shared_secret + + session_key = settings.session_key + if session_key.startswith(LASTFM_SECRET_MASK): + session_key = current.session_key + + temp_repo = LastFmRepository( + http_client=http_client, + cache=InMemoryCache(), + api_key=settings.api_key, + shared_secret=shared_secret, + session_key=session_key, + ) + valid, message = await temp_repo.validate_api_key() + if not valid: + return LastFmVerifyResult(valid=False, message=message) + + if session_key: + session_valid, session_message = await temp_repo.validate_session() + if not session_valid: + return LastFmVerifyResult( + valid=False, + message=f"The API key looks good, but the saved session isn't valid: {session_message}", + ) + return LastFmVerifyResult(valid=True, message=session_message) + + return LastFmVerifyResult(valid=valid, message=message) + except Exception as e: + logger.exception("Failed to verify Last.fm connection: %s", e) + return LastFmVerifyResult( + valid=False, message="Couldn't finish the Last.fm connection test" + ) diff --git a/backend/services/status_service.py b/backend/services/status_service.py new file mode 100644 index 0000000..ad3eda5 --- /dev/null +++ b/backend/services/status_service.py @@ -0,0 +1,28 @@ +import logging +from repositories.protocols import LidarrRepositoryProtocol +from api.v1.schemas.common import StatusReport, ServiceStatus + +logger = logging.getLogger(__name__) + + +class StatusService: + def __init__(self, lidarr_repo: LidarrRepositoryProtocol): + self._lidarr_repo = lidarr_repo + + async def get_status(self) -> StatusReport: + lidarr_status = await self._lidarr_repo.get_status() + + services = { + "lidarr": lidarr_status + } + + overall_status = "ok" + if any(s.status == "error" for s in services.values()): + overall_status = "error" + elif any(s.status != "ok" for s in services.values()): + overall_status = "degraded" + + return StatusReport( + status=overall_status, + services=services + ) diff --git a/backend/services/weekly_exploration_service.py b/backend/services/weekly_exploration_service.py new file mode 100644 index 0000000..47d1f10 --- /dev/null +++ b/backend/services/weekly_exploration_service.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +import logging + +from api.v1.schemas.weekly_exploration import ( + WeeklyExplorationSection, + WeeklyExplorationTrack, +) +from infrastructure.cover_urls import release_cover_url, release_group_cover_url +from repositories.protocols import ( + ListenBrainzRepositoryProtocol, + MusicBrainzRepositoryProtocol, +) + +logger = logging.getLogger(__name__) + + +class WeeklyExplorationService: + def __init__( + self, + listenbrainz_repo: ListenBrainzRepositoryProtocol, + musicbrainz_repo: MusicBrainzRepositoryProtocol, + ) -> None: + self._lb_repo = listenbrainz_repo + self._mb_repo = musicbrainz_repo + + async def build_section(self, username: str) -> WeeklyExplorationSection | None: + try: + playlists = await self._lb_repo.get_recommendation_playlists(username) + if not playlists: + return None + + newest = next( + (p for p in playlists if p.get("source_patch") == "weekly-exploration"), + playlists[0], + ) + playlist_id = newest.get("playlist_id", "") + if not playlist_id: + return None + + playlist = await self._lb_repo.get_playlist_tracks(playlist_id) + if not playlist or not playlist.tracks: + return None + + unique_release_ids = list({ + track.caa_release_mbid for track in playlist.tracks if track.caa_release_mbid + }) + rg_results = await asyncio.gather( + *( + self._mb_repo.get_release_group_id_from_release(release_id) + for release_id in unique_release_ids + ), + return_exceptions=True, + ) + release_to_rg = { + release_id: release_group_id + for release_id, release_group_id in zip(unique_release_ids, rg_results) + if isinstance(release_group_id, str) and release_group_id + } + + tracks: list[WeeklyExplorationTrack] = [] + for track in playlist.tracks: + artist_mbid = track.artist_mbids[0] if track.artist_mbids else None + release_group_mbid = ( + release_to_rg.get(track.caa_release_mbid, "") if track.caa_release_mbid else None + ) + + cover_url: str | None = None + if release_group_mbid: + cover_url = release_group_cover_url(release_group_mbid, size=250) + elif track.caa_release_mbid: + cover_url = release_cover_url(track.caa_release_mbid, size=250) + + tracks.append(WeeklyExplorationTrack( + title=track.title, + artist_name=track.creator, + album_name=track.album, + recording_mbid=track.recording_mbid, + artist_mbid=artist_mbid, + release_group_mbid=release_group_mbid or None, + cover_url=cover_url, + duration_ms=track.duration_ms, + )) + + return WeeklyExplorationSection( + title=playlist.title, + playlist_date=playlist.date, + tracks=tracks, + source_url=newest.get("identifier", ""), + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to build weekly exploration: %s", exc) + return None diff --git a/backend/services/youtube_service.py b/backend/services/youtube_service.py new file mode 100644 index 0000000..3a5d2a7 --- /dev/null +++ b/backend/services/youtube_service.py @@ -0,0 +1,309 @@ +import logging +import re +import uuid +from datetime import datetime, timezone + +import msgspec + +from api.v1.schemas.discover import YouTubeQuotaResponse +from api.v1.schemas.youtube import YouTubeLink, YouTubeTrackLink, YouTubeTrackLinkFailure +from core.exceptions import ConfigurationError, ExternalServiceError, ResourceNotFoundError, ValidationError +from infrastructure.persistence import YouTubeStore +from infrastructure.serialization import to_jsonable +from repositories.protocols import YouTubeRepositoryProtocol + +logger = logging.getLogger(__name__) + +_VIDEO_ID_RE = re.compile( + r'(?:youtube\.com/watch\?.*v=|youtu\.be/|youtube\.com/embed/)([a-zA-Z0-9_-]{11})' +) +_RAW_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{11}$') + + +def extract_video_id(url: str) -> str | None: + match = _VIDEO_ID_RE.search(url) + if match: + return match.group(1) + if _RAW_ID_RE.match(url): + return url + return None + + +class YouTubeService: + + def __init__( + self, + youtube_repo: YouTubeRepositoryProtocol, + youtube_store: YouTubeStore, + ): + self._youtube_repo = youtube_repo + self._youtube_store = youtube_store + + async def generate_link( + self, + artist_name: str, + album_name: str, + album_id: str, + cover_url: str | None = None, + ) -> YouTubeLink: + if not self._youtube_repo.is_configured: + raise ConfigurationError("YouTube API is not configured") + + existing = await self._youtube_store.get_youtube_link(album_id) + if existing and existing.get("video_id"): + return YouTubeLink(**existing) + + if self._youtube_repo.quota_remaining <= 0: + raise ExternalServiceError("YouTube daily quota exceeded") + + video_id = await self._youtube_repo.search_video(artist_name, album_name) + if not video_id: + raise ResourceNotFoundError( + f"No YouTube video found for '{artist_name} - {album_name}'" + ) + + now = datetime.now(timezone.utc).isoformat() + embed_url = f"https://www.youtube.com/embed/{video_id}" + + await self._youtube_store.save_youtube_link( + album_id=album_id, + video_id=video_id, + album_name=album_name, + artist_name=artist_name, + embed_url=embed_url, + cover_url=cover_url, + created_at=now, + ) + + return YouTubeLink( + album_id=album_id, + video_id=video_id, + album_name=album_name, + artist_name=artist_name, + embed_url=embed_url, + cover_url=cover_url, + created_at=now, + ) + + async def get_link(self, album_id: str) -> YouTubeLink | None: + result = await self._youtube_store.get_youtube_link(album_id) + return YouTubeLink(**result) if result else None + + async def get_all_links(self) -> list[YouTubeLink]: + results = await self._youtube_store.get_all_youtube_links() + return [YouTubeLink(**row) for row in results] + + async def delete_link(self, album_id: str) -> None: + await self._youtube_store.delete_youtube_link(album_id) + + def get_quota_status(self) -> YouTubeQuotaResponse: + return self._youtube_repo.get_quota_status() + + async def generate_track_link( + self, + album_id: str, + album_name: str, + artist_name: str, + track_name: str, + track_number: int, + disc_number: int = 1, + cover_url: str | None = None, + ) -> YouTubeTrackLink: + if not self._youtube_repo.is_configured: + raise ConfigurationError("YouTube API is not configured") + + if self._youtube_repo.quota_remaining <= 0: + raise ExternalServiceError("YouTube daily quota exceeded") + + video_id = await self._youtube_repo.search_track(artist_name, track_name) + if not video_id: + raise ResourceNotFoundError( + f"No YouTube video found for '{track_name} - {artist_name}'" + ) + + now = datetime.now(timezone.utc).isoformat() + embed_url = f"https://www.youtube.com/embed/{video_id}" + + await self._youtube_store.save_youtube_track_link( + album_id=album_id, + album_name=album_name, + track_number=track_number, + disc_number=disc_number, + track_name=track_name, + video_id=video_id, + artist_name=artist_name, + embed_url=embed_url, + created_at=now, + ) + + await self._youtube_store.ensure_youtube_album_entry( + album_id=album_id, + album_name=album_name, + artist_name=artist_name, + cover_url=cover_url, + created_at=now, + ) + + return YouTubeTrackLink( + album_id=album_id, + track_number=track_number, + disc_number=disc_number, + track_name=track_name, + video_id=video_id, + artist_name=artist_name, + embed_url=embed_url, + created_at=now, + ) + + async def generate_track_links_batch( + self, + album_id: str, + album_name: str, + artist_name: str, + tracks: list[dict], + cover_url: str | None = None, + ) -> tuple[list[YouTubeTrackLink], list[YouTubeTrackLinkFailure]]: + if not self._youtube_repo.is_configured: + raise ConfigurationError("YouTube API is not configured") + + generated: list[YouTubeTrackLink] = [] + failed: list[YouTubeTrackLinkFailure] = [] + batch_to_save: list[dict] = [] + + for track in tracks: + if self._youtube_repo.quota_remaining <= 0: + failed.append( + YouTubeTrackLinkFailure( + track_number=track["track_number"], + disc_number=track.get("disc_number", 1), + track_name=track["track_name"], + reason="Quota exceeded", + ) + ) + continue + + try: + video_id = await self._youtube_repo.search_track(artist_name, track["track_name"]) + if not video_id: + failed.append( + YouTubeTrackLinkFailure( + track_number=track["track_number"], + disc_number=track.get("disc_number", 1), + track_name=track["track_name"], + reason="No video found", + ) + ) + continue + + now = datetime.now(timezone.utc).isoformat() + embed_url = f"https://www.youtube.com/embed/{video_id}" + + link = YouTubeTrackLink( + album_id=album_id, + track_number=track["track_number"], + disc_number=track.get("disc_number", 1), + track_name=track["track_name"], + video_id=video_id, + artist_name=artist_name, + embed_url=embed_url, + created_at=now, + ) + generated.append(link) + batch_to_save.append({**to_jsonable(link), "album_name": album_name}) + except Exception as e: # noqa: BLE001 + failed.append( + YouTubeTrackLinkFailure( + track_number=track["track_number"], + disc_number=track.get("disc_number", 1), + track_name=track["track_name"], + reason=str(e), + ) + ) + + if batch_to_save: + await self._youtube_store.save_youtube_track_links_batch(album_id, batch_to_save) + + if generated: + await self._youtube_store.ensure_youtube_album_entry( + album_id=album_id, + album_name=album_name, + artist_name=artist_name, + cover_url=cover_url, + created_at=datetime.now(timezone.utc).isoformat(), + ) + + return generated, failed + + async def get_track_links(self, album_id: str) -> list[YouTubeTrackLink]: + results = await self._youtube_store.get_youtube_track_links(album_id) + return [YouTubeTrackLink(**row) for row in results] + + async def delete_track_link(self, album_id: str, disc_number: int, track_number: int) -> None: + await self._youtube_store.delete_youtube_track_link(album_id, disc_number, track_number) + await self._youtube_store.update_youtube_link_track_count(album_id) + + async def _save_and_return_link(self, **kwargs: object) -> YouTubeLink: + await self._youtube_store.save_youtube_link(**kwargs) + return YouTubeLink(**kwargs) + + async def save_manual_link( + self, + album_name: str, + artist_name: str, + youtube_url: str, + cover_url: str | None = None, + album_id: str | None = None, + ) -> YouTubeLink: + video_id = extract_video_id(youtube_url) + if not video_id: + raise ValidationError("Invalid YouTube URL — could not extract video ID") + + if not album_id: + album_id = f"manual-{uuid.uuid4().hex[:12]}" + + return await self._save_and_return_link( + album_id=album_id, + video_id=video_id, + album_name=album_name, + artist_name=artist_name, + embed_url=f"https://www.youtube.com/embed/{video_id}", + cover_url=cover_url, + created_at=datetime.now(timezone.utc).isoformat(), + is_manual=True, + ) + + async def update_link( + self, + album_id: str, + youtube_url: str | None = None, + album_name: str | None = None, + artist_name: str | None = None, + cover_url: str | None | msgspec.UnsetType = msgspec.UNSET, + ) -> YouTubeLink: + existing = await self._youtube_store.get_youtube_link(album_id) + if not existing: + raise ResourceNotFoundError(f"No YouTube link found for album '{album_id}'") + + video_id = existing["video_id"] + embed_url = existing["embed_url"] + if youtube_url: + new_vid = extract_video_id(youtube_url) + if not new_vid: + raise ValidationError("Invalid YouTube URL — could not extract video ID") + video_id = new_vid + embed_url = f"https://www.youtube.com/embed/{new_vid}" + + final_album_name = album_name or existing["album_name"] + final_artist_name = artist_name or existing["artist_name"] + final_cover_url = existing.get("cover_url") if cover_url is msgspec.UNSET else cover_url + + return await self._save_and_return_link( + album_id=album_id, + video_id=video_id, + album_name=final_album_name, + artist_name=final_artist_name, + embed_url=embed_url, + cover_url=final_cover_url, + created_at=existing["created_at"], + is_manual=bool(existing.get("is_manual", 0)), + ) diff --git a/backend/static_server.py b/backend/static_server.py new file mode 100644 index 0000000..928a7dd --- /dev/null +++ b/backend/static_server.py @@ -0,0 +1,65 @@ +from pathlib import Path +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + + +def mount_frontend(app: FastAPI): + backend_static = Path(__file__).parent / "static" + frontend_root = Path(__file__).resolve().parents[1] / "frontend" + build_candidates = [backend_static, frontend_root / "build"] + + def first_existing_build() -> Path: + for candidate in build_candidates: + if (candidate / "index.html").exists(): + return candidate + return backend_static + + build_dir = first_existing_build() + index_html = build_dir / "index.html" + asset_dirs = [build_dir, frontend_root / "static"] + + def resolve_asset(filename: str) -> Path | None: + for directory in asset_dirs: + candidate = directory / filename + if candidate.exists(): + return candidate + return None + + if (build_dir / "_app").exists(): + app.mount("/_app", StaticFiles(directory=build_dir / "_app", html=False), name="_app") + + if (img_dir := build_dir / "img").exists(): + app.mount("/img", StaticFiles(directory=img_dir, html=False), name="img") + + @app.get("/robots.txt") + async def serve_robots(): + if robots := resolve_asset("robots.txt"): + return FileResponse(robots, media_type="text/plain", headers={"Cache-Control": "public, max-age=86400"}) + raise HTTPException(status_code=404, detail="Not found") + + @app.get("/logo.png") + async def serve_logo(): + if logo := resolve_asset("logo.png"): + return FileResponse(logo) + raise HTTPException(status_code=404, detail="Not found") + + @app.get("/logo_wide.png") + async def serve_logo_wide(): + if logo := resolve_asset("logo_wide.png"): + return FileResponse(logo) + raise HTTPException(status_code=404, detail="Not found") + + @app.get("/") + async def serve_root(): + if index_html.exists(): + return FileResponse(index_html) + raise HTTPException(status_code=404, detail="Frontend not built yet") + + @app.get("/{full_path:path}") + async def serve_spa_routes(full_path: str): + if full_path.startswith("api"): + raise HTTPException(status_code=404, detail="API route not found") + if index_html.exists(): + return FileResponse(index_html) + raise HTTPException(status_code=404, detail="Frontend not built yet") diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0e116e2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,4 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/backend/tests/helpers.py b/backend/tests/helpers.py new file mode 100644 index 0000000..fac78bc --- /dev/null +++ b/backend/tests/helpers.py @@ -0,0 +1,87 @@ +"""Shared test helpers for observability / log field assertions.""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.testclient import TestClient +from starlette.exceptions import HTTPException as StarletteHTTPException + +from core.exception_handlers import ( + circuit_open_error_handler, + client_disconnected_handler, + configuration_error_handler, + external_service_error_handler, + general_exception_handler, + http_exception_handler, + request_validation_error_handler, + resource_not_found_handler, + source_resolution_error_handler, + starlette_http_exception_handler, + validation_error_handler, +) +from core.exceptions import ( + ClientDisconnectedError, + ConfigurationError, + ExternalServiceError, + ResourceNotFoundError, + SourceResolutionError, + ValidationError, +) +from infrastructure.resilience.retry import CircuitOpenError + + +def add_production_exception_handlers(app: FastAPI) -> FastAPI: + app.add_exception_handler(ClientDisconnectedError, client_disconnected_handler) + app.add_exception_handler(ResourceNotFoundError, resource_not_found_handler) + app.add_exception_handler(ExternalServiceError, external_service_error_handler) + app.add_exception_handler(CircuitOpenError, circuit_open_error_handler) + app.add_exception_handler(ValidationError, validation_error_handler) + app.add_exception_handler(ConfigurationError, configuration_error_handler) + app.add_exception_handler(SourceResolutionError, source_resolution_error_handler) + app.add_exception_handler(HTTPException, http_exception_handler) + app.add_exception_handler(StarletteHTTPException, starlette_http_exception_handler) + app.add_exception_handler(RequestValidationError, request_validation_error_handler) + app.add_exception_handler(Exception, general_exception_handler) + return app + + +def build_test_client(app: FastAPI) -> TestClient: + add_production_exception_handlers(app) + return TestClient(app, raise_server_exceptions=False) + + +def assert_log_fields( + records: list[logging.LogRecord], + prefix: str, + required_fields: list[str], + *, + min_count: int = 1, +) -> list[str]: + """Assert that log records matching *prefix* contain all *required_fields*. + + Returns the matching messages for further inspection. + + Parameters + ---------- + records: + ``caplog.records`` or equivalent list of ``LogRecord``. + prefix: + The log message prefix to filter on (e.g. ``"audiodb.cache"``). + required_fields: + Key names that must appear as ``key=`` in every matching message. + min_count: + Minimum number of matching records expected (default 1). + """ + matching = [r.message for r in records if r.message.startswith(prefix)] + assert len(matching) >= min_count, ( + f"Expected >= {min_count} log(s) starting with '{prefix}', found {len(matching)}" + ) + for msg in matching: + for field in required_fields: + assert f"{field}=" in msg, ( + f"Field '{field}=' missing in log: {msg}" + ) + return matching diff --git a/backend/tests/infrastructure/test_audiodb_url_validation.py b/backend/tests/infrastructure/test_audiodb_url_validation.py new file mode 100644 index 0000000..aecaa67 --- /dev/null +++ b/backend/tests/infrastructure/test_audiodb_url_validation.py @@ -0,0 +1,64 @@ +"""Tests for validate_audiodb_image_url SSRF protection.""" +import pytest +from infrastructure.validators import validate_audiodb_image_url + + +class TestValidateAudiodbImageUrl: + + @pytest.mark.parametrize("url", [ + "https://www.theaudiodb.com/images/media/thumb.jpg", + "https://theaudiodb.com/images/media/artist/thumb/coldplay.jpg", + "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg", + "https://r2.theaudiodb.com/images/artist/fanart/coldplay1.jpg", + ]) + def test_valid_audiodb_urls(self, url: str) -> None: + assert validate_audiodb_image_url(url) is True + + @pytest.mark.parametrize("url", [ + "http://www.theaudiodb.com/images/media/thumb.jpg", + "http://r2.theaudiodb.com/images/album/thumb.jpg", + ]) + def test_rejects_http_scheme(self, url: str) -> None: + assert validate_audiodb_image_url(url) is False + + @pytest.mark.parametrize("url", [ + "ftp://r2.theaudiodb.com/file.jpg", + "file:///etc/passwd", + "data:text/html,", + "javascript:alert(1)", + ]) + def test_rejects_non_https_schemes(self, url: str) -> None: + assert validate_audiodb_image_url(url) is False + + @pytest.mark.parametrize("url", [ + "https://evil.com/images/media/thumb.jpg", + "https://theaudiodb.com.evil.com/exploit.jpg", + "https://attacker.theaudiodb.com/images/thumb.jpg", + "https://notaudiodb.com/images/thumb.jpg", + "https://example.com/redirect?url=https://r2.theaudiodb.com/img.jpg", + ]) + def test_rejects_unknown_hosts(self, url: str) -> None: + assert validate_audiodb_image_url(url) is False + + @pytest.mark.parametrize("url", [ + "https://127.0.0.1/images/thumb.jpg", + "https://10.0.0.1/images/thumb.jpg", + "https://192.168.1.1/images/thumb.jpg", + "https://[::1]/images/thumb.jpg", + "https://169.254.169.254/latest/meta-data/", + ]) + def test_rejects_private_and_loopback_ips(self, url: str) -> None: + assert validate_audiodb_image_url(url) is False + + @pytest.mark.parametrize("url", [ + "", + None, + " ", + "not-a-url", + "://missing-scheme.com", + ]) + def test_rejects_invalid_inputs(self, url) -> None: + assert validate_audiodb_image_url(url) is False + + def test_rejects_url_without_host(self) -> None: + assert validate_audiodb_image_url("https:///path/only") is False diff --git a/backend/tests/infrastructure/test_cache_layer_followups.py b/backend/tests/infrastructure/test_cache_layer_followups.py new file mode 100644 index 0000000..efafa6b --- /dev/null +++ b/backend/tests/infrastructure/test_cache_layer_followups.py @@ -0,0 +1,303 @@ +import json +import sqlite3 +import threading +import time + +import pytest + +from infrastructure.cache.disk_cache import DiskMetadataCache +from infrastructure.persistence.genre_index import GenreIndex +from infrastructure.persistence.library_db import LibraryDB +from infrastructure.persistence.youtube_store import YouTubeStore + + +def _make_stores(db_path): + lock = threading.Lock() + lib = LibraryDB(db_path=db_path, write_lock=lock) + genre = GenreIndex(db_path=db_path, write_lock=lock) + yt = YouTubeStore(db_path=db_path, write_lock=lock) + # All stores must be initialized so cross-domain DELETEs in save_library/clear succeed + from infrastructure.persistence.mbid_store import MBIDStore + from infrastructure.persistence.sync_state_store import SyncStateStore + + SyncStateStore(db_path=db_path, write_lock=lock) + MBIDStore(db_path=db_path, write_lock=lock) + return lib, genre, yt + + +@pytest.mark.asyncio +async def test_library_cache_genre_queries_use_normalized_lookup(tmp_path): + lib, genre, _ = _make_stores(tmp_path / "library.db") + + await lib.save_library( + artists=[ + {"mbid": "artist-1", "name": "Artist One", "album_count": 1, "date_added": 10}, + {"mbid": "artist-2", "name": "Artist Two", "album_count": 1, "date_added": 20}, + ], + albums=[ + { + "mbid": "album-1", + "artist_mbid": "artist-1", + "artist_name": "Artist One", + "title": "First Album", + "date_added": 100, + "monitored": True, + }, + { + "mbid": "album-2", + "artist_mbid": "artist-2", + "artist_name": "Artist Two", + "title": "Second Album", + "date_added": 200, + "monitored": True, + }, + ], + ) + await genre.save_artist_genres( + { + "artist-1": [" Rock ", "Alternative", "rock"], + "artist-2": ["Jazz", "rock"], + } + ) + + artists = await genre.get_artists_by_genre("ROCK", limit=1) + albums = await genre.get_albums_by_genre(" rock ", limit=2) + + assert [artist["mbid"] for artist in artists] == ["artist-2"] + assert [album["mbid"] for album in albums] == ["album-2", "album-1"] + + +@pytest.mark.asyncio +async def test_library_cache_backfills_genre_lookup_from_existing_json_rows(tmp_path): + db_path = tmp_path / "library.db" + lib, genre, _ = _make_stores(db_path) + await lib.save_library( + artists=[{"mbid": "artist-1", "name": "Artist One", "album_count": 1, "date_added": 10}], + albums=[], + ) + + conn = sqlite3.connect(db_path) + try: + conn.execute("DELETE FROM artist_genre_lookup") + conn.execute( + "INSERT OR REPLACE INTO artist_genres (artist_mbid_lower, artist_mbid, genres_json) VALUES (?, ?, ?)", + ("artist-1", "artist-1", json.dumps(["post-rock"])), + ) + conn.commit() + finally: + conn.close() + + _, genre2, _ = _make_stores(db_path) + artists = await genre2.get_artists_by_genre("POST-ROCK") + + assert [artist["mbid"] for artist in artists] == ["artist-1"] + + +@pytest.mark.asyncio +async def test_cleanup_expired_covers_removes_expired_cover_payload(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + cover_dir = tmp_path / "recent" / "covers" + cover_file = cover_dir / "cover.bin" + meta_file = cover_dir / "cover.meta.json" + + cover_file.write_bytes(b"image-bytes") + meta_file.write_text(json.dumps({"expires_at": time.time() - 60, "last_accessed": 1})) + + removed = await cache.cleanup_expired_covers() + + assert removed == 1 + assert not cover_file.exists() + assert not meta_file.exists() + + +@pytest.mark.asyncio +async def test_enforce_cover_size_limits_evicts_oldest_recent_cover(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + cache.recent_covers_max_size_bytes = 6 + + cover_dir = tmp_path / "recent" / "covers" + old_cover = cover_dir / "old.bin" + new_cover = cover_dir / "new.bin" + old_meta = cover_dir / "old.meta.json" + new_meta = cover_dir / "new.meta.json" + + old_cover.write_bytes(b"1234") + new_cover.write_bytes(b"5678") + old_meta.write_text(json.dumps({"last_accessed": 1})) + new_meta.write_text(json.dumps({"last_accessed": 2})) + + freed = await cache.enforce_cover_size_limits() + + assert freed == 4 + assert not old_cover.exists() + assert not old_meta.exists() + assert new_cover.exists() + assert new_meta.exists() + + +@pytest.mark.asyncio +async def test_library_cache_keeps_youtube_track_links_distinct_per_disc(tmp_path): + _, _, yt = _make_stores(tmp_path / "library.db") + + await yt.save_youtube_track_links_batch( + "album-1", + [ + { + "track_number": 1, + "disc_number": 1, + "album_name": "Album", + "track_name": "Disc One Track One", + "video_id": "video-1", + "artist_name": "Artist", + "embed_url": "https://example.com/1", + "created_at": "2024-01-01T00:00:00Z", + }, + { + "track_number": 1, + "disc_number": 2, + "album_name": "Album", + "track_name": "Disc Two Track One", + "video_id": "video-2", + "artist_name": "Artist", + "embed_url": "https://example.com/2", + "created_at": "2024-01-01T00:00:00Z", + }, + ], + ) + + links = await yt.get_youtube_track_links("album-1") + assert [(link["disc_number"], link["track_number"], link["video_id"]) for link in links] == [ + (1, 1, "video-1"), + (2, 1, "video-2"), + ] + + await yt.delete_youtube_track_link("album-1", 2, 1) + + remaining = await yt.get_youtube_track_links("album-1") + assert [(link["disc_number"], link["track_number"], link["video_id"]) for link in remaining] == [ + (1, 1, "video-1") + ] + + +@pytest.mark.asyncio +async def test_library_cache_migrates_legacy_youtube_track_links_with_default_disc_number(tmp_path): + db_path = tmp_path / "library.db" + conn = sqlite3.connect(db_path) + try: + conn.execute( + """ + CREATE TABLE youtube_track_links ( + album_id TEXT NOT NULL, + track_number INTEGER NOT NULL, + album_name TEXT NOT NULL, + track_name TEXT NOT NULL, + video_id TEXT NOT NULL, + artist_name TEXT NOT NULL, + embed_url TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (album_id, track_number) + ) + """ + ) + conn.execute( + """ + INSERT INTO youtube_track_links ( + album_id, track_number, album_name, track_name, + video_id, artist_name, embed_url, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "album-legacy", + 5, + "Legacy Album", + "Legacy Track", + "legacy-video", + "Artist", + "https://example.com/legacy", + "2024-01-01T00:00:00Z", + ), + ) + conn.commit() + finally: + conn.close() + + _, _, yt = _make_stores(db_path) + links = await yt.get_youtube_track_links("album-legacy") + + assert len(links) == 1 + assert links[0]["disc_number"] == 1 + assert links[0]["track_number"] == 5 + + +@pytest.mark.asyncio +async def test_library_cache_youtube_track_links_uniqueness_allows_same_track_different_disc( + tmp_path, +): + """Verify the new (album_id, disc_number, track_number) PK allows same track number across discs.""" + _, _, yt = _make_stores(tmp_path / "library.db") + + await yt.save_youtube_track_link( + album_id="album-uniq", + track_number=1, + disc_number=1, + album_name="Test Album", + track_name="Track One Disc One", + video_id="vid-d1t1", + artist_name="Artist", + embed_url="https://example.com/d1t1", + created_at="2024-01-01T00:00:00Z", + ) + await yt.save_youtube_track_link( + album_id="album-uniq", + track_number=1, + disc_number=2, + album_name="Test Album", + track_name="Track One Disc Two", + video_id="vid-d2t1", + artist_name="Artist", + embed_url="https://example.com/d2t1", + created_at="2024-01-01T00:00:00Z", + ) + await yt.save_youtube_track_link( + album_id="album-uniq", + track_number=1, + disc_number=1, + album_name="Test Album", + track_name="Updated Track One", + video_id="vid-d1t1-updated", + artist_name="Artist", + embed_url="https://example.com/d1t1-v2", + created_at="2024-01-02T00:00:00Z", + ) + + links = await yt.get_youtube_track_links("album-uniq") + assert len(links) == 2 + d1 = next(link for link in links if link["disc_number"] == 1) + d2 = next(link for link in links if link["disc_number"] == 2) + assert d1["video_id"] == "vid-d1t1-updated" + assert d1["track_name"] == "Updated Track One" + assert d2["video_id"] == "vid-d2t1" + + +@pytest.mark.asyncio +async def test_library_cache_save_single_youtube_track_link_with_disc_number(tmp_path): + """Verify save_youtube_track_link (single-row path) correctly stores disc_number.""" + _, _, yt = _make_stores(tmp_path / "library.db") + + await yt.save_youtube_track_link( + album_id="album-single", + track_number=3, + disc_number=2, + album_name="Single Test", + track_name="Track Three Disc Two", + video_id="vid-single", + artist_name="Artist", + embed_url="https://example.com/single", + created_at="2024-06-15T00:00:00Z", + ) + + links = await yt.get_youtube_track_links("album-single") + assert len(links) == 1 + assert links[0]["disc_number"] == 2 + assert links[0]["track_number"] == 3 + assert links[0]["video_id"] == "vid-single" diff --git a/backend/tests/infrastructure/test_circuit_breaker_sync.py b/backend/tests/infrastructure/test_circuit_breaker_sync.py new file mode 100644 index 0000000..7850df4 --- /dev/null +++ b/backend/tests/infrastructure/test_circuit_breaker_sync.py @@ -0,0 +1,118 @@ +import asyncio +import time + +import pytest + +from infrastructure.resilience.retry import CircuitBreaker, CircuitState + + +@pytest.mark.asyncio +async def test_concurrent_arecord_failure_does_not_overcount(): + cb = CircuitBreaker(failure_threshold=5, name="test-overcount") + + async def fail_once(): + await cb.arecord_failure() + + await asyncio.gather(*[fail_once() for _ in range(10)]) + + assert cb.failure_count <= 10 + assert cb.state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_concurrent_arecord_success_transitions_half_open_to_closed(): + cb = CircuitBreaker(failure_threshold=3, success_threshold=2, name="test-success-transition") + + for _ in range(3): + cb.record_failure() + assert cb.state == CircuitState.OPEN + + cb.state = CircuitState.HALF_OPEN + cb.success_count = 0 + + async def succeed_once(): + await cb.arecord_success() + + await asyncio.gather(*[succeed_once() for _ in range(10)]) + + assert cb.state == CircuitState.CLOSED + assert cb.failure_count == 0 + assert cb.success_count == 0 + + +@pytest.mark.asyncio +async def test_atry_transition_open_to_half_open(): + cb = CircuitBreaker(failure_threshold=3, timeout=0.0, name="test-transition") + + for _ in range(3): + cb.record_failure() + assert cb.state == CircuitState.OPEN + + await asyncio.sleep(0.01) + await cb.atry_transition() + + assert cb.state == CircuitState.HALF_OPEN + assert cb.success_count == 0 + + +@pytest.mark.asyncio +async def test_atry_transition_noop_when_not_open(): + cb = CircuitBreaker(name="test-noop") + assert cb.state == CircuitState.CLOSED + + await cb.atry_transition() + assert cb.state == CircuitState.CLOSED + + +@pytest.mark.asyncio +async def test_atry_transition_noop_when_timeout_not_elapsed(): + cb = CircuitBreaker(failure_threshold=3, timeout=60.0, name="test-timeout-not-elapsed") + + for _ in range(3): + cb.record_failure() + assert cb.state == CircuitState.OPEN + + await cb.atry_transition() + assert cb.state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_sync_methods_still_work_without_await(): + cb = CircuitBreaker(failure_threshold=3, success_threshold=1, name="test-sync-compat") + + cb.record_failure() + cb.record_failure() + assert cb.state == CircuitState.CLOSED + assert cb.failure_count == 2 + + cb.record_failure() + assert cb.state == CircuitState.OPEN + + cb.reset() + assert cb.state == CircuitState.CLOSED + assert cb.failure_count == 0 + + cb.record_success() + assert cb.failure_count == 0 + + +@pytest.mark.asyncio +async def test_concurrent_atry_transition_only_transitions_once(): + cb = CircuitBreaker(failure_threshold=3, timeout=0.0, name="test-double-transition") + + for _ in range(3): + cb.record_failure() + assert cb.state == CircuitState.OPEN + + await asyncio.sleep(0.01) + + transition_states = [] + + async def try_transition(): + await cb.atry_transition() + transition_states.append(cb.state) + + await asyncio.gather(*[try_transition() for _ in range(5)]) + + assert cb.state == CircuitState.HALF_OPEN + assert all(s == CircuitState.HALF_OPEN for s in transition_states) diff --git a/backend/tests/infrastructure/test_degradation.py b/backend/tests/infrastructure/test_degradation.py new file mode 100644 index 0000000..1cfccb8 --- /dev/null +++ b/backend/tests/infrastructure/test_degradation.py @@ -0,0 +1,126 @@ +"""Tests for DegradationContext and contextvar lifecycle.""" + +import asyncio + +import pytest + +from infrastructure.degradation import ( + DegradationContext, + clear_degradation_context, + get_degradation_context, + init_degradation_context, + try_get_degradation_context, +) +from infrastructure.integration_result import IntegrationResult + + + + +class TestDegradationContext: + def test_empty_context(self): + ctx = DegradationContext() + assert ctx.summary() == {} + assert ctx.has_degradation() is False + assert ctx.degraded_summary() == {} + + def test_record_ok(self): + ctx = DegradationContext() + ctx.record(IntegrationResult.ok(data=[1], source="musicbrainz")) + assert ctx.summary() == {"musicbrainz": "ok"} + assert ctx.has_degradation() is False + + def test_record_error(self): + ctx = DegradationContext() + ctx.record(IntegrationResult.error(source="jellyfin", msg="timeout")) + assert ctx.summary() == {"jellyfin": "error"} + assert ctx.has_degradation() is True + assert ctx.degraded_summary() == {"jellyfin": "error"} + + def test_record_degraded(self): + ctx = DegradationContext() + ctx.record( + IntegrationResult.degraded(data=[], source="audiodb", msg="rate limit") + ) + assert ctx.summary() == {"audiodb": "degraded"} + assert ctx.has_degradation() is True + + def test_worst_status_wins(self): + ctx = DegradationContext() + ctx.record(IntegrationResult.ok(data=[], source="musicbrainz")) + ctx.record(IntegrationResult.degraded(data=[], source="musicbrainz", msg="slow")) + assert ctx.summary() == {"musicbrainz": "degraded"} + + def test_error_beats_degraded(self): + ctx = DegradationContext() + ctx.record( + IntegrationResult.degraded(data=[], source="musicbrainz", msg="slow") + ) + ctx.record(IntegrationResult.error(source="musicbrainz", msg="503")) + assert ctx.summary() == {"musicbrainz": "error"} + + def test_cannot_downgrade(self): + ctx = DegradationContext() + ctx.record(IntegrationResult.error(source="jellyfin", msg="down")) + ctx.record(IntegrationResult.ok(data=[1], source="jellyfin")) + assert ctx.summary() == {"jellyfin": "error"} + + def test_multiple_sources(self): + ctx = DegradationContext() + ctx.record(IntegrationResult.ok(data=[], source="musicbrainz")) + ctx.record(IntegrationResult.error(source="jellyfin", msg="down")) + ctx.record( + IntegrationResult.degraded(data={}, source="audiodb", msg="slow") + ) + assert ctx.summary() == { + "musicbrainz": "ok", + "jellyfin": "error", + "audiodb": "degraded", + } + assert ctx.degraded_summary() == { + "jellyfin": "error", + "audiodb": "degraded", + } + + + + +class TestContextVarLifecycle: + def test_no_context_raises(self): + clear_degradation_context() + with pytest.raises(RuntimeError, match="outside a request scope"): + get_degradation_context() + + def test_try_get_returns_none_outside(self): + clear_degradation_context() + assert try_get_degradation_context() is None + + def test_init_and_get(self): + ctx = init_degradation_context() + assert get_degradation_context() is ctx + clear_degradation_context() + + def test_clear_removes_context(self): + init_degradation_context() + clear_degradation_context() + assert try_get_degradation_context() is None + + @pytest.mark.asyncio + async def test_isolated_across_tasks(self): + """Context in one asyncio task must not leak into another.""" + results: dict[str, bool] = {} + + async def task_a(): + init_degradation_context() + ctx = get_degradation_context() + ctx.record(IntegrationResult.error(source="a", msg="fail")) + await asyncio.sleep(0.01) + results["a_has_degradation"] = get_degradation_context().has_degradation() + clear_degradation_context() + + async def task_b(): + await asyncio.sleep(0.005) + results["b_is_none"] = try_get_degradation_context() is None + + await asyncio.gather(task_a(), task_b()) + assert results["a_has_degradation"] is True + assert results["b_is_none"] is True diff --git a/backend/tests/infrastructure/test_disconnect.py b/backend/tests/infrastructure/test_disconnect.py new file mode 100644 index 0000000..27cd7af --- /dev/null +++ b/backend/tests/infrastructure/test_disconnect.py @@ -0,0 +1,62 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock +from core.exceptions import ClientDisconnectedError +from infrastructure.http.disconnect import check_disconnected +from infrastructure.http.deduplication import RequestDeduplicator + + +@pytest.mark.anyio +async def test_check_disconnected_raises_when_disconnected(): + is_disconnected = AsyncMock(return_value=True) + with pytest.raises(ClientDisconnectedError): + await check_disconnected(is_disconnected) + assert is_disconnected.await_count == 1 + + +@pytest.mark.anyio +async def test_check_disconnected_noop_when_connected(): + is_disconnected = AsyncMock(return_value=False) + await check_disconnected(is_disconnected) + assert is_disconnected.await_count == 1 + + +@pytest.mark.anyio +async def test_check_disconnected_noop_when_none(): + await check_disconnected(None) + + +@pytest.mark.anyio +async def test_dedup_leader_disconnect_follower_retries_as_leader(): + dedup = RequestDeduplicator() + follower_registered = asyncio.Event() + leader_error = None + expected_result = ("image-bytes", "image/png", "source") + + async def leader_coro(): + await follower_registered.wait() + raise ClientDisconnectedError("leader disconnected") + + async def run_leader(): + nonlocal leader_error + try: + await dedup.dedupe("key1", leader_coro) + except ClientDisconnectedError as e: + leader_error = e + + async def follower_coro(): + return expected_result + + async def run_follower(): + await asyncio.sleep(0) + follower_registered.set() + return await dedup.dedupe("key1", follower_coro) + + leader_task = asyncio.create_task(run_leader()) + await asyncio.sleep(0) + follower_task = asyncio.create_task(run_follower()) + + await asyncio.gather(leader_task, follower_task) + + assert isinstance(leader_error, ClientDisconnectedError) + assert follower_task.result() == expected_result diff --git a/backend/tests/infrastructure/test_disk_cache_periodic.py b/backend/tests/infrastructure/test_disk_cache_periodic.py new file mode 100644 index 0000000..f655808 --- /dev/null +++ b/backend/tests/infrastructure/test_disk_cache_periodic.py @@ -0,0 +1,76 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from core.tasks import cleanup_disk_cache_periodically + + +@pytest.mark.asyncio +async def test_periodic_cleanup_calls_both_caches(): + disk_cache = AsyncMock() + cover_disk_cache = AsyncMock() + + iteration_count = 0 + + original_cleanup = cleanup_disk_cache_periodically + + async def run_one_iteration(): + nonlocal iteration_count + task = asyncio.create_task( + original_cleanup(disk_cache, interval=0, cover_disk_cache=cover_disk_cache) + ) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + await run_one_iteration() + + disk_cache.cleanup_expired_recent.assert_called() + disk_cache.enforce_recent_size_limits.assert_called() + disk_cache.cleanup_expired_covers.assert_called() + disk_cache.enforce_cover_size_limits.assert_called() + cover_disk_cache.enforce_size_limit.assert_called_with(force=True) + + +@pytest.mark.asyncio +async def test_periodic_cleanup_works_without_cover_cache(): + disk_cache = AsyncMock() + + task = asyncio.create_task( + cleanup_disk_cache_periodically(disk_cache, interval=0, cover_disk_cache=None) + ) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + disk_cache.cleanup_expired_recent.assert_called() + disk_cache.enforce_recent_size_limits.assert_called() + disk_cache.cleanup_expired_covers.assert_called() + disk_cache.enforce_cover_size_limits.assert_called() + + +@pytest.mark.asyncio +async def test_periodic_cleanup_continues_on_cover_cache_error(): + disk_cache = AsyncMock() + cover_disk_cache = AsyncMock() + cover_disk_cache.enforce_size_limit.side_effect = [RuntimeError("disk full"), None] + + task = asyncio.create_task( + cleanup_disk_cache_periodically(disk_cache, interval=0, cover_disk_cache=cover_disk_cache) + ) + await asyncio.sleep(0.1) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert cover_disk_cache.enforce_size_limit.call_count >= 1 + assert disk_cache.cleanup_expired_recent.call_count >= 1 diff --git a/backend/tests/infrastructure/test_disk_metadata_cache.py b/backend/tests/infrastructure/test_disk_metadata_cache.py new file mode 100644 index 0000000..39f1b86 --- /dev/null +++ b/backend/tests/infrastructure/test_disk_metadata_cache.py @@ -0,0 +1,172 @@ +import hashlib +import json + +import pytest + +from api.v1.schemas.album import AlbumInfo +from infrastructure.cache.disk_cache import DiskMetadataCache +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages + + +@pytest.mark.asyncio +async def test_set_album_serializes_msgspec_struct_as_mapping(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + mbid = "4549a80c-efe6-4386-b3a2-4b4a918eb31f" + album_info = AlbumInfo( + title="The Moon Song", + musicbrainz_id=mbid, + artist_name="beabadoobee", + artist_id="88d17133-abbc-42db-9526-4e2c1db60336", + in_library=True, + ) + + await cache.set_album(mbid, album_info, is_monitored=True) + + cache_hash = hashlib.sha1(mbid.encode()).hexdigest() + cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json" + payload = json.loads(cache_file.read_text()) + + assert isinstance(payload, dict) + assert payload["musicbrainz_id"] == mbid + + cached = await cache.get_album(mbid) + assert isinstance(cached, dict) + assert cached["title"] == "The Moon Song" + + +@pytest.mark.asyncio +async def test_get_album_deletes_corrupt_string_payload(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + mbid = "8e1e9e51-38dc-4df3-8027-a0ada37d4674" + + cache_hash = hashlib.sha1(mbid.encode()).hexdigest() + cache_file = tmp_path / "persistent" / "albums" / f"{cache_hash}.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps("AlbumInfo(title='Corrupt')")) + + cached = await cache.get_album(mbid) + + assert cached is None + assert not cache_file.exists() + + +@pytest.mark.asyncio +async def test_audiodb_artist_entity_routing(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + mbid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + images = AudioDBArtistImages( + thumb_url="https://example.com/thumb.jpg", + fanart_url="https://example.com/fanart.jpg", + lookup_source="mbid", + matched_mbid=mbid, + ) + + await cache._set_entity("audiodb_artist", mbid, images, is_monitored=False, ttl_seconds=None) + + result = await cache._get_entity("audiodb_artist", mbid) + assert result is not None + assert result["thumb_url"] == "https://example.com/thumb.jpg" + assert result["fanart_url"] == "https://example.com/fanart.jpg" + assert result["lookup_source"] == "mbid" + + cache_hash = hashlib.sha1(mbid.encode()).hexdigest() + data_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json" + assert data_file.exists() + + +@pytest.mark.asyncio +async def test_audiodb_album_entity_routing(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + mbid = "b2c3d4e5-f6a7-8901-bcde-f12345678901" + images = AudioDBAlbumImages( + album_thumb_url="https://example.com/album_thumb.jpg", + album_back_url="https://example.com/album_back.jpg", + lookup_source="name", + matched_mbid=mbid, + ) + + await cache._set_entity("audiodb_album", mbid, images, is_monitored=True, ttl_seconds=None) + + result = await cache._get_entity("audiodb_album", mbid) + assert result is not None + assert result["album_thumb_url"] == "https://example.com/album_thumb.jpg" + assert result["album_back_url"] == "https://example.com/album_back.jpg" + assert result["lookup_source"] == "name" + + cache_hash = hashlib.sha1(mbid.encode()).hexdigest() + persistent_file = tmp_path / "persistent" / "audiodb_albums" / f"{cache_hash}.json" + assert persistent_file.exists() + + +@pytest.mark.asyncio +async def test_get_stats_counts_audiodb_entries(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + + artist_images = AudioDBArtistImages(thumb_url="https://example.com/a.jpg") + album_images = AudioDBAlbumImages(album_thumb_url="https://example.com/b.jpg") + + await cache._set_entity("audiodb_artist", "artist-1", artist_images, is_monitored=False, ttl_seconds=None) + await cache._set_entity("audiodb_artist", "artist-2", artist_images, is_monitored=True, ttl_seconds=None) + await cache._set_entity("audiodb_album", "album-1", album_images, is_monitored=False, ttl_seconds=None) + + stats = cache.get_stats() + assert stats["audiodb_artist_count"] == 2 + assert stats["audiodb_album_count"] == 1 + assert stats["album_count"] == 0 + assert stats["artist_count"] == 0 + assert stats["total_count"] == 3 + + +@pytest.mark.asyncio +async def test_clear_audiodb_isolates_from_other_entities(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + album_mbid = "c3d4e5f6-a7b8-9012-cdef-123456789012" + album_info = AlbumInfo( + title="Regular Album", + musicbrainz_id=album_mbid, + artist_name="Test Artist", + artist_id="d4e5f6a7-b8c9-0123-defa-234567890123", + in_library=False, + ) + await cache.set_album(album_mbid, album_info, is_monitored=False) + + artist_images = AudioDBArtistImages(thumb_url="https://example.com/thumb.jpg") + album_images = AudioDBAlbumImages(album_thumb_url="https://example.com/album.jpg") + await cache._set_entity("audiodb_artist", "adb-artist-1", artist_images, is_monitored=False, ttl_seconds=None) + await cache._set_entity("audiodb_album", "adb-album-1", album_images, is_monitored=True, ttl_seconds=None) + + stats_before = cache.get_stats() + assert stats_before["audiodb_artist_count"] == 1 + assert stats_before["audiodb_album_count"] == 1 + assert stats_before["album_count"] == 1 + + await cache.clear_audiodb() + + stats_after = cache.get_stats() + assert stats_after["audiodb_artist_count"] == 0 + assert stats_after["audiodb_album_count"] == 0 + assert stats_after["album_count"] == 1 + + regular_album = await cache.get_album(album_mbid) + assert regular_album is not None + assert regular_album["title"] == "Regular Album" + + +@pytest.mark.asyncio +async def test_audiodb_monitored_persistent_vs_recent(tmp_path): + cache = DiskMetadataCache(base_path=tmp_path) + mbid = "e5f6a7b8-c9d0-1234-efab-567890123456" + images = AudioDBArtistImages(thumb_url="https://example.com/t.jpg") + + await cache._set_entity("audiodb_artist", mbid, images, is_monitored=True, ttl_seconds=None) + + cache_hash = hashlib.sha1(mbid.encode()).hexdigest() + persistent_file = tmp_path / "persistent" / "audiodb_artists" / f"{cache_hash}.json" + recent_file = tmp_path / "recent" / "audiodb_artists" / f"{cache_hash}.json" + assert persistent_file.exists() + assert not recent_file.exists() + + await cache._set_entity("audiodb_artist", mbid, images, is_monitored=False, ttl_seconds=None) + + assert not persistent_file.exists() + assert recent_file.exists() diff --git a/backend/tests/infrastructure/test_integration_result.py b/backend/tests/infrastructure/test_integration_result.py new file mode 100644 index 0000000..9b88f69 --- /dev/null +++ b/backend/tests/infrastructure/test_integration_result.py @@ -0,0 +1,114 @@ +"""Tests for IntegrationResult and aggregate_status.""" + +import pytest + +from infrastructure.integration_result import ( + IntegrationResult, + aggregate_status, +) + + + + +class TestIntegrationResultOk: + def test_ok_carries_data(self): + r = IntegrationResult.ok(data=["a", "b"], source="musicbrainz") + assert r.data == ["a", "b"] + assert r.source == "musicbrainz" + assert r.status == "ok" + assert r.error_message is None + + def test_is_ok_true(self): + r = IntegrationResult.ok(data=42, source="jellyfin") + assert r.is_ok is True + assert r.is_degraded is False + assert r.is_error is False + + +class TestIntegrationResultDegraded: + def test_degraded_carries_partial_data(self): + r = IntegrationResult.degraded( + data={"stale": True}, source="audiodb", msg="rate limited" + ) + assert r.data == {"stale": True} + assert r.source == "audiodb" + assert r.status == "degraded" + assert r.error_message == "rate limited" + + def test_is_degraded_true(self): + r = IntegrationResult.degraded(data=[], source="lastfm", msg="timeout") + assert r.is_degraded is True + assert r.is_ok is False + assert r.is_error is False + + +class TestIntegrationResultError: + def test_error_has_no_data(self): + r = IntegrationResult.error(source="musicbrainz", msg="503 Service Unavailable") + assert r.data is None + assert r.source == "musicbrainz" + assert r.status == "error" + assert r.error_message == "503 Service Unavailable" + + def test_is_error_true(self): + r = IntegrationResult.error(source="wikidata", msg="boom") + assert r.is_error is True + assert r.is_ok is False + assert r.is_degraded is False + + + + +class TestDataOr: + def test_returns_data_when_present(self): + r = IntegrationResult.ok(data=[1, 2, 3], source="mb") + assert r.data_or([]) == [1, 2, 3] + + def test_returns_default_when_none(self): + r = IntegrationResult.error(source="mb", msg="down") + assert r.data_or([]) == [] + + def test_returns_data_for_degraded(self): + r = IntegrationResult.degraded(data={"partial": True}, source="mb", msg="slow") + assert r.data_or({}) == {"partial": True} + + + + +class TestImmutability: + def test_frozen(self): + r = IntegrationResult.ok(data="hello", source="test") + with pytest.raises(AttributeError): + r.data = "goodbye" # type: ignore[misc] + + + + +class TestAggregateStatus: + def test_all_ok(self): + assert aggregate_status( + IntegrationResult.ok(1, "a"), + IntegrationResult.ok(2, "b"), + ) == "ok" + + def test_one_degraded(self): + assert aggregate_status( + IntegrationResult.ok(1, "a"), + IntegrationResult.degraded(2, "b", "slow"), + ) == "degraded" + + def test_one_error(self): + assert aggregate_status( + IntegrationResult.ok(1, "a"), + IntegrationResult.degraded(2, "b", "slow"), + IntegrationResult.error("c", "down"), + ) == "error" + + def test_empty(self): + assert aggregate_status() == "ok" + + def test_error_short_circuits(self): + assert aggregate_status( + IntegrationResult.error("a", "x"), + IntegrationResult.ok(1, "b"), + ) == "error" diff --git a/backend/tests/infrastructure/test_library_pagination.py b/backend/tests/infrastructure/test_library_pagination.py new file mode 100644 index 0000000..49234b9 --- /dev/null +++ b/backend/tests/infrastructure/test_library_pagination.py @@ -0,0 +1,304 @@ +"""Tests for LibraryDB paginated query methods.""" + +import asyncio +import threading +from pathlib import Path + +import pytest + +from infrastructure.persistence.library_db import LibraryDB + + +@pytest.fixture +def db(tmp_path: Path) -> LibraryDB: + return LibraryDB(db_path=tmp_path / "test.db", write_lock=threading.Lock()) + + +def _make_albums(count: int, *, start: int = 1) -> list[dict]: + """Generate album dicts with predictable, sortable data.""" + albums = [] + for i in range(start, start + count): + albums.append( + { + "mbid": f"album-{i:04d}", + "artist_mbid": f"artist-{(i % 5) + 1:04d}", + "artist_name": f"Artist {chr(65 + (i % 26))}", + "title": f"Album {chr(65 + ((i + 13) % 26))} {i:04d}", + "year": 2000 + (i % 24), + "cover_url": None, + "monitored": True, + "date_added": 1700000000 + i * 100, + } + ) + return albums + + +def _make_artists(count: int) -> list[dict]: + """Generate artist dicts with predictable data.""" + artists = [] + for i in range(1, count + 1): + artists.append( + { + "mbid": f"artist-{i:04d}", + "name": f"Artist {chr(65 + (i % 26))}", + "album_count": i, + "date_added": 1700000000 + i * 100, + } + ) + return artists + + +async def _seed(db: LibraryDB, n_albums: int = 100, n_artists: int = 20) -> None: + await db.save_library(_make_artists(n_artists), _make_albums(n_albums)) + + +# --- Album pagination --- + + +def test_albums_basic_pagination(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=10, offset=0) + ) + assert total == 100 + assert len(items) == 10 + + +def test_albums_offset_beyond_total(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=10, offset=200) + ) + assert total == 100 + assert len(items) == 0 + + +def test_albums_last_partial_page(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=30, offset=90) + ) + assert total == 100 + assert len(items) == 10 + + +def test_albums_sort_by_title_asc(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="asc") + ) + titles = [i.get("title", "") for i in items] + assert titles == sorted(titles, key=str.casefold) + + +def test_albums_sort_by_title_desc(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, sort_by="title", sort_order="desc") + ) + titles = [i.get("title", "") for i in items] + assert titles == sorted(titles, key=str.casefold, reverse=True) + + +def test_albums_sort_by_year(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, sort_by="year", sort_order="desc") + ) + years = [i.get("year", 0) or 0 for i in items] + assert years == sorted(years, reverse=True) + + +def test_albums_sort_by_date_added(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, sort_by="date_added", sort_order="desc") + ) + dates = [i.get("date_added", 0) or 0 for i in items] + assert dates == sorted(dates, reverse=True) + + +def test_albums_search_by_title(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="Album A") + ) + assert total > 0 + assert all("Album A" in i.get("title", "") for i in items) + + +def test_albums_search_by_artist(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="Artist A") + ) + assert total > 0 + assert all( + "Artist A" in i.get("artist_name", "") or "Artist A" in i.get("title", "") + for i in items + ) + + +def test_albums_search_no_results(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=10, offset=0, search="zzz_no_match_zzz") + ) + assert total == 0 + assert len(items) == 0 + + +def test_albums_search_case_insensitive(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items_upper, total_upper = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="ALBUM A") + ) + items_lower, total_lower = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="album a") + ) + assert total_upper == total_lower + assert len(items_upper) == len(items_lower) + + +def test_albums_search_escapes_like_metacharacters(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items_pct, total_pct = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="100%") + ) + assert total_pct == 0 + assert len(items_pct) == 0 + items_under, total_under = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=100, offset=0, search="Album_A") + ) + assert total_under == 0 + + +def test_artists_search_escapes_like_metacharacters(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=100, offset=0, search="Artist%B") + ) + assert total == 0 + + +def test_albums_invalid_sort_falls_back(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=10, offset=0, sort_by="nonexistent") + ) + assert total == 100 + assert len(items) == 10 + + +def test_albums_empty_library(db: LibraryDB): + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated(limit=10, offset=0) + ) + assert total == 0 + assert len(items) == 0 + + +# --- Artist pagination --- + + +def test_artists_basic_pagination(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=5, offset=0) + ) + assert total == 20 + assert len(items) == 5 + + +def test_artists_offset_beyond_total(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=10, offset=50) + ) + assert total == 20 + assert len(items) == 0 + + +def test_artists_sort_by_name_asc(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=20, offset=0, sort_by="name", sort_order="asc") + ) + names = [i.get("name", "") for i in items] + assert names == sorted(names, key=str.casefold) + + +def test_artists_sort_by_album_count_desc(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, _ = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=20, offset=0, sort_by="album_count", sort_order="desc") + ) + counts = [i.get("album_count", 0) for i in items] + assert counts == sorted(counts, reverse=True) + + +def test_artists_search(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=20, offset=0, search="Artist B") + ) + assert total > 0 + assert all("Artist B" in i.get("name", "") for i in items) + + +def test_artists_search_no_results(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db)) + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=10, offset=0, search="zzz_no_match_zzz") + ) + assert total == 0 + assert len(items) == 0 + + +def test_artists_empty_library(db: LibraryDB): + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated(limit=10, offset=0) + ) + assert total == 0 + assert len(items) == 0 + + +# --- Pagination consistency (no duplicates/missing across pages) --- + + +def test_albums_pagination_no_duplicates(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=50)) + all_mbids: list[str] = [] + offset = 0 + page_size = 10 + while True: + items, total = asyncio.get_event_loop().run_until_complete( + db.get_albums_paginated( + limit=page_size, offset=offset, sort_by="title", sort_order="asc" + ) + ) + if not items: + break + all_mbids.extend(i.get("mbid", "") for i in items) + offset += page_size + assert len(all_mbids) == 50 + assert len(set(all_mbids)) == 50 + + +def test_artists_pagination_no_duplicates(db: LibraryDB): + asyncio.get_event_loop().run_until_complete(_seed(db, n_albums=10, n_artists=30)) + all_mbids: list[str] = [] + offset = 0 + page_size = 7 + while True: + items, total = asyncio.get_event_loop().run_until_complete( + db.get_artists_paginated( + limit=page_size, offset=offset, sort_by="name", sort_order="asc" + ) + ) + if not items: + break + all_mbids.extend(i.get("mbid", "") for i in items) + offset += page_size + assert len(all_mbids) == 30 + assert len(set(all_mbids)) == 30 diff --git a/backend/tests/infrastructure/test_msgspec_fastapi.py b/backend/tests/infrastructure/test_msgspec_fastapi.py new file mode 100644 index 0000000..caa731a --- /dev/null +++ b/backend/tests/infrastructure/test_msgspec_fastapi.py @@ -0,0 +1,117 @@ +import json + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.testclient import TestClient + +from infrastructure.msgspec_fastapi import ( + AppStruct, + MsgSpecBody, + MsgSpecJSONRequest, + MsgSpecJSONResponse, + MsgSpecRoute, + _contains_msgspec_struct, + _merge_response_schema, +) + + +class SamplePayload(AppStruct): + value: int + + +@pytest.mark.asyncio +async def test_msgspec_json_request_caches_decoded_body(): + body = b'{"value": 7}' + + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + request = MsgSpecJSONRequest( + { + "type": "http", + "http_version": "1.1", + "method": "POST", + "path": "/", + "raw_path": b"/", + "scheme": "http", + "headers": [], + "query_string": b"", + "client": ("testclient", 123), + "server": ("testserver", 80), + }, + receive, + ) + + first = await request.json() + second = await request.json() + + assert first == {"value": 7} + assert second == first + + +@pytest.mark.asyncio +async def test_msgspec_json_request_raises_json_decode_error(): + async def receive(): + return {"type": "http.request", "body": b"{", "more_body": False} + + request = MsgSpecJSONRequest( + { + "type": "http", + "http_version": "1.1", + "method": "POST", + "path": "/", + "raw_path": b"/", + "scheme": "http", + "headers": [], + "query_string": b"", + "client": ("testclient", 123), + "server": ("testserver", 80), + }, + receive, + ) + + with pytest.raises(json.JSONDecodeError): + await request.json() + + +def test_app_struct_iteration_and_json_response_render(): + payload = SamplePayload(value=5) + + assert dict(payload) == {"value": 5} + + response = MsgSpecJSONResponse(content=payload) + assert response.body == b'{"value":5}' + + +def test_msgspec_body_and_route_work_with_fastapi(): + app = FastAPI() + router = APIRouter(route_class=MsgSpecRoute) + + @router.post("/items", response_model=SamplePayload) + async def create_item(body: SamplePayload = MsgSpecBody(SamplePayload)): + return body + + app.include_router(router) + client = TestClient(app) + + ok = client.post("/items", json={"value": 11}) + assert ok.status_code == 200 + assert ok.json() == {"value": 11} + + bad = client.post("/items", json={"value": "nope"}) + assert bad.status_code == 422 + + +def test_contains_msgspec_struct_and_merge_response_schema(): + assert _contains_msgspec_struct(SamplePayload) is True + assert _contains_msgspec_struct(list[SamplePayload]) is True + assert _contains_msgspec_struct(str | None) is False + + merged = _merge_response_schema( + {"responses": {"200": {"description": "ok"}}}, + {"type": "object", "properties": {"value": {"type": "integer"}}}, + ) + + schema = merged["responses"]["200"]["content"]["application/json"]["schema"] + assert schema["type"] == "object" + assert "value" in schema["properties"] diff --git a/backend/tests/infrastructure/test_queue_persistence.py b/backend/tests/infrastructure/test_queue_persistence.py new file mode 100644 index 0000000..a1f646f --- /dev/null +++ b/backend/tests/infrastructure/test_queue_persistence.py @@ -0,0 +1,110 @@ +import asyncio +import pytest +from pathlib import Path +from infrastructure.queue.queue_store import QueueStore +from infrastructure.queue.request_queue import RequestQueue + + +@pytest.fixture +def store(tmp_path: Path) -> QueueStore: + return QueueStore(db_path=tmp_path / "test_queue.db") + + +@pytest.mark.asyncio +async def test_jobs_survive_restart(store: QueueStore): + processed = [] + + async def slow_processor(mbid: str) -> dict: + await asyncio.sleep(100) + processed.append(mbid) + return {"status": "ok"} + + q1 = RequestQueue(processor=slow_processor, store=store) + await q1.start() + + store.enqueue("job-1", "mbid-abc") + store.mark_processing("job-1") + + q1._processor_task.cancel() + try: + await q1._processor_task + except asyncio.CancelledError: + pass + + fast_processed = [] + + async def fast_processor(mbid: str) -> dict: + fast_processed.append(mbid) + return {"status": "ok"} + + q2 = RequestQueue(processor=fast_processor, store=store) + await q2.start() + + await asyncio.sleep(0.5) + assert "mbid-abc" in fast_processed + await q2.stop() + + +@pytest.mark.asyncio +async def test_failed_job_lands_in_dead_letter(store: QueueStore): + async def failing_processor(mbid: str) -> dict: + raise ValueError("Lidarr is down") + + q = RequestQueue(processor=failing_processor, store=store) + await q.start() + + try: + await asyncio.wait_for(q.add("mbid-fail"), timeout=2.0) + except (ValueError, asyncio.TimeoutError): + pass + + await asyncio.sleep(0.1) + assert store.get_dead_letter_count() >= 1 + await q.stop() + + +@pytest.mark.asyncio +async def test_dead_letter_retry_on_restart(store: QueueStore): + store.add_dead_letter("dlj-1", "mbid-retry", "old error", retry_count=1, max_retries=3) + + processed = [] + + async def processor(mbid: str) -> dict: + processed.append(mbid) + return {"status": "ok"} + + q = RequestQueue(processor=processor, store=store) + await q.start() + await asyncio.sleep(0.5) + assert "mbid-retry" in processed + await q.stop() + + +@pytest.mark.asyncio +async def test_successful_job_removed_from_store(store: QueueStore): + async def ok_processor(mbid: str) -> dict: + return {"status": "ok"} + + q = RequestQueue(processor=ok_processor, store=store) + await q.start() + + await asyncio.wait_for(q.add("mbid-ok"), timeout=2.0) + assert len(store.get_all()) == 0 + await q.stop() + + +@pytest.mark.asyncio +async def test_exhausted_dead_letter_not_retried(store: QueueStore): + store.add_dead_letter("dlj-ex", "mbid-exhausted", "fatal", retry_count=3, max_retries=3) + + processed = [] + + async def processor(mbid: str) -> dict: + processed.append(mbid) + return {"status": "ok"} + + q = RequestQueue(processor=processor, store=store) + await q.start() + await asyncio.sleep(0.3) + assert "mbid-exhausted" not in processed + await q.stop() diff --git a/backend/tests/infrastructure/test_queue_store.py b/backend/tests/infrastructure/test_queue_store.py new file mode 100644 index 0000000..9665338 --- /dev/null +++ b/backend/tests/infrastructure/test_queue_store.py @@ -0,0 +1,88 @@ +import pytest +from pathlib import Path +from infrastructure.queue.queue_store import QueueStore + + +@pytest.fixture +def store(tmp_path: Path) -> QueueStore: + return QueueStore(db_path=tmp_path / "test_queue.db") + + +def test_enqueue_and_get_pending(store: QueueStore): + store.enqueue("j1", "mbid-1") + store.enqueue("j2", "mbid-2") + store.enqueue("j3", "mbid-3") + assert len(store.get_pending()) == 3 + + +def test_dequeue_removes_job(store: QueueStore): + store.enqueue("j1", "mbid-1") + store.dequeue("j1") + assert len(store.get_pending()) == 0 + + +def test_duplicate_enqueue_ignored(store: QueueStore): + assert store.enqueue("j1", "mbid-1") is True + assert store.enqueue("j2", "mbid-1") is False + assert len(store.get_pending()) == 1 + + +def test_mark_processing(store: QueueStore): + store.enqueue("j1", "mbid-1") + store.mark_processing("j1") + assert len(store.get_pending()) == 0 + assert len(store.get_all()) == 1 + + +def test_reset_processing(store: QueueStore): + store.enqueue("j1", "mbid-1") + store.mark_processing("j1") + store.reset_processing() + assert len(store.get_pending()) == 1 + + +def test_add_dead_letter_retryable(store: QueueStore): + store.add_dead_letter("j1", "mbid-1", "error", retry_count=1, max_retries=3) + retryable = store.get_retryable_dead_letters() + assert len(retryable) == 1 + assert retryable[0]["album_mbid"] == "mbid-1" + + +def test_add_dead_letter_exhausted(store: QueueStore): + store.add_dead_letter("j1", "mbid-1", "error", retry_count=3, max_retries=3) + assert len(store.get_retryable_dead_letters()) == 0 + + +def test_remove_dead_letter(store: QueueStore): + store.add_dead_letter("j1", "mbid-1", "error", retry_count=1, max_retries=3) + store.remove_dead_letter("j1") + assert len(store.get_retryable_dead_letters()) == 0 + + +def test_update_dead_letter_attempt(store: QueueStore): + store.add_dead_letter("j1", "mbid-1", "error1", retry_count=1, max_retries=3) + store.update_dead_letter_attempt("j1", "error2", retry_count=3) + assert len(store.get_retryable_dead_letters()) == 0 + assert store.get_dead_letter_count() == 1 + + +def test_get_dead_letter_count(store: QueueStore): + store.add_dead_letter("j1", "mbid-1", "e1", 1, 3) + store.add_dead_letter("j2", "mbid-2", "e2", 1, 3) + store.add_dead_letter("j3", "mbid-3", "e3", 1, 3) + assert store.get_dead_letter_count() == 3 + + +def test_has_pending_mbid(store: QueueStore): + assert store.has_pending_mbid("mbid-1") is False + store.enqueue("j1", "mbid-1") + assert store.has_pending_mbid("mbid-1") is True + store.mark_processing("j1") + assert store.has_pending_mbid("mbid-1") is False + store.dequeue("j1") + assert store.has_pending_mbid("mbid-1") is False + + +def test_enqueue_returns_bool(store: QueueStore): + assert store.enqueue("j1", "mbid-1") is True + assert store.enqueue("j1", "mbid-1") is False diff --git a/backend/tests/infrastructure/test_retry_non_breaking.py b/backend/tests/infrastructure/test_retry_non_breaking.py new file mode 100644 index 0000000..60e247a --- /dev/null +++ b/backend/tests/infrastructure/test_retry_non_breaking.py @@ -0,0 +1,199 @@ +"""Tests that non_breaking_exceptions bypass circuit breaker failure recording.""" + +import asyncio + +import pytest + +from infrastructure.resilience.retry import ( + CircuitBreaker, + CircuitOpenError, + CircuitState, + with_retry, +) + + +class _RateLimited(Exception): + def __init__(self, retry_after: float = 1.0): + super().__init__("rate limited") + self.retry_after_seconds = retry_after + + +class _ServiceDown(Exception): + pass + + +@pytest.mark.asyncio +async def test_non_breaking_exception_does_not_trip_circuit(): + cb = CircuitBreaker(failure_threshold=3, name="test-non-breaking") + call_count = 0 + + @with_retry( + max_attempts=4, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited, _ServiceDown), + non_breaking_exceptions=(_RateLimited,), + ) + async def flaky(): + nonlocal call_count + call_count += 1 + if call_count < 4: + raise _RateLimited(retry_after=0.01) + return "ok" + + result = await flaky() + + assert result == "ok" + assert call_count == 4 + assert cb.state == CircuitState.CLOSED + assert cb.failure_count == 0 + + +@pytest.mark.asyncio +async def test_breaking_exception_still_trips_circuit(): + cb = CircuitBreaker(failure_threshold=2, name="test-breaking") + call_count = 0 + + @with_retry( + max_attempts=3, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited, _ServiceDown), + non_breaking_exceptions=(_RateLimited,), + ) + async def fail(): + nonlocal call_count + call_count += 1 + raise _ServiceDown("down") + + with pytest.raises(_ServiceDown): + await fail() + + assert call_count == 3 + assert cb.state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_non_breaking_uses_retry_after_for_delay(): + cb = CircuitBreaker(failure_threshold=5, name="test-retry-after") + call_count = 0 + + @with_retry( + max_attempts=2, + base_delay=100.0, + max_delay=100.0, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited,), + non_breaking_exceptions=(_RateLimited,), + ) + async def rate_limited_then_ok(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise _RateLimited(retry_after=0.01) + return "ok" + + result = await rate_limited_then_ok() + + assert result == "ok" + assert call_count == 2 + assert cb.failure_count == 0 + + +@pytest.mark.asyncio +async def test_circuit_still_opens_for_real_errors_amid_rate_limits(): + cb = CircuitBreaker(failure_threshold=2, name="test-mixed") + + @with_retry( + max_attempts=1, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited, _ServiceDown), + non_breaking_exceptions=(_RateLimited,), + ) + async def real_failure(): + raise _ServiceDown("down") + + for _ in range(2): + with pytest.raises(_ServiceDown): + await real_failure() + + assert cb.state == CircuitState.OPEN + + @with_retry( + max_attempts=1, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited, _ServiceDown), + non_breaking_exceptions=(_RateLimited,), + ) + async def subsequent_call(): + return "should not reach" + + with pytest.raises(CircuitOpenError): + await subsequent_call() + + +@pytest.mark.asyncio +async def test_non_breaking_in_half_open_reopens_circuit(): + """Non-breaking exceptions in HALF_OPEN must still reopen the circuit.""" + cb = CircuitBreaker(failure_threshold=2, success_threshold=2, timeout=0.01, name="test-half-open") + + for _ in range(2): + cb.record_failure() + assert cb.state == CircuitState.OPEN + + await asyncio.sleep(0.02) + await cb.atry_transition() + assert cb.state == CircuitState.HALF_OPEN + + @with_retry( + max_attempts=1, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited,), + non_breaking_exceptions=(_RateLimited,), + ) + async def rate_limited_in_half_open(): + raise _RateLimited(retry_after=0.01) + + with pytest.raises(_RateLimited): + await rate_limited_in_half_open() + + assert cb.state == CircuitState.OPEN + + +@pytest.mark.asyncio +async def test_retry_after_not_clamped_by_max_delay(): + """Server-provided Retry-After should not be clamped by max_delay.""" + cb = CircuitBreaker(failure_threshold=10, name="test-retry-after-clamp") + call_count = 0 + observed_gap = 0.0 + + @with_retry( + max_attempts=2, + base_delay=0.01, + max_delay=0.05, + circuit_breaker=cb, + retriable_exceptions=(_RateLimited,), + non_breaking_exceptions=(_RateLimited,), + ) + async def rate_limited_then_ok(): + nonlocal call_count, observed_gap + call_count += 1 + if call_count == 1: + raise _RateLimited(retry_after=0.3) + return "ok" + + import time + start = time.monotonic() + result = await rate_limited_then_ok() + elapsed = time.monotonic() - start + + assert result == "ok" + assert elapsed >= 0.25, f"Expected >=0.25s delay from retry_after=0.3, got {elapsed:.3f}s" diff --git a/backend/tests/infrastructure/test_serialization.py b/backend/tests/infrastructure/test_serialization.py new file mode 100644 index 0000000..4ee0f2f --- /dev/null +++ b/backend/tests/infrastructure/test_serialization.py @@ -0,0 +1,41 @@ +import msgspec +import pytest + +from infrastructure.serialization import clone_with_updates, to_jsonable + + +class SampleStruct(msgspec.Struct): + value: int + name: str = "x" + + +def test_to_jsonable_struct_and_dict(): + struct_value = SampleStruct(value=3, name="abc") + + assert to_jsonable(struct_value) == {"value": 3, "name": "abc"} + assert to_jsonable({"x": 1}) == {"x": 1} + + +def test_clone_with_updates_struct(): + original = SampleStruct(value=1, name="before") + + updated = clone_with_updates(original, {"name": "after"}) + + assert isinstance(updated, SampleStruct) + assert updated.value == 1 + assert updated.name == "after" + assert original.name == "before" + + +def test_clone_with_updates_dict(): + original = {"value": 1, "name": "before"} + + updated = clone_with_updates(original, {"name": "after", "extra": True}) + + assert updated == {"value": 1, "name": "after", "extra": True} + assert original == {"value": 1, "name": "before"} + + +def test_clone_with_updates_unsupported_type_raises(): + with pytest.raises(TypeError): + clone_with_updates([1, 2, 3], {"value": 9}) diff --git a/backend/tests/repositories/test_audiodb_models.py b/backend/tests/repositories/test_audiodb_models.py new file mode 100644 index 0000000..6372a84 --- /dev/null +++ b/backend/tests/repositories/test_audiodb_models.py @@ -0,0 +1,363 @@ +import time + +import msgspec +import pytest + +from repositories.audiodb_models import ( + AudioDBArtistImages, + AudioDBArtistResponse, + AudioDBAlbumImages, + AudioDBAlbumResponse, +) + + +def test_artist_images_serialization_roundtrip(): + now = time.time() + original = AudioDBArtistImages( + thumb_url="https://cdn.example.com/thumb.jpg", + fanart_url="https://cdn.example.com/fanart.jpg", + fanart_url_2="https://cdn.example.com/fanart2.jpg", + fanart_url_3="https://cdn.example.com/fanart3.jpg", + fanart_url_4="https://cdn.example.com/fanart4.jpg", + wide_thumb_url="https://cdn.example.com/wide.jpg", + banner_url="https://cdn.example.com/banner.jpg", + logo_url="https://cdn.example.com/logo.jpg", + cutout_url="https://cdn.example.com/cutout.jpg", + clearart_url="https://cdn.example.com/clearart.jpg", + lookup_source="mbid", + matched_mbid="abc-123", + is_negative=False, + cached_at=now, + ) + data = msgspec.json.encode(original) + decoded = msgspec.json.decode(data, type=AudioDBArtistImages) + + assert decoded.thumb_url == original.thumb_url + assert decoded.fanart_url == original.fanart_url + assert decoded.fanart_url_2 == original.fanart_url_2 + assert decoded.fanart_url_3 == original.fanart_url_3 + assert decoded.fanart_url_4 == original.fanart_url_4 + assert decoded.wide_thumb_url == original.wide_thumb_url + assert decoded.banner_url == original.banner_url + assert decoded.logo_url == original.logo_url + assert decoded.cutout_url == original.cutout_url + assert decoded.clearart_url == original.clearart_url + assert decoded.lookup_source == "mbid" + assert decoded.matched_mbid == "abc-123" + assert decoded.is_negative is False + assert decoded.cached_at == now + + +def test_artist_images_negative_entry_roundtrip(): + original = AudioDBArtistImages( + is_negative=True, + lookup_source="mbid", + cached_at=time.time(), + ) + data = msgspec.json.encode(original) + decoded = msgspec.json.decode(data, type=AudioDBArtistImages) + + assert decoded.is_negative is True + assert decoded.thumb_url is None + assert decoded.fanart_url is None + assert decoded.fanart_url_2 is None + assert decoded.fanart_url_3 is None + assert decoded.fanart_url_4 is None + assert decoded.wide_thumb_url is None + assert decoded.banner_url is None + assert decoded.logo_url is None + assert decoded.cutout_url is None + assert decoded.clearart_url is None + + +def test_album_images_serialization_roundtrip(): + now = time.time() + original = AudioDBAlbumImages( + album_thumb_url="https://cdn.example.com/album_thumb.jpg", + album_back_url="https://cdn.example.com/album_back.jpg", + album_cdart_url="https://cdn.example.com/album_cdart.jpg", + album_spine_url="https://cdn.example.com/album_spine.jpg", + album_3d_case_url="https://cdn.example.com/album_3d_case.jpg", + album_3d_flat_url="https://cdn.example.com/album_3d_flat.jpg", + album_3d_face_url="https://cdn.example.com/album_3d_face.jpg", + album_3d_thumb_url="https://cdn.example.com/album_3d_thumb.jpg", + lookup_source="mbid", + matched_mbid="album-456", + is_negative=False, + cached_at=now, + ) + data = msgspec.json.encode(original) + decoded = msgspec.json.decode(data, type=AudioDBAlbumImages) + + assert decoded.album_thumb_url == original.album_thumb_url + assert decoded.album_back_url == original.album_back_url + assert decoded.album_cdart_url == original.album_cdart_url + assert decoded.album_spine_url == original.album_spine_url + assert decoded.album_3d_case_url == original.album_3d_case_url + assert decoded.album_3d_flat_url == original.album_3d_flat_url + assert decoded.album_3d_face_url == original.album_3d_face_url + assert decoded.album_3d_thumb_url == original.album_3d_thumb_url + assert decoded.lookup_source == "mbid" + assert decoded.matched_mbid == "album-456" + assert decoded.is_negative is False + assert decoded.cached_at == now + + +def test_album_images_negative_entry_roundtrip(): + original = AudioDBAlbumImages( + is_negative=True, + lookup_source="mbid", + cached_at=time.time(), + ) + data = msgspec.json.encode(original) + decoded = msgspec.json.decode(data, type=AudioDBAlbumImages) + + assert decoded.is_negative is True + assert decoded.album_thumb_url is None + assert decoded.album_back_url is None + assert decoded.album_cdart_url is None + assert decoded.album_spine_url is None + assert decoded.album_3d_case_url is None + assert decoded.album_3d_flat_url is None + assert decoded.album_3d_face_url is None + assert decoded.album_3d_thumb_url is None + + +def test_artist_images_name_lookup_source(): + original = AudioDBArtistImages( + lookup_source="name", + matched_mbid="different-mbid", + cached_at=time.time(), + ) + data = msgspec.json.encode(original) + decoded = msgspec.json.decode(data, type=AudioDBArtistImages) + + assert decoded.lookup_source == "name" + assert decoded.matched_mbid == "different-mbid" + + +def test_artist_response_from_full_payload(): + resp = AudioDBArtistResponse( + idArtist="112345", + strArtist="Test Artist", + strMusicBrainzID="mbid-artist-001", + strArtistThumb="https://cdn.example.com/thumb.jpg", + strArtistFanart="https://cdn.example.com/fanart.jpg", + strArtistFanart2="https://cdn.example.com/fanart2.jpg", + strArtistFanart3="https://cdn.example.com/fanart3.jpg", + strArtistFanart4="https://cdn.example.com/fanart4.jpg", + strArtistWideThumb="https://cdn.example.com/wide.jpg", + strArtistBanner="https://cdn.example.com/banner.jpg", + strArtistLogo="https://cdn.example.com/logo.jpg", + strArtistCutout="https://cdn.example.com/cutout.jpg", + strArtistClearart="https://cdn.example.com/clearart.jpg", + ) + images = AudioDBArtistImages.from_response(resp, lookup_source="mbid") + + assert images.thumb_url == "https://cdn.example.com/thumb.jpg" + assert images.fanart_url == "https://cdn.example.com/fanart.jpg" + assert images.fanart_url_2 == "https://cdn.example.com/fanart2.jpg" + assert images.fanart_url_3 == "https://cdn.example.com/fanart3.jpg" + assert images.fanart_url_4 == "https://cdn.example.com/fanart4.jpg" + assert images.wide_thumb_url == "https://cdn.example.com/wide.jpg" + assert images.banner_url == "https://cdn.example.com/banner.jpg" + assert images.logo_url == "https://cdn.example.com/logo.jpg" + assert images.cutout_url == "https://cdn.example.com/cutout.jpg" + assert images.clearart_url == "https://cdn.example.com/clearart.jpg" + assert images.is_negative is False + assert images.matched_mbid == "mbid-artist-001" + + +def test_album_response_from_full_payload(): + resp = AudioDBAlbumResponse( + idAlbum="998877", + strAlbum="Test Album", + strMusicBrainzID="mbid-album-001", + strAlbumThumb="https://cdn.example.com/album_thumb.jpg", + strAlbumBack="https://cdn.example.com/album_back.jpg", + strAlbumCDart="https://cdn.example.com/album_cdart.jpg", + strAlbumSpine="https://cdn.example.com/album_spine.jpg", + strAlbum3DCase="https://cdn.example.com/album_3d_case.jpg", + strAlbum3DFlat="https://cdn.example.com/album_3d_flat.jpg", + strAlbum3DFace="https://cdn.example.com/album_3d_face.jpg", + strAlbum3DThumb="https://cdn.example.com/album_3d_thumb.jpg", + ) + images = AudioDBAlbumImages.from_response(resp, lookup_source="mbid") + + assert images.album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + assert images.album_back_url == "https://cdn.example.com/album_back.jpg" + assert images.album_cdart_url == "https://cdn.example.com/album_cdart.jpg" + assert images.album_spine_url == "https://cdn.example.com/album_spine.jpg" + assert images.album_3d_case_url == "https://cdn.example.com/album_3d_case.jpg" + assert images.album_3d_flat_url == "https://cdn.example.com/album_3d_flat.jpg" + assert images.album_3d_face_url == "https://cdn.example.com/album_3d_face.jpg" + assert images.album_3d_thumb_url == "https://cdn.example.com/album_3d_thumb.jpg" + assert images.is_negative is False + assert images.matched_mbid == "mbid-album-001" + + +def test_artist_negative_factory(): + before = time.time() + result = AudioDBArtistImages.negative(lookup_source="mbid") + after = time.time() + + assert result.is_negative is True + assert result.lookup_source == "mbid" + assert result.thumb_url is None + assert result.fanart_url is None + assert result.fanart_url_2 is None + assert result.fanart_url_3 is None + assert result.fanart_url_4 is None + assert result.wide_thumb_url is None + assert result.banner_url is None + assert result.logo_url is None + assert result.cutout_url is None + assert result.clearart_url is None + assert before <= result.cached_at <= after + + +def test_artist_negative_factory_name_source(): + result = AudioDBArtistImages.negative(lookup_source="name") + + assert result.lookup_source == "name" + + +def test_album_negative_factory(): + before = time.time() + result = AudioDBAlbumImages.negative(lookup_source="mbid") + after = time.time() + + assert result.is_negative is True + assert result.lookup_source == "mbid" + assert result.album_thumb_url is None + assert result.album_back_url is None + assert result.album_cdart_url is None + assert result.album_spine_url is None + assert result.album_3d_case_url is None + assert result.album_3d_flat_url is None + assert result.album_3d_face_url is None + assert result.album_3d_thumb_url is None + assert before <= result.cached_at <= after + + +def test_artist_response_tolerates_unknown_fields(): + data = b'{"idArtist": "1", "strArtist": "Test", "strNewUnknownField": "value"}' + resp = msgspec.json.decode(data, type=AudioDBArtistResponse) + + assert resp.idArtist == "1" + assert resp.strArtist == "Test" + + +def test_album_response_tolerates_unknown_fields(): + data = b'{"idAlbum": "1", "strAlbum": "Test", "strNewUnknownField": "value"}' + resp = msgspec.json.decode(data, type=AudioDBAlbumResponse) + + assert resp.idAlbum == "1" + assert resp.strAlbum == "Test" + + +REAL_ARTIST_PAYLOAD = { + "idArtist": "111239", + "strArtist": "Coldplay", + "strMusicBrainzID": "cc197bad-dc9c-440d-a5b5-d52ba2e14234", + "strArtistThumb": "https://r2.theaudiodb.com/images/artist/thumb/coldplay.jpg", + "strArtistFanart": "https://r2.theaudiodb.com/images/artist/fanart/coldplay1.jpg", + "strArtistFanart2": "https://r2.theaudiodb.com/images/artist/fanart/coldplay2.jpg", + "strArtistFanart3": None, + "strArtistFanart4": None, + "strArtistWideThumb": "https://r2.theaudiodb.com/images/artist/widethumb/coldplay.jpg", + "strArtistBanner": "https://r2.theaudiodb.com/images/artist/banner/coldplay.jpg", + "strArtistLogo": None, + "strArtistCutout": None, + "strArtistClearart": None, + "strArtistStripped": None, + "strArtistAlternate": "", + "strLabel": "Parlophone", + "idLabel": "45114", + "intFormedYear": "1996", + "intBornYear": "1996", + "intDiedYear": None, + "strDisbanded": None, + "strStyle": "Rock/Pop", + "strGenre": "Alternative Rock", + "strMood": "Happy", + "strWebsite": "www.coldplay.com", + "strFacebook": "", + "strTwitter": "", + "strBiographyEN": "Coldplay are a British rock band...", + "strGender": "Male", + "intMembers": "4", + "strCountry": "London, England", + "strCountryCode": "GB", + "strArtistFanart5": None, + "strArtistFanart6": None, +} + +REAL_ALBUM_PAYLOAD = { + "idAlbum": "2115888", + "strAlbum": "Parachutes", + "strMusicBrainzID": "1dc4c347-a1db-32aa-b14f-bc9cc507b843", + "strAlbumThumb": "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg", + "strAlbumBack": "https://r2.theaudiodb.com/images/album/back/parachutes.jpg", + "strAlbumCDart": None, + "strAlbumSpine": None, + "strAlbum3DCase": None, + "strAlbum3DFlat": None, + "strAlbum3DFace": None, + "strAlbum3DThumb": None, + "idArtist": "111239", + "idLabel": "45114", + "strArtist": "Coldplay", + "intYearReleased": "2000", + "strStyle": "Rock/Pop", + "strGenre": "Alternative Rock", + "strLabel": "Parlophone", + "strReleaseFormat": "Album", + "intSales": "0", + "strAlbumStripped": "Parachutes", + "strDescriptionEN": "Parachutes is the debut studio album...", + "intScore": "8", + "intScoreVotes": "5", + "strLocked": "unlocked", +} + + +def test_real_artist_payload_decodes_to_response(): + """8.1.f — Decode a real-shape AudioDB artist payload with unknown fields.""" + data = msgspec.json.encode(REAL_ARTIST_PAYLOAD) + resp = msgspec.json.decode(data, type=AudioDBArtistResponse) + + assert resp.idArtist == "111239" + assert resp.strArtist == "Coldplay" + assert resp.strMusicBrainzID == "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + assert resp.strArtistThumb == "https://r2.theaudiodb.com/images/artist/thumb/coldplay.jpg" + assert resp.strArtistFanart == "https://r2.theaudiodb.com/images/artist/fanart/coldplay1.jpg" + assert resp.strArtistFanart2 == "https://r2.theaudiodb.com/images/artist/fanart/coldplay2.jpg" + assert resp.strArtistFanart3 is None + assert resp.strArtistWideThumb == "https://r2.theaudiodb.com/images/artist/widethumb/coldplay.jpg" + assert resp.strArtistBanner == "https://r2.theaudiodb.com/images/artist/banner/coldplay.jpg" + + images = AudioDBArtistImages.from_response(resp, lookup_source="mbid") + assert images.thumb_url == resp.strArtistThumb + assert images.fanart_url == resp.strArtistFanart + assert images.is_negative is False + assert images.matched_mbid == "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +def test_real_album_payload_decodes_to_response(): + """8.1.f — Decode a real-shape AudioDB album payload with unknown fields.""" + data = msgspec.json.encode(REAL_ALBUM_PAYLOAD) + resp = msgspec.json.decode(data, type=AudioDBAlbumResponse) + + assert resp.idAlbum == "2115888" + assert resp.strAlbum == "Parachutes" + assert resp.strMusicBrainzID == "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + assert resp.strAlbumThumb == "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg" + assert resp.strAlbumBack == "https://r2.theaudiodb.com/images/album/back/parachutes.jpg" + assert resp.strAlbumCDart is None + + images = AudioDBAlbumImages.from_response(resp, lookup_source="mbid") + assert images.album_thumb_url == resp.strAlbumThumb + assert images.album_back_url == resp.strAlbumBack + assert images.is_negative is False + assert images.matched_mbid == "1dc4c347-a1db-32aa-b14f-bc9cc507b843" diff --git a/backend/tests/repositories/test_audiodb_repository.py b/backend/tests/repositories/test_audiodb_repository.py new file mode 100644 index 0000000..a0ba902 --- /dev/null +++ b/backend/tests/repositories/test_audiodb_repository.py @@ -0,0 +1,705 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import msgspec +import pytest + +from core.exceptions import ExternalServiceError, RateLimitedError +from repositories.audiodb_models import AudioDBAlbumResponse, AudioDBArtistResponse +from infrastructure.resilience.retry import CircuitBreaker +from repositories.audiodb_repository import ( + AUDIODB_FREE_KEY, + AudioDBRepository, + _audiodb_circuit_breaker, + _make_rate_limiter, +) + + +def _make_advanced_settings(enabled: bool = True, api_key: str = "test_key") -> MagicMock: + settings = MagicMock() + settings.audiodb_enabled = enabled + settings.audiodb_api_key = api_key + return settings + + +def _make_repo(enabled: bool = True, api_key: str = "test_key", premium: bool = False) -> AudioDBRepository: + client = AsyncMock(spec=httpx.AsyncClient) + prefs = MagicMock() + prefs.get_advanced_settings.return_value = _make_advanced_settings(enabled, api_key) + return AudioDBRepository( + http_client=client, + preferences_service=prefs, + api_key=api_key, + premium=premium, + ) + + +def _mock_response(status_code: int = 200, json_data: dict | None = None) -> httpx.Response: + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + if json_data is not None: + resp.content = msgspec.json.encode(json_data) + else: + resp.content = b"{}" + return resp + + +SAMPLE_ARTIST_DATA = { + "idArtist": "111239", + "strArtist": "Coldplay", + "strMusicBrainzID": "cc197bad-dc9c-440d-a5b5-d52ba2e14234", + "strArtistThumb": "https://r2.theaudiodb.com/images/artist/thumb/coldplay.jpg", + "strArtistFanart": "https://r2.theaudiodb.com/images/artist/fanart/coldplay1.jpg", + "strArtistFanart2": "https://r2.theaudiodb.com/images/artist/fanart/coldplay2.jpg", + "strArtistFanart3": None, + "strArtistFanart4": None, + "strArtistWideThumb": "https://r2.theaudiodb.com/images/artist/widethumb/coldplay.jpg", + "strArtistBanner": "https://r2.theaudiodb.com/images/artist/banner/coldplay.jpg", + "strArtistLogo": None, + "strArtistCutout": None, + "strArtistClearart": None, +} + +SAMPLE_ALBUM_DATA = { + "idAlbum": "2115888", + "strAlbum": "Parachutes", + "strMusicBrainzID": "1dc4c347-a1db-32aa-b14f-bc9cc507b843", + "strAlbumThumb": "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg", + "strAlbumBack": "https://r2.theaudiodb.com/images/album/back/parachutes.jpg", + "strAlbumCDart": None, + "strAlbumSpine": None, + "strAlbum3DCase": None, + "strAlbum3DFlat": None, + "strAlbum3DFace": None, + "strAlbum3DThumb": None, +} + +FULL_PAYLOAD_ARTIST_DATA = { + **SAMPLE_ARTIST_DATA, + "strArtistStripped": None, + "strArtistAlternate": "", + "strLabel": "Parlophone", + "idLabel": "45114", + "intFormedYear": "1996", + "intBornYear": "1996", + "intDiedYear": None, + "strDisbanded": None, + "strStyle": "Rock/Pop", + "strGenre": "Alternative Rock", + "strMood": "Happy", + "strWebsite": "www.coldplay.com", + "strFacebook": "", + "strTwitter": "", + "strBiographyEN": "Coldplay are a British rock band...", + "strBiographyDE": None, + "strBiographyFR": None, + "strBiographyES": None, + "strGender": "Male", + "intMembers": "4", + "strCountry": "London, England", + "strCountryCode": "GB", + "strArtistFanart5": None, + "strArtistFanart6": None, +} + +FULL_PAYLOAD_ALBUM_DATA = { + **SAMPLE_ALBUM_DATA, + "idArtist": "111239", + "idLabel": "45114", + "strArtist": "Coldplay", + "strArtistStripped": None, + "intYearReleased": "2000", + "strStyle": "Rock/Pop", + "strGenre": "Alternative Rock", + "strLabel": "Parlophone", + "strReleaseFormat": "Album", + "intSales": "0", + "strAlbumStripped": "Parachutes", + "strDescriptionEN": "Parachutes is the debut studio album...", + "strDescriptionDE": None, + "strDescriptionFR": None, + "strDescriptionES": None, + "intScore": "8", + "intScoreVotes": "5", + "strReview": "", + "strMood": "Happy", + "strTheme": None, + "strSpeed": None, + "strLocation": None, + "strMusicBrainzArtistID": "cc197bad-dc9c-440d-a5b5-d52ba2e14234", + "strAllMusicID": None, + "strBBCReviewID": None, + "strRateYourMusicID": None, + "strDiscogsID": None, + "strWikidataID": None, + "strWikipediaID": None, + "strGeniusID": None, + "strLyricWikiID": None, + "strMusicMozID": None, + "strItunesID": None, + "strAmazonID": None, + "strLocked": "unlocked", +} + + +@pytest.fixture(autouse=True) +def _reset_resilience(): + _audiodb_circuit_breaker.reset() + yield + _audiodb_circuit_breaker.reset() + + +@pytest.fixture(autouse=True) +def _stub_retry_sleep(): + with patch("infrastructure.resilience.retry.asyncio.sleep", new=AsyncMock()): + yield + + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_disabled(): + repo = _make_repo(enabled=False) + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_album_by_mbid_disabled(): + repo = _make_repo(enabled=False) + result = await repo.get_album_by_mbid("1dc4c347-a1db-32aa-b14f-bc9cc507b843") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_artist_by_name_disabled(): + repo = _make_repo(enabled=False) + result = await repo.search_artist_by_name("Coldplay") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_album_by_name_disabled(): + repo = _make_repo(enabled=False) + result = await repo.search_album_by_name("Coldplay", "Parachutes") + assert result is None + repo._client.get.assert_not_called() + + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_empty(): + repo = _make_repo() + result = await repo.get_artist_by_mbid("") + assert result is None + + +@pytest.mark.asyncio +async def test_get_album_by_mbid_empty(): + repo = _make_repo() + result = await repo.get_album_by_mbid("") + assert result is None + + +@pytest.mark.asyncio +async def test_search_artist_by_name_empty(): + repo = _make_repo() + result = await repo.search_artist_by_name("") + assert result is None + + +@pytest.mark.asyncio +async def test_search_album_by_name_empty_artist(): + repo = _make_repo() + result = await repo.search_album_by_name("", "Parachutes") + assert result is None + + +@pytest.mark.asyncio +async def test_search_album_by_name_empty_album(): + repo = _make_repo() + result = await repo.search_album_by_name("Coldplay", "") + assert result is None + + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_found(): + repo = _make_repo() + response = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + assert result is not None + assert isinstance(result, AudioDBArtistResponse) + assert result.strArtist == "Coldplay" + assert result.strArtistThumb == "https://r2.theaudiodb.com/images/artist/thumb/coldplay.jpg" + assert result.strMusicBrainzID == "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +@pytest.mark.asyncio +async def test_get_album_by_mbid_found(): + repo = _make_repo() + response = _mock_response(200, {"album": [SAMPLE_ALBUM_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_album_by_mbid("1dc4c347-a1db-32aa-b14f-bc9cc507b843") + + assert result is not None + assert isinstance(result, AudioDBAlbumResponse) + assert result.strAlbum == "Parachutes" + assert result.strAlbumThumb == "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg" + + +@pytest.mark.asyncio +async def test_search_artist_by_name_found(): + repo = _make_repo() + response = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.search_artist_by_name("Coldplay") + + assert result is not None + assert isinstance(result, AudioDBArtistResponse) + assert result.strArtist == "Coldplay" + + +@pytest.mark.asyncio +async def test_search_album_by_name_found(): + repo = _make_repo() + response = _mock_response(200, {"album": [SAMPLE_ALBUM_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.search_album_by_name("Coldplay", "Parachutes") + + assert result is not None + assert isinstance(result, AudioDBAlbumResponse) + assert result.strAlbum == "Parachutes" + + +# These fixtures include many extra fields from real AudioDB responses +# to ensure unknown fields are silently ignored, not rejected. + +@pytest.mark.asyncio +async def test_full_payload_artist_parses_successfully(): + repo = _make_repo() + response = _mock_response(200, {"artists": [FULL_PAYLOAD_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + assert result is not None + assert isinstance(result, AudioDBArtistResponse) + assert result.strArtist == "Coldplay" + assert result.idArtist == "111239" + assert result.strArtistThumb == "https://r2.theaudiodb.com/images/artist/thumb/coldplay.jpg" + assert result.strMusicBrainzID == "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +@pytest.mark.asyncio +async def test_full_payload_album_parses_successfully(): + repo = _make_repo() + response = _mock_response(200, {"album": [FULL_PAYLOAD_ALBUM_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_album_by_mbid("1dc4c347-a1db-32aa-b14f-bc9cc507b843") + + assert result is not None + assert isinstance(result, AudioDBAlbumResponse) + assert result.strAlbum == "Parachutes" + assert result.idAlbum == "2115888" + assert result.strAlbumThumb == "https://r2.theaudiodb.com/images/album/thumb/parachutes.jpg" + assert result.strMusicBrainzID == "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_not_found_null(): + repo = _make_repo() + response = _mock_response(200, {"artists": None}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("00000000-0000-0000-0000-000000000000") + assert result is None + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_not_found_empty(): + repo = _make_repo() + response = _mock_response(200, {"artists": []}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("00000000-0000-0000-0000-000000000000") + assert result is None + + +@pytest.mark.asyncio +async def test_get_album_by_mbid_not_found(): + repo = _make_repo() + response = _mock_response(200, {"album": None}) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_album_by_mbid("00000000-0000-0000-0000-000000000000") + assert result is None + + + +@pytest.mark.asyncio +async def test_request_429(caplog): + repo = _make_repo() + response = _mock_response(429) + repo._client.get = AsyncMock(return_value=response) + + with caplog.at_level("WARNING"), pytest.raises(RateLimitedError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert repo._client.get.call_count == 3 + assert _audiodb_circuit_breaker.failure_count == 3 + ratelimit_logs = [r.message for r in caplog.records if "audiodb.ratelimit" in r.message] + assert len(ratelimit_logs) == 3 + assert all("retry_after_s=60" in msg for msg in ratelimit_logs) + + +@pytest.mark.asyncio +async def test_request_500(): + repo = _make_repo() + response = _mock_response(500) + repo._client.get = AsyncMock(return_value=response) + + with pytest.raises(ExternalServiceError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + +@pytest.mark.asyncio +async def test_request_404_returns_none(): + repo = _make_repo() + response = _mock_response(404) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert result is None + + +@pytest.mark.asyncio +async def test_request_timeout(): + repo = _make_repo() + repo._client.get = AsyncMock(side_effect=httpx.ReadTimeout("timed out")) + + with pytest.raises(ExternalServiceError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + +@pytest.mark.asyncio +async def test_request_connection_error(): + repo = _make_repo() + repo._client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused")) + + with pytest.raises(ExternalServiceError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + + +@pytest.mark.asyncio +async def test_circuit_breaker_resets(): + _audiodb_circuit_breaker.record_failure() + _audiodb_circuit_breaker.record_failure() + _audiodb_circuit_breaker.record_failure() + _audiodb_circuit_breaker.record_failure() + _audiodb_circuit_breaker.record_failure() + assert _audiodb_circuit_breaker.is_open() + + AudioDBRepository.reset_circuit_breaker() + assert not _audiodb_circuit_breaker.is_open() + + + +def test_rate_limiter_free_tier(): + limiter = _make_rate_limiter(premium=False) + assert limiter.rate == 0.5 + assert limiter.capacity == 2 + + +def test_rate_limiter_premium_tier(): + limiter = _make_rate_limiter(premium=True) + assert limiter.rate == 5.0 + assert limiter.capacity == 10 + + +def test_repo_uses_free_limiter(): + repo = _make_repo(api_key=AUDIODB_FREE_KEY, premium=False) + assert repo._rate_limiter.rate == 0.5 + + +def test_repo_uses_premium_limiter(): + repo = _make_repo(premium=True) + assert repo._rate_limiter.rate == 5.0 + + +def test_repo_custom_key_uses_free_limiter_by_default(): + repo = _make_repo(api_key="custom_key_abc") + assert repo._rate_limiter.rate == 0.5 + + + +@pytest.mark.asyncio +async def test_url_contains_api_key_in_path(): + repo = _make_repo(api_key="my_test_key") + response = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + call_args = repo._client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1].get("url", "") + assert "my_test_key" in url + assert "artist-mb.php" in url + + +@pytest.mark.asyncio +async def test_search_album_passes_both_params(): + repo = _make_repo() + response = _mock_response(200, {"album": [SAMPLE_ALBUM_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + await repo.search_album_by_name("Coldplay", "Parachutes") + + call_args = repo._client.get.call_args + params = call_args[1].get("params", {}) if call_args[1] else {} + assert params.get("s") == "Coldplay" + assert params.get("a") == "Parachutes" + + + +def test_half_open_requires_two_successes_to_close(): + breaker = CircuitBreaker(failure_threshold=5, success_threshold=2, timeout=0.0, name="test") + for _ in range(5): + breaker.record_failure() + assert breaker.is_open() is False # timeout=0 → auto-transitions to HALF_OPEN + assert breaker.state.value == "half_open" + + breaker.record_success() + assert breaker.state.value == "half_open" + + breaker.record_success() + assert breaker.state.value == "closed" + + +def test_half_open_single_success_does_not_close(): + breaker = CircuitBreaker(failure_threshold=3, success_threshold=2, timeout=0.0, name="test2") + for _ in range(3): + breaker.record_failure() + breaker.is_open() # triggers HALF_OPEN transition + assert breaker.state.value == "half_open" + + breaker.record_success() + assert breaker.state.value == "half_open" + + breaker.record_failure() + assert breaker.state.value == "open" + + + +@pytest.mark.asyncio +async def test_get_artist_by_mbid_schema_error(): + repo = _make_repo() + bad_data = {"artists": [{"unexpected_field": "value"}]} + response = _mock_response(200, bad_data) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert result is None + + +@pytest.mark.asyncio +async def test_get_album_by_mbid_schema_error(): + repo = _make_repo() + bad_data = {"album": [{"unexpected_field": "value"}]} + response = _mock_response(200, bad_data) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.get_album_by_mbid("1dc4c347-a1db-32aa-b14f-bc9cc507b843") + assert result is None + + +@pytest.mark.asyncio +async def test_search_artist_schema_error(): + repo = _make_repo() + bad_data = {"artists": [{"unexpected_field": "value"}]} + response = _mock_response(200, bad_data) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.search_artist_by_name("Coldplay") + assert result is None + + +@pytest.mark.asyncio +async def test_search_album_schema_error(): + repo = _make_repo() + bad_data = {"album": [{"unexpected_field": "value"}]} + response = _mock_response(200, bad_data) + repo._client.get = AsyncMock(return_value=response) + + result = await repo.search_album_by_name("Coldplay", "Parachutes") + assert result is None + + + +@pytest.mark.asyncio +async def test_circuit_open_returns_none_for_artist_mbid(): + for _ in range(5): + _audiodb_circuit_breaker.record_failure() + assert _audiodb_circuit_breaker.is_open() + + repo = _make_repo() + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_circuit_open_returns_none_for_album_mbid(): + for _ in range(5): + _audiodb_circuit_breaker.record_failure() + assert _audiodb_circuit_breaker.is_open() + + repo = _make_repo() + result = await repo.get_album_by_mbid("1dc4c347-a1db-32aa-b14f-bc9cc507b843") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_circuit_open_returns_none_for_artist_search(): + for _ in range(5): + _audiodb_circuit_breaker.record_failure() + assert _audiodb_circuit_breaker.is_open() + + repo = _make_repo() + result = await repo.search_artist_by_name("Coldplay") + assert result is None + repo._client.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_circuit_open_returns_none_for_album_search(): + for _ in range(5): + _audiodb_circuit_breaker.record_failure() + assert _audiodb_circuit_breaker.is_open() + + repo = _make_repo() + result = await repo.search_album_by_name("Coldplay", "Parachutes") + assert result is None + repo._client.get.assert_not_called() + + + +@pytest.mark.asyncio +async def test_retries_on_transient_error(): + repo = _make_repo() + fail_resp = _mock_response(500) + ok_resp = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(side_effect=[fail_resp, fail_resp, ok_resp]) + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert result is not None + assert result.strArtist == "Coldplay" + assert repo._client.get.call_count == 3 + + +@pytest.mark.asyncio +async def test_retries_exhausted_raises(): + repo = _make_repo() + fail_resp = _mock_response(500) + repo._client.get = AsyncMock(return_value=fail_resp) + + with pytest.raises(ExternalServiceError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + assert repo._client.get.call_count == 3 + + +@pytest.mark.asyncio +async def test_rate_limit_failures_open_circuit_and_short_circuit_next_lookup(): + repo = _make_repo() + response = _mock_response(429) + repo._client.get = AsyncMock(return_value=response) + + for _ in range(2): + with pytest.raises(RateLimitedError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + assert _audiodb_circuit_breaker.is_open() + call_count_before = repo._client.get.call_count + + result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + assert result is None + assert repo._client.get.call_count == call_count_before + + +@pytest.mark.asyncio +async def test_audiodb_specific_circuit_state_change_logs(caplog): + repo = _make_repo() + fail_resp = _mock_response(500) + success_resp = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=fail_resp) + + with caplog.at_level("INFO"): + for _ in range(2): + with pytest.raises(ExternalServiceError): + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + _audiodb_circuit_breaker.last_failure_time -= _audiodb_circuit_breaker.timeout + 1 + repo._client.get = AsyncMock(return_value=success_resp) + + first_result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + second_result = await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + assert first_result is not None + assert second_result is not None + + state_change_logs = [record.message for record in caplog.records if record.message.startswith("audiodb.circuit_state_change")] + assert any("state=open" in message for message in state_change_logs) + assert any("state=half_open" in message for message in state_change_logs) + assert any("state=closed" in message for message in state_change_logs) + + + + +class TestEffectiveApiKey: + def test_prefers_settings_key_over_constructor(self): + repo = _make_repo(api_key="constructor_key") + repo._preferences_service.get_advanced_settings.return_value = ( + _make_advanced_settings(api_key="settings_key") + ) + assert repo._effective_api_key() == "settings_key" + + def test_falls_back_to_constructor_when_settings_empty(self): + repo = _make_repo(api_key="constructor_key") + repo._preferences_service.get_advanced_settings.return_value = ( + _make_advanced_settings(api_key="") + ) + assert repo._effective_api_key() == "constructor_key" + + def test_falls_back_to_constructor_when_settings_whitespace(self): + repo = _make_repo(api_key="constructor_key") + repo._preferences_service.get_advanced_settings.return_value = ( + _make_advanced_settings(api_key=" ") + ) + assert repo._effective_api_key() == "constructor_key" + + @pytest.mark.asyncio + async def test_request_uses_settings_key_not_constructor(self): + repo = _make_repo(api_key="constructor_key") + repo._preferences_service.get_advanced_settings.return_value = ( + _make_advanced_settings(api_key="settings_key") + ) + response = _mock_response(200, {"artists": [SAMPLE_ARTIST_DATA]}) + repo._client.get = AsyncMock(return_value=response) + + await repo.get_artist_by_mbid("cc197bad-dc9c-440d-a5b5-d52ba2e14234") + + url = repo._client.get.call_args[0][0] + assert "settings_key" in url + assert "constructor_key" not in url diff --git a/backend/tests/repositories/test_coverart_album_fetcher.py b/backend/tests/repositories/test_coverart_album_fetcher.py new file mode 100644 index 0000000..f709efe --- /dev/null +++ b/backend/tests/repositories/test_coverart_album_fetcher.py @@ -0,0 +1,63 @@ +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from infrastructure.queue.priority_queue import RequestPriority +from repositories.coverart_album import AlbumCoverFetcher + + +@pytest.mark.asyncio +async def test_release_local_sources_prefers_lidarr_before_jellyfin(): + mb_repo = MagicMock() + mb_repo.get_release_group_id_from_release = AsyncMock(return_value='rg-id') + + fetcher = AlbumCoverFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + lidarr_repo=MagicMock(), + mb_repo=mb_repo, + jellyfin_repo=MagicMock(), + ) + + fetcher._fetch_from_lidarr = AsyncMock(return_value=(b'img', 'image/jpeg', 'lidarr')) + fetcher._fetch_from_jellyfin = AsyncMock(return_value=(b'img2', 'image/jpeg', 'jellyfin')) + + result = await fetcher._fetch_release_local_sources( + 'release-id', + Path('/tmp/cover.bin'), + '500', + ) + + assert result is not None + assert result[2] == 'lidarr' + fetcher._fetch_from_lidarr.assert_awaited_once_with('rg-id', Path('/tmp/cover.bin'), size=500, priority=RequestPriority.IMAGE_FETCH) + fetcher._fetch_from_jellyfin.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_release_local_sources_uses_jellyfin_when_lidarr_misses(): + mb_repo = MagicMock() + mb_repo.get_release_group_id_from_release = AsyncMock(return_value='rg-id') + + fetcher = AlbumCoverFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + lidarr_repo=MagicMock(), + mb_repo=mb_repo, + jellyfin_repo=MagicMock(), + ) + + fetcher._fetch_from_lidarr = AsyncMock(return_value=None) + fetcher._fetch_from_jellyfin = AsyncMock(return_value=(b'img2', 'image/jpeg', 'jellyfin')) + + result = await fetcher._fetch_release_local_sources( + 'release-id', + Path('/tmp/cover.bin'), + '500', + ) + + assert result is not None + assert result[2] == 'jellyfin' + fetcher._fetch_from_lidarr.assert_awaited_once_with('rg-id', Path('/tmp/cover.bin'), size=500, priority=RequestPriority.IMAGE_FETCH) + fetcher._fetch_from_jellyfin.assert_awaited_once_with('rg-id', Path('/tmp/cover.bin'), priority=RequestPriority.IMAGE_FETCH) diff --git a/backend/tests/repositories/test_coverart_audiodb_provider.py b/backend/tests/repositories/test_coverart_audiodb_provider.py new file mode 100644 index 0000000..2df6829 --- /dev/null +++ b/backend/tests/repositories/test_coverart_audiodb_provider.py @@ -0,0 +1,370 @@ +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from infrastructure.queue.priority_queue import RequestPriority +from repositories.audiodb_models import AudioDBAlbumImages, AudioDBArtistImages +from repositories.coverart_album import AlbumCoverFetcher +from repositories.coverart_artist import ArtistImageFetcher, TransientImageFetchError + + +def _response( + status_code: int = 200, + content_type: str = "image/jpeg", + content: bytes = b"img", +) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.headers = {"content-type": content_type} + response.content = content + return response + + +@pytest.mark.asyncio +async def test_album_fetch_from_audiodb_downloads_and_writes_cache(): + http_get = AsyncMock(return_value=_response()) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg") + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin")) + + assert result == (b"img", "image/jpeg", "audiodb") + audiodb_service.fetch_and_cache_album_images.assert_awaited_once_with("release-group-id") + http_get.assert_awaited_once() + await asyncio.sleep(0) + assert write_cache.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cached_value", + [ + None, + AudioDBAlbumImages(is_negative=True), + AudioDBAlbumImages(album_thumb_url=None), + ], +) +async def test_album_fetch_from_audiodb_skips_when_cache_not_usable(cached_value): + http_get = AsyncMock(return_value=_response()) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock(return_value=cached_value) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin")) + + assert result is None + http_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_album_fetch_from_audiodb_returns_none_when_service_missing(): + fetcher = AlbumCoverFetcher( + http_get_fn=AsyncMock(return_value=_response()), + write_cache_fn=AsyncMock(), + audiodb_service=None, + ) + + result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin")) + + assert result is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "status_code,content_type", + [ + (404, "image/jpeg"), + (200, "application/json"), + ], +) +async def test_album_fetch_from_audiodb_returns_none_for_invalid_download(status_code, content_type): + http_get = AsyncMock(return_value=_response(status_code=status_code, content_type=content_type)) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg") + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin")) + + assert result is None + await asyncio.sleep(0) + assert write_cache.await_count == 0 + + +@pytest.mark.asyncio +async def test_album_fetch_from_audiodb_returns_none_on_exception(): + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages(album_thumb_url="https://r2.theaudiodb.com/album.jpg") + ) + http_get = AsyncMock(side_effect=RuntimeError("boom")) + write_cache = AsyncMock() + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("release-group-id", Path("/tmp/album.bin")) + + assert result is None + await asyncio.sleep(0) + assert write_cache.await_count == 0 + + +@pytest.mark.asyncio +async def test_release_cover_skips_audiodb_when_release_group_id_unavailable(): + http_get = AsyncMock(return_value=_response(content=b"cover")) + mb_repo = MagicMock() + mb_repo.get_release_group_id_from_release = AsyncMock(return_value=None) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock() + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + mb_repo=mb_repo, + audiodb_service=audiodb_service, + ) + fetcher._fetch_release_local_sources = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_cover("release-id", None, Path("/tmp/release.bin")) + + assert result == (b"cover", "image/jpeg", "cover-art-archive") + audiodb_service.fetch_and_cache_album_images.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_album_release_group_chain_uses_audiodb_before_coverartarchive(): + http_get = AsyncMock() + fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=MagicMock()) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb")) + + result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin")) + + assert result is not None and result[2] == "audiodb" + fetcher._fetch_from_audiodb.assert_awaited_once_with("release-group-id", Path("/tmp/album.bin"), priority=RequestPriority.IMAGE_FETCH) + http_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_album_release_group_chain_falls_back_to_coverartarchive_when_audiodb_misses(): + http_get = AsyncMock(return_value=_response(content=b"cover")) + fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=MagicMock()) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._fetch_from_audiodb = AsyncMock(return_value=None) + fetcher._get_cover_from_best_release = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin")) + + assert result == (b"cover", "image/jpeg", "cover-art-archive") + fetcher._fetch_from_audiodb.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_release_cover_uses_audiodb_before_coverartarchive(): + http_get = AsyncMock() + mb_repo = MagicMock() + mb_repo.get_release_group_id_from_release = AsyncMock(return_value="release-group-id") + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + mb_repo=mb_repo, + audiodb_service=MagicMock(), + ) + fetcher._fetch_release_local_sources = AsyncMock(return_value=None) + fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb")) + + result = await fetcher.fetch_release_cover("release-id", None, Path("/tmp/release.bin")) + + assert result is not None and result[2] == "audiodb" + fetcher._fetch_from_audiodb.assert_awaited_once_with("release-group-id", Path("/tmp/release.bin"), priority=RequestPriority.IMAGE_FETCH) + http_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_release_group_cover_skips_audiodb_when_service_missing(): + http_get = AsyncMock(return_value=_response(content=b"cover")) + fetcher = AlbumCoverFetcher(http_get_fn=http_get, write_cache_fn=AsyncMock(), audiodb_service=None) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._get_cover_from_best_release = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_group_cover("release-group-id", None, Path("/tmp/album.bin")) + + assert result == (b"cover", "image/jpeg", "cover-art-archive") + + +@pytest.mark.asyncio +async def test_artist_fetch_from_audiodb_downloads_and_writes_cache(): + http_get = AsyncMock(return_value=_response()) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock( + return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg") + ) + fetcher = ArtistImageFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin")) + + assert result == (b"img", "image/jpeg", "audiodb") + audiodb_service.fetch_and_cache_artist_images.assert_awaited_once_with("artist-id") + http_get.assert_awaited_once() + await asyncio.sleep(0) + assert write_cache.await_count == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cached_value", + [ + None, + AudioDBArtistImages(is_negative=True), + AudioDBArtistImages(thumb_url=None), + ], +) +async def test_artist_fetch_from_audiodb_skips_when_cache_not_usable(cached_value): + http_get = AsyncMock(return_value=_response()) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock(return_value=cached_value) + fetcher = ArtistImageFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin")) + + assert result is None + http_get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_artist_fetch_from_audiodb_reraises_transient_exception(): + http_get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock( + return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg") + ) + fetcher = ArtistImageFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + + with pytest.raises(httpx.TimeoutException): + await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin")) + + +@pytest.mark.asyncio +async def test_artist_fetch_from_audiodb_handles_non_transient_exception(): + http_get = AsyncMock(side_effect=RuntimeError("boom")) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock( + return_value=AudioDBArtistImages(thumb_url="https://r2.theaudiodb.com/artist.jpg") + ) + fetcher = ArtistImageFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + + result = await fetcher._fetch_from_audiodb("artist-id", Path("/tmp/artist.bin")) + + assert result is None + + +@pytest.mark.asyncio +async def test_artist_chain_uses_audiodb_before_wikidata(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=MagicMock(), + ) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_audiodb = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb")) + fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata")) + + result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin")) + + assert result is not None and result[2] == "audiodb" + fetcher._fetch_from_wikidata.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_artist_chain_falls_back_to_wikidata_after_audiodb_miss(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=MagicMock(), + ) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_audiodb = AsyncMock(return_value=None) + fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata")) + + result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin")) + + assert result is not None and result[2] == "wikidata" + fetcher._fetch_from_wikidata.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_artist_chain_skips_audiodb_when_service_missing(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=None, + ) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_wikidata = AsyncMock(return_value=(b"wiki", "image/jpeg", "wikidata")) + + result = await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin")) + + assert result is not None and result[2] == "wikidata" + + +@pytest.mark.asyncio +async def test_artist_chain_raises_transient_when_audiodb_fails_transiently_and_no_fallback_result(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=MagicMock(), + ) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_audiodb = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + fetcher._fetch_from_wikidata = AsyncMock(return_value=None) + + with pytest.raises(TransientImageFetchError): + await fetcher.fetch_artist_image("artist-id", 300, Path("/tmp/artist.bin")) diff --git a/backend/tests/repositories/test_coverart_disconnect.py b/backend/tests/repositories/test_coverart_disconnect.py new file mode 100644 index 0000000..39c8807 --- /dev/null +++ b/backend/tests/repositories/test_coverart_disconnect.py @@ -0,0 +1,106 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from pathlib import Path + +from core.exceptions import ClientDisconnectedError +from infrastructure.queue.priority_queue import RequestPriority +from repositories.coverart_artist import ArtistImageFetcher +from repositories.coverart_album import AlbumCoverFetcher + + +@pytest.mark.anyio +async def test_artist_fetcher_bails_before_audiodb(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + ) + fetcher._fetch_from_audiodb = AsyncMock() + + is_disconnected = AsyncMock(return_value=True) + with pytest.raises(ClientDisconnectedError): + await fetcher.fetch_artist_image( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + None, + Path("/tmp/test"), + is_disconnected=is_disconnected, + ) + fetcher._fetch_from_audiodb.assert_not_awaited() + + +@pytest.mark.anyio +async def test_artist_fetcher_bails_between_audiodb_and_local(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + ) + fetcher._fetch_from_audiodb = AsyncMock(return_value=None) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + + call_count = 0 + + async def disconnect_after_first(): + nonlocal call_count + call_count += 1 + return call_count > 1 + + with pytest.raises(ClientDisconnectedError): + await fetcher.fetch_artist_image( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + None, + Path("/tmp/test"), + is_disconnected=disconnect_after_first, + ) + fetcher._fetch_from_audiodb.assert_awaited_once() + fetcher._fetch_local_sources.assert_not_awaited() + + +@pytest.mark.anyio +async def test_album_fetcher_bails_before_caa(): + fetcher = AlbumCoverFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + ) + fetcher._fetch_from_audiodb = AsyncMock(return_value=None) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + + call_count = 0 + + async def disconnect_after_two(): + nonlocal call_count + call_count += 1 + return call_count > 2 + + with pytest.raises(ClientDisconnectedError): + await fetcher.fetch_release_group_cover( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "500", + Path("/tmp/test"), + is_disconnected=disconnect_after_two, + ) + fetcher._fetch_from_audiodb.assert_awaited_once() + + +@pytest.mark.anyio +async def test_fetcher_completes_when_disconnect_is_none(): + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + ) + fetcher._fetch_from_audiodb = AsyncMock(return_value=None) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_wikidata = AsyncMock(return_value=None) + + result = await fetcher.fetch_artist_image( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + None, + Path("/tmp/test"), + is_disconnected=None, + ) + assert result is None + fetcher._fetch_from_audiodb.assert_awaited_once() + fetcher._fetch_local_sources.assert_awaited_once() + fetcher._fetch_from_wikidata.assert_awaited_once() diff --git a/backend/tests/repositories/test_coverart_disk_cache.py b/backend/tests/repositories/test_coverart_disk_cache.py new file mode 100644 index 0000000..f2cee83 --- /dev/null +++ b/backend/tests/repositories/test_coverart_disk_cache.py @@ -0,0 +1,53 @@ +import asyncio +import hashlib + +import pytest + +from repositories.coverart_disk_cache import CoverDiskCache + + +@pytest.mark.asyncio +async def test_write_persists_content_hash(tmp_path): + cache = CoverDiskCache(tmp_path) + file_path = cache.get_file_path("rg_test", "500") + content = b"image-bytes-1" + + await cache.write(file_path, content, "image/jpeg", {"source": "cover-art-archive"}) + + content_hash = await cache.get_content_hash(file_path) + assert content_hash == hashlib.sha1(content).hexdigest() + + +@pytest.mark.asyncio +async def test_enforce_size_limit_evicts_oldest_non_monitored(tmp_path): + cache = CoverDiskCache(tmp_path, max_size_mb=1) + + first_path = cache.get_file_path("rg_first", "500") + second_path = cache.get_file_path("rg_second", "500") + + content = b"a" * (700 * 1024) + + await cache.write(first_path, content, "image/jpeg", {"source": "cover-art-archive"}) + await asyncio.sleep(0.02) + await cache.write(second_path, content, "image/jpeg", {"source": "cover-art-archive"}) + + await cache.enforce_size_limit(force=True) + + assert not first_path.exists() + assert second_path.exists() + + +@pytest.mark.asyncio +async def test_enforce_size_limit_preserves_monitored_entries(tmp_path): + cache = CoverDiskCache(tmp_path, max_size_mb=1) + + monitored_path = cache.get_file_path("rg_monitored", "500") + transient_path = cache.get_file_path("rg_transient", "500") + + await cache.write(monitored_path, b"m" * (800 * 1024), "image/jpeg", is_monitored=True) + await cache.write(transient_path, b"t" * (400 * 1024), "image/jpeg") + + await cache.enforce_size_limit(force=True) + + assert monitored_path.exists() + assert not transient_path.exists() diff --git a/backend/tests/repositories/test_coverart_repository_memory_cache.py b/backend/tests/repositories/test_coverart_repository_memory_cache.py new file mode 100644 index 0000000..c8366b4 --- /dev/null +++ b/backend/tests/repositories/test_coverart_repository_memory_cache.py @@ -0,0 +1,119 @@ +import hashlib +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +import repositories.coverart_repository as coverart_repository_module +from repositories.coverart_artist import TransientImageFetchError +from repositories.coverart_repository import CoverArtRepository + + +RELEASE_GROUP_MBID = '11111111-1111-1111-1111-111111111111' +RELEASE_MBID = '22222222-2222-2222-2222-222222222222' +ARTIST_MBID = '33333333-3333-3333-3333-333333333333' + + +@pytest.mark.asyncio +async def test_release_group_disk_hit_is_promoted_to_memory_and_skips_second_disk_read(tmp_path): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + repo._disk_cache.read = AsyncMock( + return_value=(b'disk-image', 'image/jpeg', {'source': 'cover-art-archive'}) + ) + repo._disk_cache.is_negative = AsyncMock(return_value=False) + + first = await repo.get_release_group_cover(RELEASE_GROUP_MBID, size='500') + second = await repo.get_release_group_cover(RELEASE_GROUP_MBID, size='500') + + assert first == second == (b'disk-image', 'image/jpeg', 'cover-art-archive') + assert repo._disk_cache.read.await_count == 1 + + +@pytest.mark.asyncio +async def test_release_cover_etag_uses_memory_before_disk(tmp_path): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + identifier = f'rel_{RELEASE_MBID}' + suffix = '500' + cache_key = repo._memory_cache_key(identifier, suffix) + + await repo._cover_memory_cache.set(cache_key, b'cached-image', 'image/jpeg', 'cover-art-archive') + repo._disk_cache.get_content_hash = AsyncMock(return_value='disk-hash') + + etag = await repo.get_release_cover_etag(RELEASE_MBID, size=suffix) + + assert etag == hashlib.sha1(b'cached-image').hexdigest() + repo._disk_cache.get_content_hash.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_non_image_payload_is_not_stored_in_memory_cache(tmp_path): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + repo._disk_cache.read = AsyncMock(return_value=(b'not-image', 'text/plain', {'source': 'disk'})) + repo._disk_cache.is_negative = AsyncMock(return_value=False) + + first = await repo.get_release_cover(RELEASE_MBID, size='500') + second = await repo.get_release_cover(RELEASE_MBID, size='500') + + assert first == second == (b'not-image', 'text/plain', 'disk') + assert repo._disk_cache.read.await_count == 2 + + +@pytest.mark.asyncio +async def test_artist_transient_fetch_failure_does_not_write_negative_cache(tmp_path, monkeypatch): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + repo._disk_cache.read = AsyncMock(return_value=None) + repo._disk_cache.is_negative = AsyncMock(return_value=False) + repo._disk_cache.write_negative = AsyncMock() + + async def dedupe_raise_transient(_key, _factory): + raise TransientImageFetchError('transient fetch failure') + + monkeypatch.setattr(coverart_repository_module._deduplicator, 'dedupe', dedupe_raise_transient) + + result = await repo.get_artist_image(ARTIST_MBID, size=500) + + assert result is None + repo._disk_cache.write_negative.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_artist_definitive_miss_writes_negative_cache(tmp_path, monkeypatch): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + repo._disk_cache.read = AsyncMock(return_value=None) + repo._disk_cache.is_negative = AsyncMock(return_value=False) + repo._disk_cache.write_negative = AsyncMock() + + async def dedupe_return_none(_key, _factory): + return None + + monkeypatch.setattr(coverart_repository_module._deduplicator, 'dedupe', dedupe_return_none) + + result = await repo.get_artist_image(ARTIST_MBID, size=500) + + assert result is None + repo._disk_cache.write_negative.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_artist_fetcher_uses_non_default_user_agent_for_external_requests(tmp_path): + async with httpx.AsyncClient() as http_client: + cache = MagicMock() + repo = CoverArtRepository(http_client=http_client, cache=cache, cache_dir=tmp_path) + + assert repo._artist_fetcher._external_headers is not None + assert repo._artist_fetcher._external_headers['User-Agent'].startswith('Musicseerr/') diff --git a/backend/tests/repositories/test_jellyfin_playback_url.py b/backend/tests/repositories/test_jellyfin_playback_url.py new file mode 100644 index 0000000..64991b9 --- /dev/null +++ b/backend/tests/repositories/test_jellyfin_playback_url.py @@ -0,0 +1,200 @@ +import pytest +from unittest.mock import AsyncMock + +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError +from infrastructure.constants import BROWSER_AUDIO_DEVICE_PROFILE +from repositories.jellyfin_repository import JellyfinRepository + + +@pytest.fixture +def repo() -> JellyfinRepository: + http_client = AsyncMock() + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return JellyfinRepository( + http_client=http_client, + cache=cache, + base_url="http://jellyfin:8096", + api_key="test-api-key", + user_id="user-123", + ) + + +@pytest.mark.asyncio +async def test_get_playback_url_direct_play(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-1", + "MediaSources": [ + {"SupportsDirectPlay": True, "SupportsDirectStream": True} + ], + } + ) + + result = await repo.get_playback_url("item-1") + + assert result.url == "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-api-key" + assert result.seekable is True + assert result.play_session_id == "sess-1" + assert result.play_method == "DirectPlay" + + +@pytest.mark.asyncio +async def test_get_playback_url_transcode(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-2", + "MediaSources": [ + { + "SupportsDirectPlay": False, + "SupportsDirectStream": False, + "TranscodingUrl": "/Audio/item-2/universal?container=opus&api_key=embedded-key", + } + ], + } + ) + + result = await repo.get_playback_url("item-2") + + assert result.url == "http://jellyfin:8096/Audio/item-2/universal?container=opus&api_key=embedded-key" + assert result.seekable is False + assert result.play_session_id == "sess-2" + assert result.play_method == "Transcode" + + +@pytest.mark.asyncio +async def test_get_playback_url_direct_stream(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-ds", + "MediaSources": [ + {"SupportsDirectPlay": False, "SupportsDirectStream": True} + ], + } + ) + + result = await repo.get_playback_url("item-direct-stream") + + assert result.url == "http://jellyfin:8096/Audio/item-direct-stream/stream?static=true&api_key=test-api-key" + assert result.seekable is True + assert result.play_method == "DirectStream" + + +@pytest.mark.asyncio +async def test_get_playback_url_uses_post_with_device_profile(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-3", + "MediaSources": [ + {"SupportsDirectPlay": True, "SupportsDirectStream": True} + ], + } + ) + + await repo.get_playback_url("item-3") + + repo._request.assert_awaited_once_with( + "POST", + "/Items/item-3/PlaybackInfo", + params={"userId": "user-123"}, + json_data={"DeviceProfile": BROWSER_AUDIO_DEVICE_PROFILE}, + ) + + +@pytest.mark.asyncio +async def test_get_playback_url_not_configured_raises(): + http_client = AsyncMock() + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + unconfigured_repo = JellyfinRepository( + http_client=http_client, + cache=cache, + base_url="", + api_key="", + ) + + with pytest.raises(ExternalServiceError, match="not configured"): + await unconfigured_repo.get_playback_url("item-4") + + +@pytest.mark.asyncio +async def test_get_playback_url_missing_item_raises(repo: JellyfinRepository): + repo._request = AsyncMock(return_value=None) + + with pytest.raises(ResourceNotFoundError, match="Playback info not found"): + await repo.get_playback_url("bad-item") + + +@pytest.mark.asyncio +async def test_get_playback_url_missing_media_sources_raises(repo: JellyfinRepository): + repo._request = AsyncMock(return_value={"PlaySessionId": "sess-5", "MediaSources": []}) + + with pytest.raises(ExternalServiceError, match="missing media sources"): + await repo.get_playback_url("item-5") + + +@pytest.mark.asyncio +async def test_get_playback_url_playback_not_allowed_raises(repo: JellyfinRepository): + repo._request = AsyncMock(return_value={"ErrorCode": "NotAllowed"}) + + with pytest.raises(PlaybackNotAllowedError, match="NotAllowed"): + await repo.get_playback_url("item-6") + + +@pytest.mark.asyncio +async def test_get_playback_url_null_play_session_id_defaults_empty(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": None, + "MediaSources": [ + {"SupportsDirectPlay": True, "SupportsDirectStream": True} + ], + } + ) + + result = await repo.get_playback_url("item-7") + + assert result.play_session_id == "" + + +@pytest.mark.asyncio +async def test_get_playback_url_transcoding_url_keeps_embedded_api_key(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-8", + "MediaSources": [ + { + "SupportsDirectPlay": False, + "SupportsDirectStream": False, + "TranscodingUrl": "/audio/item-8/stream.opus?ApiKey=embedded&PlaySessionId=sess-8", + } + ], + } + ) + + result = await repo.get_playback_url("item-8") + + assert "ApiKey=embedded" in result.url + assert result.url.count("ApiKey=") == 1 + + +@pytest.mark.asyncio +async def test_get_playback_url_transcoding_url_absolute_is_used_as_is(repo: JellyfinRepository): + repo._request = AsyncMock( + return_value={ + "PlaySessionId": "sess-9", + "MediaSources": [ + { + "SupportsDirectPlay": False, + "SupportsDirectStream": False, + "TranscodingUrl": "https://jellyfin.example/audio/item-9/stream.opus?ApiKey=embedded", + } + ], + } + ) + + result = await repo.get_playback_url("item-9") + + assert result.url == "https://jellyfin.example/audio/item-9/stream.opus?ApiKey=embedded" diff --git a/backend/tests/repositories/test_lidarr_album_cache.py b/backend/tests/repositories/test_lidarr_album_cache.py new file mode 100644 index 0000000..cdb1a69 --- /dev/null +++ b/backend/tests/repositories/test_lidarr_album_cache.py @@ -0,0 +1,116 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx + +from core.config import Settings +from infrastructure.cache.memory_cache import InMemoryCache +from repositories.lidarr.album import LidarrAlbumRepository + + +def _make_settings() -> Settings: + settings = MagicMock(spec=Settings) + settings.lidarr_url = "http://localhost:8686" + settings.lidarr_api_key = "test-key" + settings.quality_profile_id = 1 + return settings + + +def _sample_album_data() -> list[dict]: + return [ + { + "id": 1, + "title": "Album One", + "foreignAlbumId": "aaaa-bbbb-cccc", + "monitored": True, + "images": [], + "artist": { + "artistName": "Artist A", + "foreignArtistId": "artist-a-mbid", + }, + } + ] + + +@pytest.fixture +def cache(): + return InMemoryCache(max_entries=100) + + +@pytest.fixture +def repo(cache): + settings = _make_settings() + http_client = AsyncMock(spec=httpx.AsyncClient) + return LidarrAlbumRepository(settings=settings, http_client=http_client, cache=cache) + + +class TestGetAllAlbumsCache: + @pytest.mark.asyncio + async def test_get_all_albums_uses_shared_raw_cache(self, repo): + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + first = await repo.get_all_albums() + second = await repo.get_all_albums() + + assert mock_get.await_count == 1 + assert first == second + assert len(first) == 1 + + +class TestAlbumMutationInvalidation: + @pytest.mark.asyncio + async def test_delete_album_invalidates_shared_and_derived_album_cache(self, repo): + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get, patch.object( + repo, "_delete", new_callable=AsyncMock + ) as mock_delete: + mock_get.return_value = _sample_album_data() + mock_delete.return_value = None + + await repo.get_all_albums() + assert mock_get.await_count == 1 + + deleted = await repo.delete_album(album_id=1, delete_files=False) + assert deleted is True + assert mock_delete.await_count == 1 + + await repo.get_all_albums() + assert mock_get.await_count == 2 + + +class TestGetAlbumTracksCoercion: + """Regression: Lidarr returns trackNumber as a string; get_album_tracks must coerce to int.""" + + @pytest.mark.asyncio + async def test_string_track_numbers_coerced_to_int(self, repo): + raw_tracks = [ + { + "trackNumber": "3", + "absoluteTrackNumber": 3, + "mediumNumber": "1", + "title": "Speed Kills", + "duration": 230000, + "trackFileId": 2618, + "hasFile": True, + }, + { + "trackNumber": "10", + "absoluteTrackNumber": 10, + "mediumNumber": 1, + "title": "Fresh Air", + "duration": 180000, + "trackFileId": 2625, + "hasFile": True, + }, + ] + with patch.object(repo, "_get", new_callable=AsyncMock, return_value=raw_tracks): + result = await repo.get_album_tracks(album_id=52) + + assert len(result) == 2 + assert all(isinstance(t["track_number"], int) for t in result) + assert all(isinstance(t["disc_number"], int) for t in result) + assert result[0]["track_number"] == 3 + assert result[1]["track_number"] == 10 + # Verify sorting is numeric (3 before 10), not lexicographic ("10" before "3") + assert result[0]["title"] == "Speed Kills" + assert result[1]["title"] == "Fresh Air" diff --git a/backend/tests/repositories/test_lidarr_library_cache.py b/backend/tests/repositories/test_lidarr_library_cache.py new file mode 100644 index 0000000..387b432 --- /dev/null +++ b/backend/tests/repositories/test_lidarr_library_cache.py @@ -0,0 +1,189 @@ +import asyncio +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +import httpx + +from core.config import Settings +from infrastructure.cache.memory_cache import InMemoryCache +from repositories.lidarr.library import LidarrLibraryRepository + + +def _make_settings() -> Settings: + settings = MagicMock(spec=Settings) + settings.lidarr_url = "http://localhost:8686" + settings.lidarr_api_key = "test-key" + return settings + + +def _sample_album_data() -> list[dict]: + return [ + { + "id": 1, + "title": "Album One", + "foreignAlbumId": "aaaa-bbbb-cccc", + "monitored": True, + "releaseDate": "2023-01-15", + "added": "2023-01-10T12:00:00Z", + "images": [], + "artist": { + "artistName": "Artist A", + "foreignArtistId": "artist-a-mbid", + }, + }, + { + "id": 2, + "title": "Album Two", + "foreignAlbumId": "dddd-eeee-ffff", + "monitored": True, + "releaseDate": "2024-06-01", + "added": "2024-06-01T08:00:00Z", + "images": [], + "artist": { + "artistName": "Artist B", + "foreignArtistId": "artist-b-mbid", + }, + }, + { + "id": 3, + "title": "Unmonitored Album", + "foreignAlbumId": "1111-2222-3333", + "monitored": False, + "releaseDate": "2020-03-01", + "added": "2020-03-01T00:00:00Z", + "images": [], + "artist": { + "artistName": "Artist C", + "foreignArtistId": "artist-c-mbid", + }, + }, + ] + + +@pytest.fixture +def cache(): + return InMemoryCache(max_entries=100) + + +@pytest.fixture +def repo(cache): + settings = _make_settings() + http_client = AsyncMock(spec=httpx.AsyncClient) + return LidarrLibraryRepository(settings=settings, http_client=http_client, cache=cache) + + +class TestGetLibraryCache: + @pytest.mark.asyncio + async def test_get_library_caches_result(self, repo): + """Second call should return cached result without hitting the API.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + first = await repo.get_library() + second = await repo.get_library() + + assert mock_get.await_count == 1 + assert len(first) == 2 + assert first == second + + @pytest.mark.asyncio + async def test_get_library_separate_cache_keys_for_unmonitored(self, repo): + """include_unmonitored=True and False use different cache keys.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + monitored_only = await repo.get_library(include_unmonitored=False) + all_albums = await repo.get_library(include_unmonitored=True) + + assert mock_get.await_count == 1 + assert len(monitored_only) == 2 + assert len(all_albums) == 3 + + +class TestGetArtistsFromLibraryCache: + @pytest.mark.asyncio + async def test_get_artists_caches_result(self, repo): + """Second call should return cached result without hitting the API.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + first = await repo.get_artists_from_library() + second = await repo.get_artists_from_library() + + assert mock_get.await_count == 1 + assert len(first) == 2 + assert first == second + + @pytest.mark.asyncio + async def test_get_artists_separate_cache_keys_for_unmonitored(self, repo): + """include_unmonitored=True and False use different cache keys.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + monitored_only = await repo.get_artists_from_library(include_unmonitored=False) + all_artists = await repo.get_artists_from_library(include_unmonitored=True) + + assert mock_get.await_count == 1 + assert len(monitored_only) == 2 + assert len(all_artists) == 3 + + +class TestCacheInvalidation: + @pytest.mark.asyncio + async def test_clear_prefix_invalidates_derived_but_keeps_raw_cache(self, repo, cache): + """Clearing library prefix should invalidate derived keys while reusing the raw shared cache.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + await repo.get_library() + await repo.get_artists_from_library() + assert mock_get.await_count == 1 + + await cache.clear_prefix("lidarr:library:") + + await repo.get_library() + await repo.get_artists_from_library() + assert mock_get.await_count == 1 + + +class TestSharedRawAlbumCache: + @pytest.mark.asyncio + async def test_concurrent_mbids_calls_deduplicate_raw_album_fetch(self, repo): + """Concurrent MBID calls should coalesce to one /api/v1/album request.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = [ + { + "foreignAlbumId": "aaaa", + "monitored": True, + "statistics": {"trackFileCount": 10}, + "releases": [], + }, + { + "foreignAlbumId": "bbbb", + "monitored": True, + "statistics": {"trackFileCount": 0}, + "releases": [], + }, + ] + + library_mbids, requested_mbids = await asyncio.gather( + repo.get_library_mbids(include_release_ids=False), + repo.get_requested_mbids(), + ) + + assert mock_get.await_count == 1 + assert library_mbids == {"aaaa"} + assert requested_mbids == {"bbbb"} + + @pytest.mark.asyncio + async def test_explicit_album_cache_invalidation_forces_refetch(self, repo): + """Base helper should clear raw cache so next read refetches /api/v1/album.""" + with patch.object(repo, "_get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = _sample_album_data() + + await repo.get_library() + assert mock_get.await_count == 1 + + await repo._invalidate_album_list_caches() + await repo.get_library() + assert mock_get.await_count == 2 diff --git a/backend/tests/repositories/test_navidrome_repository.py b/backend/tests/repositories/test_navidrome_repository.py new file mode 100644 index 0000000..4f58399 --- /dev/null +++ b/backend/tests/repositories/test_navidrome_repository.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import hashlib +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from core.exceptions import ExternalServiceError, NavidromeApiError, NavidromeAuthError +from repositories.navidrome_repository import _navidrome_circuit_breaker +from repositories.navidrome_models import ( + SubsonicAlbum, + SubsonicArtist, + SubsonicGenre, + SubsonicSearchResult, + SubsonicSong, + parse_album, + parse_artist, + parse_genre, + parse_song, + parse_subsonic_response, +) +from repositories.navidrome_repository import NavidromeRepository + + +def _make_cache() -> MagicMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + cache.clear_prefix = AsyncMock(return_value=0) + return cache + + +def _make_repo(configured: bool = True) -> tuple[NavidromeRepository, AsyncMock, MagicMock]: + client = AsyncMock(spec=httpx.AsyncClient) + cache = _make_cache() + repo = NavidromeRepository(http_client=client, cache=cache) + if configured: + repo.configure("http://navidrome:4533", "admin", "secret") + return repo, client, cache + + +def _ok_envelope(body: dict | None = None) -> dict: + resp: dict = {"subsonic-response": {"status": "ok", "version": "1.16.1"}} + if body: + resp["subsonic-response"].update(body) + return resp + + +def _mock_response(json_data: dict, status_code: int = 200) -> MagicMock: + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.json.return_value = json_data + return resp + + +class TestBuildAuthParams: + def test_contains_required_keys(self): + repo, _, _ = _make_repo() + params = repo._build_auth_params() + assert set(params.keys()) == {"u", "t", "s", "v", "c", "f"} + + def test_username_matches(self): + repo, _, _ = _make_repo() + params = repo._build_auth_params() + assert params["u"] == "admin" + assert params["v"] == "1.16.1" + assert params["c"] == "musicseerr" + assert params["f"] == "json" + + def test_token_is_correct_md5(self): + repo, _, _ = _make_repo() + params = repo._build_auth_params() + expected = hashlib.md5(("secret" + params["s"]).encode("utf-8")).hexdigest() + assert params["t"] == expected + + def test_fresh_salt_per_call(self): + repo, _, _ = _make_repo() + salts = {repo._build_auth_params()["s"] for _ in range(10)} + assert len(salts) > 1 + + +class TestParseSubsonicResponse: + def test_ok_status(self): + data = {"subsonic-response": {"status": "ok", "version": "1.16.1"}} + resp = parse_subsonic_response(data) + assert resp["status"] == "ok" + + def test_error_status_raises_api_error(self): + data = { + "subsonic-response": { + "status": "failed", + "error": {"code": 70, "message": "Not found"}, + } + } + with pytest.raises(NavidromeApiError, match="Not found"): + parse_subsonic_response(data) + + def test_auth_error_code_40(self): + data = { + "subsonic-response": { + "status": "failed", + "error": {"code": 40, "message": "Wrong creds"}, + } + } + with pytest.raises(NavidromeAuthError): + parse_subsonic_response(data) + + def test_auth_error_code_41(self): + data = { + "subsonic-response": { + "status": "failed", + "error": {"code": 41, "message": "Token expired"}, + } + } + with pytest.raises(NavidromeAuthError): + parse_subsonic_response(data) + + def test_missing_envelope_raises(self): + with pytest.raises(NavidromeApiError, match="Missing"): + parse_subsonic_response({}) + + +class TestParseHelpers: + def test_parse_artist_valid(self): + data = {"id": "a1", "name": "Muse", "albumCount": 9, "coverArt": "ca", "musicBrainzId": "mb1"} + artist = parse_artist(data) + assert artist.id == "a1" + assert artist.name == "Muse" + assert artist.albumCount == 9 + assert artist.musicBrainzId == "mb1" + + def test_parse_artist_missing_fields(self): + artist = parse_artist({}) + assert artist.id == "" + assert artist.name == "Unknown" + assert artist.albumCount == 0 + + def test_parse_song_valid(self): + data = { + "id": "s1", "title": "Uprising", "album": "The Resistance", + "albumId": "al1", "artist": "Muse", "artistId": "a1", + "track": 1, "year": 2009, "duration": 305, "bitRate": 320, + "suffix": "mp3", "contentType": "audio/mpeg", "musicBrainzId": "mb-s1", + } + song = parse_song(data) + assert song.id == "s1" + assert song.title == "Uprising" + assert song.duration == 305 + assert song.musicBrainzId == "mb-s1" + + def test_parse_song_empty(self): + song = parse_song({}) + assert song.title == "Unknown" + assert song.track == 0 + + def test_parse_album_valid(self): + data = { + "id": "al1", "name": "OK Computer", "artist": "Radiohead", + "artistId": "ar1", "year": 1997, "genre": "Rock", + "songCount": 12, "duration": 3300, "coverArt": "cover1", + "musicBrainzId": "mb-al1", + } + album = parse_album(data) + assert album.id == "al1" + assert album.name == "OK Computer" + assert album.songCount == 12 + assert album.song is None + + def test_parse_album_with_songs(self): + data = { + "id": "al1", "name": "Album", + "song": [{"id": "s1", "title": "Track 1"}, {"id": "s2", "title": "Track 2"}], + } + album = parse_album(data) + assert album.song is not None + assert len(album.song) == 2 + assert album.song[0].title == "Track 1" + + def test_parse_album_empty(self): + album = parse_album({}) + assert album.name == "Unknown" + assert album.song is None + + def test_parse_album_title_fallback(self): + album = parse_album({"id": "x", "title": "Fallback Title"}) + assert album.name == "Fallback Title" + + def test_parse_genre_with_value_key(self): + genre = parse_genre({"value": "Rock", "songCount": 100, "albumCount": 10}) + assert genre.name == "Rock" + assert genre.songCount == 100 + + def test_parse_genre_with_name_key(self): + genre = parse_genre({"name": "Jazz", "songCount": 50, "albumCount": 5}) + assert genre.name == "Jazz" + + def test_parse_genre_empty(self): + genre = parse_genre({}) + assert genre.name == "" + assert genre.songCount == 0 + + +class TestEndpointWrappers: + @pytest.mark.asyncio + async def test_get_album_list_calls_correct_endpoint(self): + repo, client, cache = _make_repo() + client.get = AsyncMock( + return_value=_mock_response(_ok_envelope({"albumList2": {"album": []}})) + ) + result = await repo.get_album_list(type="recent", size=10, offset=0) + assert result == [] + call_args = client.get.call_args + assert "/rest/getAlbumList2" in call_args.args[0] + + @pytest.mark.asyncio + async def test_get_album_calls_correct_endpoint(self): + repo, client, cache = _make_repo() + client.get = AsyncMock( + return_value=_mock_response(_ok_envelope({"album": {"id": "a1", "name": "Test"}})) + ) + result = await repo.get_album("a1") + assert result.id == "a1" + assert result.name == "Test" + + @pytest.mark.asyncio + async def test_get_artists_parses_index_structure(self): + repo, client, cache = _make_repo() + body = { + "artists": { + "index": [ + {"artist": [{"id": "a1", "name": "ABBA"}, {"id": "a2", "name": "AC/DC"}]}, + {"artist": [{"id": "a3", "name": "Blur"}]}, + ] + } + } + client.get = AsyncMock(return_value=_mock_response(_ok_envelope(body))) + result = await repo.get_artists() + assert len(result) == 3 + assert result[0].name == "ABBA" + assert result[2].name == "Blur" + + @pytest.mark.asyncio + async def test_search_calls_search3(self): + repo, client, cache = _make_repo() + body = {"searchResult3": {"artist": [], "album": [], "song": []}} + client.get = AsyncMock(return_value=_mock_response(_ok_envelope(body))) + result = await repo.search("test") + assert isinstance(result, SubsonicSearchResult) + assert "/rest/search3" in client.get.call_args.args[0] + + @pytest.mark.asyncio + async def test_get_genres_calls_correct_endpoint(self): + repo, client, cache = _make_repo() + body = {"genres": {"genre": [{"value": "Rock", "songCount": 5, "albumCount": 1}]}} + client.get = AsyncMock(return_value=_mock_response(_ok_envelope(body))) + result = await repo.get_genres() + assert len(result) == 1 + assert result[0].name == "Rock" + + @pytest.mark.asyncio + async def test_scrobble_returns_true_on_success(self): + repo, client, cache = _make_repo() + client.get = AsyncMock(return_value=_mock_response(_ok_envelope({}))) + result = await repo.scrobble("s1", time_ms=123456) + assert result is True + + @pytest.mark.asyncio + async def test_scrobble_returns_false_on_error(self): + repo, client, cache = _make_repo() + client.get = AsyncMock(side_effect=httpx.HTTPError("fail")) + result = await repo.scrobble("s1") + assert result is False + + +class TestCaching: + @pytest.mark.asyncio + async def test_cached_result_returned_on_second_call(self): + repo, client, cache = _make_repo() + cached_albums = [SubsonicAlbum(id="a1", name="Cached")] + cache.get = AsyncMock(return_value=cached_albums) + result = await repo.get_album_list(type="recent", size=10, offset=0) + assert result == cached_albums + client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_clear_cache_calls_prefix(self): + repo, _, cache = _make_repo() + await repo.clear_cache() + cache.clear_prefix.assert_awaited_once_with("navidrome:") + + +class TestErrorHandling: + def setup_method(self): + _navidrome_circuit_breaker.reset() + + @pytest.mark.asyncio + async def test_timeout_raises_external_service_error(self): + repo, client, _ = _make_repo() + client.get = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + with pytest.raises(ExternalServiceError, match="timed out"): + await repo._request("/rest/ping") + + @pytest.mark.asyncio + async def test_http_error_raises_external_service_error(self): + _navidrome_circuit_breaker.reset() + repo, client, _ = _make_repo() + client.get = AsyncMock(side_effect=httpx.ConnectError("refused")) + with pytest.raises(ExternalServiceError, match="failed"): + await repo._request("/rest/ping") + + @pytest.mark.asyncio + async def test_401_raises_auth_error(self): + _navidrome_circuit_breaker.reset() + repo, client, _ = _make_repo() + client.get = AsyncMock(return_value=_mock_response({}, status_code=401)) + with pytest.raises(NavidromeAuthError): + await repo._request("/rest/ping") + + @pytest.mark.asyncio + async def test_500_raises_api_error(self): + _navidrome_circuit_breaker.reset() + repo, client, _ = _make_repo() + client.get = AsyncMock(return_value=_mock_response({}, status_code=500)) + with pytest.raises(NavidromeApiError): + await repo._request("/rest/ping") + + @pytest.mark.asyncio + async def test_not_configured_raises(self): + _navidrome_circuit_breaker.reset() + repo, _, _ = _make_repo(configured=False) + with pytest.raises(ExternalServiceError, match="not configured"): + await repo._request("/rest/ping") + + +class TestValidateConnection: + def setup_method(self): + _navidrome_circuit_breaker.reset() + + @pytest.mark.asyncio + async def test_success(self): + repo, client, _ = _make_repo() + client.get = AsyncMock( + return_value=_mock_response(_ok_envelope({"version": "1.16.1"})) + ) + ok, msg = await repo.validate_connection() + assert ok is True + assert "Connected" in msg + + @pytest.mark.asyncio + async def test_not_configured(self): + repo, _, _ = _make_repo(configured=False) + ok, msg = await repo.validate_connection() + assert ok is False + assert "not configured" in msg + + @pytest.mark.asyncio + async def test_auth_failure(self): + _navidrome_circuit_breaker.reset() + repo, client, _ = _make_repo() + client.get = AsyncMock(return_value=_mock_response({}, status_code=401)) + ok, msg = await repo.validate_connection() + assert ok is False + assert "Authentication" in msg or "failed" in msg.lower() + + @pytest.mark.asyncio + async def test_timeout_failure(self): + _navidrome_circuit_breaker.reset() + repo, client, _ = _make_repo() + client.get = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + ok, msg = await repo.validate_connection() + assert ok is False + assert "timed out" in msg.lower() or "Connection" in msg + + +class TestConfigure: + def test_configure_sets_configured(self): + repo, _, _ = _make_repo(configured=False) + assert repo.is_configured() is False + repo.configure("http://nd:4533", "user", "pass") + assert repo.is_configured() is True + + def test_configure_strips_trailing_slash(self): + repo, _, _ = _make_repo(configured=False) + repo.configure("http://nd:4533/", "u", "p") + assert repo._url == "http://nd:4533" + + def test_configure_empty_url_not_configured(self): + repo, _, _ = _make_repo(configured=False) + repo.configure("", "u", "p") + assert repo.is_configured() is False diff --git a/backend/tests/repositories/test_playlist_repository.py b/backend/tests/repositories/test_playlist_repository.py new file mode 100644 index 0000000..77b1bbc --- /dev/null +++ b/backend/tests/repositories/test_playlist_repository.py @@ -0,0 +1,440 @@ +import json +import threading + +import pytest + +from repositories.playlist_repository import PlaylistRepository + + +@pytest.fixture +def repo(tmp_path): + db = tmp_path / "test.db" + return PlaylistRepository(db_path=db) + + +class TestEnsureTables: + def test_idempotent(self, tmp_path): + db = tmp_path / "test.db" + repo1 = PlaylistRepository(db_path=db) + repo2 = PlaylistRepository(db_path=db) + assert repo1.get_all_playlists() == [] + assert repo2.get_all_playlists() == [] + + def test_foreign_key_cascade(self, repo): + playlist = repo.create_playlist("Test") + repo.add_tracks(playlist.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + ]) + assert len(repo.get_tracks(playlist.id)) == 1 + repo.delete_playlist(playlist.id) + assert repo.get_tracks(playlist.id) == [] + + def test_unique_position_constraint(self, repo): + playlist = repo.create_playlist("Test") + repo.add_tracks(playlist.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + ]) + import sqlite3 + with pytest.raises(sqlite3.IntegrityError): + conn = repo._get_connection() + conn.execute( + "INSERT INTO playlist_tracks " + "(id, playlist_id, position, track_name, artist_name, album_name, source_type, created_at) " + "VALUES (?, ?, 0, 'dup', 'dup', 'dup', 'local', '2025-01-01')", + ("dup-id", playlist.id), + ) + + +class TestCreatePlaylist: + def test_returns_record(self, repo): + result = repo.create_playlist("My Playlist") + assert result.name == "My Playlist" + assert result.id + assert result.created_at + assert result.updated_at + assert result.cover_image_path is None + + +class TestGetPlaylist: + def test_existing(self, repo): + created = repo.create_playlist("Test") + fetched = repo.get_playlist(created.id) + assert fetched is not None + assert fetched.id == created.id + assert fetched.name == "Test" + + def test_non_existent(self, repo): + assert repo.get_playlist("nonexistent") is None + + +class TestGetAllPlaylists: + def test_empty(self, repo): + assert repo.get_all_playlists() == [] + + def test_with_playlists_and_tracks(self, repo): + p1 = repo.create_playlist("P1") + p2 = repo.create_playlist("P2") + repo.add_tracks(p1.id, [ + {"track_name": "T1", "artist_name": "A", "album_name": "AL", + "source_type": "local", "cover_url": "http://a.jpg", "duration": 200}, + {"track_name": "T2", "artist_name": "A", "album_name": "AL", + "source_type": "local", "cover_url": "http://b.jpg", "duration": 300}, + ]) + summaries = repo.get_all_playlists() + assert len(summaries) == 2 + + p1_summary = next(s for s in summaries if s.id == p1.id) + assert p1_summary.track_count == 2 + assert p1_summary.total_duration == 500 + assert len(p1_summary.cover_urls) == 2 + + p2_summary = next(s for s in summaries if s.id == p2.id) + assert p2_summary.track_count == 0 + + def test_ordered_by_updated_at_desc(self, repo): + import time + p1 = repo.create_playlist("First") + time.sleep(0.05) + p2 = repo.create_playlist("Second") + time.sleep(0.05) + repo.update_playlist(p1.id, name="First Updated") + summaries = repo.get_all_playlists() + assert len(summaries) == 2 + assert summaries[0].id == p1.id + assert summaries[1].id == p2.id + + +class TestUpdatePlaylist: + def test_update_name(self, repo): + p = repo.create_playlist("Old Name") + updated = repo.update_playlist(p.id, name="New Name") + assert updated is not None + assert updated.name == "New Name" + assert updated.updated_at > p.updated_at + + def test_non_existent(self, repo): + assert repo.update_playlist("nonexistent", name="X") is None + + def test_update_cover_path(self, repo): + p = repo.create_playlist("Test") + updated = repo.update_playlist(p.id, cover_image_path="/some/path.jpg") + assert updated is not None + assert updated.cover_image_path == "/some/path.jpg" + + def test_clear_cover_path(self, repo): + p = repo.create_playlist("Test") + repo.update_playlist(p.id, cover_image_path="/old.jpg") + updated = repo.update_playlist(p.id, cover_image_path=None) + assert updated is not None + assert updated.cover_image_path is None + + +class TestDeletePlaylist: + def test_existing(self, repo): + p = repo.create_playlist("Test") + assert repo.delete_playlist(p.id) is True + assert repo.get_playlist(p.id) is None + + def test_non_existent(self, repo): + assert repo.delete_playlist("nonexistent") is False + + +class TestAddTracks: + def test_append(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + {"track_name": "T2", "artist_name": "A2", "album_name": "AL2", "source_type": "jellyfin"}, + ]) + assert len(tracks) == 2 + assert tracks[0].position == 0 + assert tracks[1].position == 1 + + def test_append_to_existing(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + ]) + new_tracks = repo.add_tracks(p.id, [ + {"track_name": "T2", "artist_name": "A2", "album_name": "AL2", "source_type": "local"}, + ]) + assert new_tracks[0].position == 1 + + def test_insert_at_position(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + {"track_name": "T2", "artist_name": "A2", "album_name": "AL2", "source_type": "local"}, + ]) + inserted = repo.add_tracks(p.id, [ + {"track_name": "T_INS", "artist_name": "A", "album_name": "AL", "source_type": "local"}, + ], position=1) + assert inserted[0].position == 1 + + all_tracks = repo.get_tracks(p.id) + assert all_tracks[0].track_name == "T1" + assert all_tracks[1].track_name == "T_INS" + assert all_tracks[2].track_name == "T2" + assert [t.position for t in all_tracks] == [0, 1, 2] + + def test_insert_at_position_zero(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + {"track_name": "T2", "artist_name": "A2", "album_name": "AL2", "source_type": "local"}, + ]) + inserted = repo.add_tracks(p.id, [ + {"track_name": "T_FIRST", "artist_name": "A", "album_name": "AL", "source_type": "local"}, + ], position=0) + assert inserted[0].position == 0 + + all_tracks = repo.get_tracks(p.id) + assert all_tracks[0].track_name == "T_FIRST" + assert all_tracks[1].track_name == "T1" + assert all_tracks[2].track_name == "T2" + assert [t.position for t in all_tracks] == [0, 1, 2] + + def test_empty_list(self, repo): + p = repo.create_playlist("Test") + assert repo.add_tracks(p.id, []) == [] + + def test_available_sources_roundtrip(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", + "source_type": "local", "available_sources": ["local", "jellyfin"]}, + ]) + assert tracks[0].available_sources == ["local", "jellyfin"] + + fetched = repo.get_tracks(p.id) + assert fetched[0].available_sources == ["local", "jellyfin"] + + def test_disc_number_roundtrip(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", + "source_type": "local", "track_number": 1, "disc_number": 2}, + ]) + assert tracks[0].disc_number == 2 + + fetched = repo.get_tracks(p.id) + assert fetched[0].disc_number == 2 + + +class TestRemoveTrack: + def test_remove_and_recompact(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(3) + ]) + assert repo.remove_track(p.id, tracks[1].id) is True + + remaining = repo.get_tracks(p.id) + assert len(remaining) == 2 + assert [t.position for t in remaining] == [0, 1] + assert remaining[0].track_name == "T0" + assert remaining[1].track_name == "T2" + + def test_non_existent(self, repo): + p = repo.create_playlist("Test") + assert repo.remove_track(p.id, "nonexistent") is False + + +class TestReorderTrack: + def test_move_forward(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(4) + ]) + assert repo.reorder_track(p.id, tracks[0].id, 2) == 2 + + result = repo.get_tracks(p.id) + names = [t.track_name for t in result] + assert names == ["T1", "T2", "T0", "T3"] + assert [t.position for t in result] == [0, 1, 2, 3] + + def test_move_backward(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(4) + ]) + assert repo.reorder_track(p.id, tracks[3].id, 1) == 1 + + result = repo.get_tracks(p.id) + names = [t.track_name for t in result] + assert names == ["T0", "T3", "T1", "T2"] + + def test_same_position(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T0", "artist_name": "A", "album_name": "AL", "source_type": "local"}, + ]) + assert repo.reorder_track(p.id, tracks[0].id, 0) == 0 + + def test_move_to_end(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(3) + ]) + assert repo.reorder_track(p.id, tracks[0].id, 2) == 2 + + result = repo.get_tracks(p.id) + names = [t.track_name for t in result] + assert names == ["T1", "T2", "T0"] + + def test_non_existent(self, repo): + p = repo.create_playlist("Test") + assert repo.reorder_track(p.id, "nonexistent", 0) is None + + def test_clamps_out_of_range(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(3) + ]) + actual = repo.reorder_track(p.id, tracks[0].id, 9999) + assert actual == 2 + result = repo.get_tracks(p.id) + assert [t.track_name for t in result] == ["T1", "T2", "T0"] + + +class TestUpdateTrackSource: + def test_update_source_type(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + ]) + result = repo.update_track_source(p.id, tracks[0].id, source_type="jellyfin") + assert result is not None + assert result.source_type == "jellyfin" + + def test_update_available_sources(self, repo): + p = repo.create_playlist("Test") + tracks = repo.add_tracks(p.id, [ + {"track_name": "T1", "artist_name": "A1", "album_name": "AL1", "source_type": "local"}, + ]) + result = repo.update_track_source( + p.id, tracks[0].id, available_sources=["local", "jellyfin"], + ) + assert result is not None + assert result.available_sources == ["local", "jellyfin"] + + def test_non_existent(self, repo): + p = repo.create_playlist("Test") + assert repo.update_track_source(p.id, "nonexistent") is None + + +class TestGetTracks: + def test_ordered(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": f"T{i}", "artist_name": "A", "album_name": "AL", "source_type": "local"} + for i in range(3) + ]) + tracks = repo.get_tracks(p.id) + assert [t.position for t in tracks] == [0, 1, 2] + + def test_empty_playlist(self, repo): + p = repo.create_playlist("Test") + assert repo.get_tracks(p.id) == [] + + def test_non_existent_playlist(self, repo): + assert repo.get_tracks("nonexistent") == [] + + +class TestConcurrency: + def test_concurrent_writes(self, repo): + p = repo.create_playlist("Test") + errors: list[Exception] = [] + + def add_track(idx: int): + try: + repo.add_tracks(p.id, [ + {"track_name": f"T{idx}", "artist_name": "A", "album_name": "AL", "source_type": "local"}, + ]) + except Exception as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=add_track, args=(i,)) for i in range(10)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert errors == [] + tracks = repo.get_tracks(p.id) + assert len(tracks) == 10 + + +class TestCheckTrackMembership: + def test_empty_tracks(self, repo): + result = repo.check_track_membership([]) + assert result == {} + + def test_no_playlists(self, repo): + result = repo.check_track_membership([("Song", "Artist", "Album")]) + assert result == {} + + def test_full_overlap(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "Song A", "artist_name": "Artist X", "album_name": "Album 1", "source_type": "local"}, + {"track_name": "Song B", "artist_name": "Artist Y", "album_name": "Album 2", "source_type": "local"}, + ]) + result = repo.check_track_membership([ + ("Song A", "Artist X", "Album 1"), + ("Song B", "Artist Y", "Album 2"), + ]) + assert result == {p.id: [0, 1]} + + def test_partial_overlap(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "Song A", "artist_name": "Artist X", "album_name": "Album 1", "source_type": "local"}, + ]) + result = repo.check_track_membership([ + ("Song A", "Artist X", "Album 1"), + ("Song C", "Artist Z", "Album 3"), + ]) + assert result == {p.id: [0]} + + def test_no_overlap(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "Song A", "artist_name": "Artist X", "album_name": "Album 1", "source_type": "local"}, + ]) + result = repo.check_track_membership([ + ("Different", "Other", "Nope"), + ]) + assert result == {} + + def test_case_insensitive(self, repo): + p = repo.create_playlist("Test") + repo.add_tracks(p.id, [ + {"track_name": "Hello World", "artist_name": "ARTIST", "album_name": "Album", "source_type": "local"}, + ]) + result = repo.check_track_membership([ + ("hello world", "artist", "album"), + ]) + assert result == {p.id: [0]} + + def test_multiple_playlists(self, repo): + p1 = repo.create_playlist("One") + p2 = repo.create_playlist("Two") + repo.add_tracks(p1.id, [ + {"track_name": "Song A", "artist_name": "Art", "album_name": "Alb", "source_type": "local"}, + ]) + repo.add_tracks(p2.id, [ + {"track_name": "Song A", "artist_name": "Art", "album_name": "Alb", "source_type": "local"}, + {"track_name": "Song B", "artist_name": "Art2", "album_name": "Alb2", "source_type": "local"}, + ]) + result = repo.check_track_membership([ + ("Song A", "Art", "Alb"), + ("Song B", "Art2", "Alb2"), + ]) + assert sorted(result[p1.id]) == [0] + assert sorted(result[p2.id]) == [0, 1] diff --git a/backend/tests/routes/__init__.py b/backend/tests/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/routes/test_covers_routes.py b/backend/tests/routes/test_covers_routes.py new file mode 100644 index 0000000..ab10932 --- /dev/null +++ b/backend/tests/routes/test_covers_routes.py @@ -0,0 +1,157 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, ANY + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.covers import router +from core.dependencies import get_coverart_repository +from core.exceptions import ClientDisconnectedError +from core.exception_handlers import client_disconnected_handler + + +@pytest.fixture +def mock_cover_repo(): + mock = MagicMock() + mock.get_release_group_cover = AsyncMock(return_value=(b'rg-image', 'image/jpeg', 'lidarr')) + mock.get_release_cover = AsyncMock(return_value=(b'rel-image', 'image/jpeg', 'jellyfin')) + mock.get_artist_image = AsyncMock(return_value=(b'artist-image', 'image/png', 'wikidata')) + mock.get_release_group_cover_etag = AsyncMock(return_value='etag-rg') + mock.get_release_cover_etag = AsyncMock(return_value='etag-rel') + mock.get_artist_image_etag = AsyncMock(return_value='etag-artist') + mock.debug_artist_image = AsyncMock(side_effect=lambda _artist_id, debug_info: debug_info) + return mock + + +@pytest.fixture +def client(mock_cover_repo): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_coverart_repository] = lambda: mock_cover_repo + app.add_exception_handler(ClientDisconnectedError, client_disconnected_handler) + return TestClient(app) + + +def test_release_group_uses_dynamic_source_header(client): + response = client.get('/covers/release-group/11111111-1111-1111-1111-111111111111?size=500') + + assert response.status_code == 200 + assert response.headers['x-cover-source'] == 'lidarr' + + +def test_release_uses_dynamic_source_header(client): + response = client.get('/covers/release/22222222-2222-2222-2222-222222222222?size=500') + + assert response.status_code == 200 + assert response.headers['x-cover-source'] == 'jellyfin' + + +def test_artist_uses_dynamic_source_header(client, mock_cover_repo): + mock_cover_repo.get_artist_image = AsyncMock(return_value=(b'artist-image', 'image/png', 'lidarr')) + + response = client.get('/covers/artist/33333333-3333-3333-3333-333333333333?size=250') + + assert response.status_code == 200 + assert response.headers['x-cover-source'] == 'lidarr' + + +def test_release_group_uses_placeholder_header_when_missing(client, mock_cover_repo): + mock_cover_repo.get_release_group_cover = AsyncMock(return_value=None) + + response = client.get('/covers/release-group/44444444-4444-4444-4444-444444444444?size=500') + + assert response.status_code == 200 + assert response.headers['x-cover-source'] == 'placeholder' + + +def test_artist_uses_placeholder_header_when_missing(client, mock_cover_repo): + mock_cover_repo.get_artist_image = AsyncMock(return_value=None) + + response = client.get('/covers/artist/55555555-5555-5555-5555-555555555555') + + assert response.status_code == 200 + assert response.headers['x-cover-source'] == 'placeholder' + + +def test_release_group_original_size_maps_to_none(client, mock_cover_repo): + response = client.get('/covers/release-group/66666666-6666-6666-6666-666666666666?size=original') + + assert response.status_code == 200 + mock_cover_repo.get_release_group_cover.assert_awaited_once_with( + '66666666-6666-6666-6666-666666666666', + None, + is_disconnected=ANY, + ) + + +def test_release_rejects_invalid_size(client): + response = client.get('/covers/release/77777777-7777-7777-7777-777777777777?size=999') + + assert response.status_code == 400 + + +def test_release_group_sets_etag_header(client): + response = client.get('/covers/release-group/11111111-1111-1111-1111-111111111111?size=500') + + assert response.status_code == 200 + assert response.headers['etag'] == '"etag-rg"' + + +def test_release_group_returns_304_when_etag_matches(client, mock_cover_repo): + response = client.get( + '/covers/release-group/11111111-1111-1111-1111-111111111111?size=500', + headers={'If-None-Match': '"etag-rg"'}, + ) + + assert response.status_code == 304 + mock_cover_repo.get_release_group_cover.assert_not_awaited() + + +def test_artist_returns_304_when_etag_matches(client, mock_cover_repo): + response = client.get( + '/covers/artist/33333333-3333-3333-3333-333333333333?size=250', + headers={'If-None-Match': '"etag-artist"'}, + ) + + assert response.status_code == 304 + mock_cover_repo.get_artist_image.assert_not_awaited() + + +def test_debug_artist_cover_recommends_negative_cache(client, mock_cover_repo): + async def _debug_with_negative(_artist_id, debug_info): + debug_info['disk_cache']['negative_250'] = True + return debug_info + + mock_cover_repo.debug_artist_image = AsyncMock(side_effect=_debug_with_negative) + + response = client.get('/covers/debug/artist/33333333-3333-3333-3333-333333333333') + + assert response.status_code == 200 + assert 'negative cache entry' in response.json()['recommendation'].lower() + + +def test_release_group_returns_204_on_disconnect(client, mock_cover_repo): + mock_cover_repo.get_release_group_cover_etag = AsyncMock(return_value=None) + mock_cover_repo.get_release_group_cover = AsyncMock( + side_effect=ClientDisconnectedError("disconnected") + ) + response = client.get('/covers/release-group/66666666-6666-6666-6666-666666666666') + assert response.status_code == 204 + + +def test_release_returns_204_on_disconnect(client, mock_cover_repo): + mock_cover_repo.get_release_cover_etag = AsyncMock(return_value=None) + mock_cover_repo.get_release_cover = AsyncMock( + side_effect=ClientDisconnectedError("disconnected") + ) + response = client.get('/covers/release/66666666-6666-6666-6666-666666666666') + assert response.status_code == 204 + + +def test_artist_returns_204_on_disconnect(client, mock_cover_repo): + mock_cover_repo.get_artist_image_etag = AsyncMock(return_value=None) + mock_cover_repo.get_artist_image = AsyncMock( + side_effect=ClientDisconnectedError("disconnected") + ) + response = client.get('/covers/artist/33333333-3333-3333-3333-333333333333') + assert response.status_code == 204 diff --git a/backend/tests/routes/test_discover_queue_routes.py b/backend/tests/routes/test_discover_queue_routes.py new file mode 100644 index 0000000..a872f01 --- /dev/null +++ b/backend/tests/routes/test_discover_queue_routes.py @@ -0,0 +1,179 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.discover import DiscoverQueueResponse, DiscoverQueueItemLight +from api.v1.routes.discover import router +from core.dependencies import get_discover_service, get_discover_queue_manager + + +def _make_queue_response(source: str) -> DiscoverQueueResponse: + return DiscoverQueueResponse( + items=[ + DiscoverQueueItemLight( + release_group_mbid="rg-mbid-1", + album_name="Test Album", + artist_name="Test Artist", + artist_mbid="artist-mbid-1", + cover_url="/covers/release-group/rg-mbid-1?size=500", + recommendation_reason=f"From {source}", + in_library=False, + ) + ], + queue_id="test-queue-id", + ) + + +@pytest.fixture +def mock_discover_service(): + mock = AsyncMock() + mock.build_queue = AsyncMock(return_value=_make_queue_response("listenbrainz")) + mock.resolve_source = MagicMock(side_effect=lambda s: s or "listenbrainz") + return mock + + +@pytest.fixture +def mock_queue_manager(): + mock = AsyncMock() + mock.consume_queue = AsyncMock(return_value=None) + mock.build_hydrated_queue = AsyncMock(return_value=_make_queue_response("listenbrainz")) + return mock + + +@pytest.fixture +def client(mock_discover_service, mock_queue_manager): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_discover_service] = lambda: mock_discover_service + app.dependency_overrides[get_discover_queue_manager] = lambda: mock_queue_manager + return TestClient(app) + + +class TestDiscoverQueueSourceRoute: + def test_queue_passes_source_param_to_service(self, client, mock_discover_service): + resp = client.get("/discover/queue?source=lastfm") + assert resp.status_code == 200 + mock_discover_service.build_queue.assert_not_called() + + def test_queue_passes_source_param_to_manager(self, client, mock_queue_manager): + resp = client.get("/discover/queue?source=lastfm") + assert resp.status_code == 200 + mock_queue_manager.build_hydrated_queue.assert_awaited_once_with("lastfm", None) + + def test_queue_passes_listenbrainz_source(self, client, mock_discover_service): + resp = client.get("/discover/queue?source=listenbrainz") + assert resp.status_code == 200 + mock_discover_service.build_queue.assert_not_called() + + def test_queue_no_source_uses_resolved_source(self, client, mock_discover_service, mock_queue_manager): + resp = client.get("/discover/queue") + assert resp.status_code == 200 + mock_queue_manager.build_hydrated_queue.assert_awaited_once_with("listenbrainz", None) + mock_discover_service.resolve_source.assert_called_once_with(None) + + def test_queue_respects_count_param(self, client, mock_queue_manager): + resp = client.get("/discover/queue?count=5&source=lastfm") + assert resp.status_code == 200 + mock_queue_manager.build_hydrated_queue.assert_awaited_once_with("lastfm", 5) + + def test_queue_caps_count_at_20(self, client, mock_queue_manager): + resp = client.get("/discover/queue?count=50") + assert resp.status_code == 200 + mock_queue_manager.build_hydrated_queue.assert_awaited_once_with("listenbrainz", 20) + + def test_queue_returns_items(self, client, mock_discover_service): + resp = client.get("/discover/queue?source=lastfm") + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "queue_id" in data + assert len(data["items"]) == 1 + assert data["items"][0]["artist_name"] == "Test Artist" + + +class TestQueueStatusRoute: + def test_status_returns_ok(self, client, mock_queue_manager): + mock_queue_manager.get_status = MagicMock( + return_value={"status": "idle", "source": "listenbrainz"} + ) + resp = client.get("/discover/queue/status?source=listenbrainz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "idle" + assert data["source"] == "listenbrainz" + + def test_status_defaults_source_via_resolve(self, client, mock_discover_service, mock_queue_manager): + mock_queue_manager.get_status = MagicMock( + return_value={"status": "idle", "source": "listenbrainz"} + ) + resp = client.get("/discover/queue/status") + assert resp.status_code == 200 + mock_discover_service.resolve_source.assert_called_once_with(None) + + def test_status_ready_includes_queue_info(self, client, mock_queue_manager): + mock_queue_manager.get_status = MagicMock( + return_value={ + "status": "ready", + "source": "listenbrainz", + "queue_id": "abc", + "item_count": 5, + "built_at": 1000.0, + "stale": False, + } + ) + resp = client.get("/discover/queue/status?source=listenbrainz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ready" + assert data["item_count"] == 5 + assert data["stale"] is False + + +class TestQueueGenerateRoute: + def test_generate_triggers_build(self, client, mock_queue_manager): + mock_queue_manager.start_build = AsyncMock( + return_value={"action": "started", "status": "building", "source": "listenbrainz"} + ) + resp = client.post( + "/discover/queue/generate", + json={"source": "listenbrainz", "force": False}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "started" + mock_queue_manager.start_build.assert_awaited_once_with("listenbrainz", force=False) + + def test_generate_already_building(self, client, mock_queue_manager): + mock_queue_manager.start_build = AsyncMock( + return_value={"action": "already_building", "status": "building", "source": "listenbrainz"} + ) + resp = client.post( + "/discover/queue/generate", + json={"source": "listenbrainz", "force": False}, + ) + assert resp.status_code == 200 + assert resp.json()["action"] == "already_building" + + def test_generate_force_rebuild(self, client, mock_queue_manager): + mock_queue_manager.start_build = AsyncMock( + return_value={"action": "started", "status": "building", "source": "listenbrainz"} + ) + resp = client.post( + "/discover/queue/generate", + json={"source": "listenbrainz", "force": True}, + ) + assert resp.status_code == 200 + mock_queue_manager.start_build.assert_awaited_once_with("listenbrainz", force=True) + + def test_generate_defaults_source(self, client, mock_discover_service, mock_queue_manager): + mock_queue_manager.start_build = AsyncMock( + return_value={"action": "started", "status": "building", "source": "listenbrainz"} + ) + resp = client.post( + "/discover/queue/generate", + json={"force": False}, + ) + assert resp.status_code == 200 + mock_discover_service.resolve_source.assert_called_once_with(None) diff --git a/backend/tests/routes/test_home_routes.py b/backend/tests/routes/test_home_routes.py new file mode 100644 index 0000000..34ea329 --- /dev/null +++ b/backend/tests/routes/test_home_routes.py @@ -0,0 +1,115 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.home import router +from core.dependencies import get_home_charts_service, get_home_service + + +@pytest.fixture +def mock_home_service(): + mock = AsyncMock() + mock.get_home_data = AsyncMock( + return_value={ + 'recently_added': None, + 'library_artists': None, + 'library_albums': None, + 'recommended_artists': None, + 'trending_artists': None, + 'popular_albums': None, + 'recently_played': None, + 'top_genres': None, + 'genre_list': None, + 'fresh_releases': None, + 'favorite_artists': None, + 'weekly_exploration': None, + 'service_prompts': [], + 'integration_status': {}, + 'genre_artists': {}, + 'discover_preview': None, + } + ) + return mock + + +@pytest.fixture +def mock_charts_service(): + mock = AsyncMock() + mock.get_trending_artists_by_range = AsyncMock( + return_value={ + 'range_key': 'this_week', + 'label': 'This Week', + 'items': [], + 'offset': 0, + 'limit': 25, + 'has_more': False, + } + ) + mock.get_popular_albums_by_range = AsyncMock( + return_value={ + 'range_key': 'this_week', + 'label': 'This Week', + 'items': [], + 'offset': 0, + 'limit': 25, + 'has_more': False, + } + ) + return mock + + +@pytest.fixture +def client(mock_home_service, mock_charts_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_home_service] = lambda: mock_home_service + app.dependency_overrides[get_home_charts_service] = lambda: mock_charts_service + return TestClient(app) + + +class TestHomeRangeSourcePropagation: + def test_trending_range_forwards_lastfm_source(self, client, mock_charts_service): + response = client.get('/home/trending/artists/this_week?limit=10&offset=5&source=lastfm') + + assert response.status_code == 200 + mock_charts_service.get_trending_artists_by_range.assert_awaited_once_with( + range_key='this_week', + limit=10, + offset=5, + source='lastfm', + ) + + def test_trending_range_forwards_none_source_when_missing(self, client, mock_charts_service): + response = client.get('/home/trending/artists/this_week?limit=10&offset=0') + + assert response.status_code == 200 + mock_charts_service.get_trending_artists_by_range.assert_awaited_once_with( + range_key='this_week', + limit=10, + offset=0, + source=None, + ) + + def test_popular_range_forwards_listenbrainz_source(self, client, mock_charts_service): + response = client.get('/home/popular/albums/this_month?limit=12&offset=3&source=listenbrainz') + + assert response.status_code == 200 + mock_charts_service.get_popular_albums_by_range.assert_awaited_once_with( + range_key='this_month', + limit=12, + offset=3, + source='listenbrainz', + ) + + def test_popular_range_forwards_none_source_when_missing(self, client, mock_charts_service): + response = client.get('/home/popular/albums/this_year?limit=8&offset=1') + + assert response.status_code == 200 + mock_charts_service.get_popular_albums_by_range.assert_awaited_once_with( + range_key='this_year', + limit=8, + offset=1, + source=None, + ) diff --git a/backend/tests/routes/test_lastfm_routes.py b/backend/tests/routes/test_lastfm_routes.py new file mode 100644 index 0000000..c8514d5 --- /dev/null +++ b/backend/tests/routes/test_lastfm_routes.py @@ -0,0 +1,128 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.settings import ( + LastFmConnectionSettings, +) +from api.v1.routes.lastfm import router as lastfm_router +from core.dependencies import ( + get_lastfm_auth_service, + get_preferences_service, +) +from core.exceptions import ConfigurationError, ExternalServiceError, TokenNotAuthorizedError +from tests.helpers import add_production_exception_handlers + + +def _default_settings() -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="test-key", + shared_secret="test-secret", + session_key="sk-abc", + username="testuser", + enabled=True, + ) + + +@pytest.fixture +def mock_preferences(): + mock = MagicMock() + mock.get_lastfm_connection.return_value = _default_settings() + mock.save_lastfm_connection = MagicMock() + mock.is_lastfm_enabled.return_value = True + return mock + + +@pytest.fixture +def mock_auth_service(): + mock = AsyncMock() + mock.request_token = AsyncMock( + return_value=("tok-123", "https://www.last.fm/api/auth/?api_key=test-key&token=tok-123") + ) + mock.exchange_session = AsyncMock(return_value=("testuser", "sk-new", "")) + return mock + + +@pytest.fixture +def auth_client(mock_preferences, mock_auth_service): + app = FastAPI() + app.include_router(lastfm_router) + app.dependency_overrides[get_preferences_service] = lambda: mock_preferences + app.dependency_overrides[get_lastfm_auth_service] = lambda: mock_auth_service + add_production_exception_handlers(app) + return TestClient(app) + + +def test_request_token_success(auth_client, mock_auth_service): + response = auth_client.post("/lastfm/auth/token") + assert response.status_code == 200 + data = response.json() + assert data["token"] == "tok-123" + assert "auth_url" in data + + +def test_request_token_missing_credentials(auth_client, mock_preferences): + mock_preferences.get_lastfm_connection.return_value = LastFmConnectionSettings( + api_key="", shared_secret="", session_key="", username="", enabled=False + ) + response = auth_client.post("/lastfm/auth/token") + assert response.status_code == 400 + + +def test_request_token_external_error(auth_client, mock_auth_service): + mock_auth_service.request_token.side_effect = ExternalServiceError("Last.fm down") + response = auth_client.post("/lastfm/auth/token") + assert response.status_code == 502 + + +def test_exchange_session_success(auth_client, mock_auth_service, mock_preferences): + response = auth_client.post( + "/lastfm/auth/session", + json={"token": "tok-123"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["username"] == "testuser" + mock_preferences.save_lastfm_connection.assert_called_once() + + +def test_exchange_session_expired_token(auth_client, mock_auth_service): + mock_auth_service.exchange_session.side_effect = ConfigurationError( + "Token expired or not recognized" + ) + response = auth_client.post( + "/lastfm/auth/session", + json={"token": "expired-tok"}, + ) + assert response.status_code == 422 + data = response.json() + assert "configuration error" in data["error"]["message"].lower() + + +def test_exchange_session_external_error(auth_client, mock_auth_service): + mock_auth_service.exchange_session.side_effect = ExternalServiceError( + "Last.fm unreachable" + ) + response = auth_client.post( + "/lastfm/auth/session", + json={"token": "tok-123"}, + ) + assert response.status_code == 502 + data = response.json() + assert "error" in data + + +def test_exchange_session_token_not_authorized(auth_client, mock_auth_service): + mock_auth_service.exchange_session.side_effect = TokenNotAuthorizedError( + "Token not yet authorized" + ) + response = auth_client.post( + "/lastfm/auth/session", + json={"token": "tok-pending"}, + ) + assert response.status_code == 502 + data = response.json() + assert "authorize" in data["error"]["message"].lower() diff --git a/backend/tests/routes/test_lastfm_settings_routes.py b/backend/tests/routes/test_lastfm_settings_routes.py new file mode 100644 index 0000000..01ffc30 --- /dev/null +++ b/backend/tests/routes/test_lastfm_settings_routes.py @@ -0,0 +1,142 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.settings import ( + LastFmConnectionSettings, + LastFmConnectionSettingsResponse, + LastFmVerifyResponse, + LASTFM_SECRET_MASK, + _mask_secret, +) +from api.v1.routes.settings import router +from core.dependencies import get_preferences_service, get_settings_service + + +def _saved_settings() -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="real-key", + shared_secret="real-secret-value", + session_key="sk-real-session", + username="testuser", + enabled=True, + ) + + +@pytest.fixture +def mock_prefs(): + mock = MagicMock() + mock.get_lastfm_connection.return_value = _saved_settings() + mock.save_lastfm_connection = MagicMock() + return mock + + +@pytest.fixture +def mock_settings_service(): + mock = MagicMock() + mock.clear_home_cache = AsyncMock() + mock.on_lastfm_settings_changed = AsyncMock() + return mock + + +@pytest.fixture +def client(mock_prefs, mock_settings_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_preferences_service] = lambda: mock_prefs + app.dependency_overrides[get_settings_service] = lambda: mock_settings_service + yield TestClient(app) + + +class TestGetLastFmSettings: + def test_returns_masked_secrets(self, client, mock_prefs): + response = client.get("/settings/lastfm") + assert response.status_code == 200 + data = response.json() + assert data["api_key"] == "real-key" + assert data["shared_secret"] == _mask_secret("real-secret-value") + assert data["session_key"] == _mask_secret("sk-real-session") + assert data["username"] == "testuser" + assert data["enabled"] is True + + def test_returns_empty_when_no_secret(self, client, mock_prefs): + mock_prefs.get_lastfm_connection.return_value = LastFmConnectionSettings() + response = client.get("/settings/lastfm") + assert response.status_code == 200 + data = response.json() + assert data["shared_secret"] == "" + assert data["session_key"] == "" + + +class TestPutLastFmSettings: + def test_save_returns_masked_response(self, client, mock_prefs): + response = client.put( + "/settings/lastfm", + json={ + "api_key": "new-key", + "shared_secret": "new-secret", + "session_key": "", + "username": "", + "enabled": True, + }, + ) + assert response.status_code == 200 + mock_prefs.save_lastfm_connection.assert_called_once() + data = response.json() + assert data["shared_secret"] == _mask_secret("real-secret-value") + + def test_save_with_empty_creds_still_succeeds(self, client, mock_prefs): + mock_prefs.get_lastfm_connection.return_value = LastFmConnectionSettings( + api_key="", + shared_secret="", + session_key="", + username="", + enabled=False, + ) + response = client.put( + "/settings/lastfm", + json={ + "api_key": "", + "shared_secret": "", + "session_key": "", + "username": "", + "enabled": False, + }, + ) + assert response.status_code == 200 + + def test_save_preserves_masked_session_key(self, client, mock_prefs): + masked = _mask_secret("sk-real-session") + response = client.put( + "/settings/lastfm", + json={ + "api_key": "real-key", + "shared_secret": LASTFM_SECRET_MASK, + "session_key": masked, + "username": "testuser", + "enabled": True, + }, + ) + assert response.status_code == 200 + saved = mock_prefs.save_lastfm_connection.call_args[0][0] + assert saved.shared_secret == LASTFM_SECRET_MASK + assert saved.session_key == masked + + +class TestMaskSecretConsistency: + def test_mask_secret_starts_with_mask_constant(self): + masked = _mask_secret("secret123") + assert masked.startswith(LASTFM_SECRET_MASK) + + def test_mask_secret_short_value_equals_mask(self): + masked = _mask_secret("ab") + assert masked == LASTFM_SECRET_MASK + + def test_mask_secret_empty_returns_empty(self): + assert _mask_secret("") == "" + + def test_mask_secret_suffix_preserved(self): + masked = _mask_secret("my-long-secret") + assert masked == LASTFM_SECRET_MASK + "cret" diff --git a/backend/tests/routes/test_navidrome_routes.py b/backend/tests/routes/test_navidrome_routes.py new file mode 100644 index 0000000..1ef67b7 --- /dev/null +++ b/backend/tests/routes/test_navidrome_routes.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.navidrome_library import router as library_router +from api.v1.routes.stream import router as stream_router +from api.v1.schemas.navidrome import ( + NavidromeAlbumDetail, + NavidromeAlbumMatch, + NavidromeAlbumSummary, + NavidromeArtistSummary, + NavidromeLibraryStats, + NavidromeSearchResponse, + NavidromeTrackInfo, +) +from core.dependencies import get_navidrome_library_service, get_navidrome_playback_service +from core.exceptions import ExternalServiceError + + +def _album_summary(id: str = "a1", name: str = "Album") -> NavidromeAlbumSummary: + return NavidromeAlbumSummary(navidrome_id=id, name=name, artist_name="Artist") + + +def _track_info(id: str = "t1", title: str = "Track") -> NavidromeTrackInfo: + return NavidromeTrackInfo(navidrome_id=id, title=title, track_number=1, duration_seconds=200.0) + + +def _artist_summary(id: str = "ar1", name: str = "Artist") -> NavidromeArtistSummary: + return NavidromeArtistSummary(navidrome_id=id, name=name) + + +@pytest.fixture +def mock_library_service(): + mock = MagicMock() + mock.get_albums = AsyncMock(return_value=[_album_summary()]) + mock.get_album_detail = AsyncMock(return_value=NavidromeAlbumDetail( + navidrome_id="a1", name="Album", tracks=[_track_info()], + )) + mock.get_artists = AsyncMock(return_value=[_artist_summary()]) + mock.get_artist_detail = AsyncMock(return_value={ + "artist": {"navidrome_id": "ar1", "name": "Artist", "image_url": None, "album_count": 0, "musicbrainz_id": None}, + "albums": [{"navidrome_id": "a1", "name": "Album", "artist_name": "Artist", "year": None, "track_count": 0, "image_url": None, "musicbrainz_id": None}], + }) + mock.search = AsyncMock(return_value=NavidromeSearchResponse( + albums=[_album_summary()], artists=[_artist_summary()], tracks=[_track_info()], + )) + mock.get_recent = AsyncMock(return_value=[_album_summary()]) + mock.get_favorites = AsyncMock(return_value=NavidromeSearchResponse()) + mock.get_genres = AsyncMock(return_value=["Rock", "Jazz"]) + mock.get_stats = AsyncMock(return_value=NavidromeLibraryStats( + total_tracks=100, total_albums=10, total_artists=5, + )) + mock.get_album_match = AsyncMock(return_value=NavidromeAlbumMatch(found=True, navidrome_album_id="nd-1")) + return mock + + +@pytest.fixture +def mock_playback_service(): + mock = MagicMock() + mock.get_stream_url = MagicMock(return_value="http://navidrome:4533/rest/stream?id=s1&u=admin") + mock.scrobble = AsyncMock(return_value=True) + return mock + + +@pytest.fixture +def library_client(mock_library_service): + app = FastAPI() + app.include_router(library_router) + app.dependency_overrides[get_navidrome_library_service] = lambda: mock_library_service + return TestClient(app) + + +@pytest.fixture +def stream_client(mock_playback_service): + app = FastAPI() + app.include_router(stream_router) + app.dependency_overrides[get_navidrome_playback_service] = lambda: mock_playback_service + return TestClient(app) + + +class TestLibraryAlbums: + def test_get_albums(self, library_client): + resp = library_client.get("/navidrome/albums") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 1 + assert data["items"][0]["navidrome_id"] == "a1" + assert data["total"] == 1 + + def test_get_album_detail(self, library_client): + resp = library_client.get("/navidrome/albums/a1") + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Album" + assert len(data["tracks"]) == 1 + + def test_get_album_detail_not_found(self, library_client, mock_library_service): + mock_library_service.get_album_detail = AsyncMock(return_value=None) + resp = library_client.get("/navidrome/albums/missing") + assert resp.status_code == 404 + + def test_get_albums_502_on_external_error(self, library_client, mock_library_service): + mock_library_service.get_albums = AsyncMock(side_effect=ExternalServiceError("down")) + resp = library_client.get("/navidrome/albums") + assert resp.status_code == 502 + + +class TestLibraryArtists: + def test_get_artists(self, library_client): + resp = library_client.get("/navidrome/artists") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["name"] == "Artist" + + def test_get_artist_detail(self, library_client): + resp = library_client.get("/navidrome/artists/ar1") + assert resp.status_code == 200 + data = resp.json() + assert "artist" in data + assert "albums" in data + + def test_get_artist_detail_not_found(self, library_client, mock_library_service): + mock_library_service.get_artist_detail = AsyncMock(return_value=None) + resp = library_client.get("/navidrome/artists/missing") + assert resp.status_code == 404 + + +class TestLibrarySearch: + def test_search(self, library_client): + resp = library_client.get("/navidrome/search?q=test") + assert resp.status_code == 200 + data = resp.json() + assert "albums" in data + assert "artists" in data + assert "tracks" in data + + def test_search_missing_query(self, library_client): + resp = library_client.get("/navidrome/search") + assert resp.status_code == 422 + + +class TestLibraryRecent: + def test_get_recent(self, library_client): + resp = library_client.get("/navidrome/recent") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + +class TestLibraryFavorites: + def test_get_favorites(self, library_client): + resp = library_client.get("/navidrome/favorites") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + +class TestLibraryGenres: + def test_get_genres(self, library_client): + resp = library_client.get("/navidrome/genres") + assert resp.status_code == 200 + assert resp.json() == ["Rock", "Jazz"] + + def test_genres_502_on_external_error(self, library_client, mock_library_service): + mock_library_service.get_genres = AsyncMock(side_effect=ExternalServiceError("down")) + resp = library_client.get("/navidrome/genres") + assert resp.status_code == 502 + + +class TestLibraryStats: + def test_get_stats(self, library_client): + resp = library_client.get("/navidrome/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total_tracks"] == 100 + assert data["total_albums"] == 10 + assert data["total_artists"] == 5 + + +class TestAlbumMatch: + def test_album_match(self, library_client): + resp = library_client.get("/navidrome/album-match/mb-1?name=Album&artist=Artist") + assert resp.status_code == 200 + data = resp.json() + assert data["found"] is True + assert data["navidrome_album_id"] == "nd-1" + + +class TestNavidromeStreamProxy: + def test_stream_returns_streaming_response(self, stream_client, mock_playback_service): + from fastapi.responses import StreamingResponse + + async def fake_chunks(): + yield b"audio-data" + + mock_response = StreamingResponse( + content=fake_chunks(), + status_code=200, + headers={"Content-Type": "audio/mpeg"}, + media_type="audio/mpeg", + ) + mock_playback_service.proxy_stream = AsyncMock(return_value=mock_response) + + resp = stream_client.get("/stream/navidrome/s1") + assert resp.status_code == 200 + + def test_stream_returns_400_when_not_configured(self, stream_client, mock_playback_service): + mock_playback_service.proxy_stream = AsyncMock(side_effect=ValueError("not configured")) + resp = stream_client.get("/stream/navidrome/s1") + assert resp.status_code == 400 + + +class TestNavidromeScrobble: + def test_scrobble_returns_ok(self, stream_client): + resp = stream_client.post("/stream/navidrome/s1/scrobble") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_scrobble_failure_returns_error(self, stream_client, mock_playback_service): + mock_playback_service.scrobble = AsyncMock(return_value=False) + resp = stream_client.post("/stream/navidrome/s1/scrobble") + assert resp.status_code == 200 + assert resp.json()["status"] == "error" diff --git a/backend/tests/routes/test_playlist_routes.py b/backend/tests/routes/test_playlist_routes.py new file mode 100644 index 0000000..cf73a34 --- /dev/null +++ b/backend/tests/routes/test_playlist_routes.py @@ -0,0 +1,387 @@ +import pytest +from unittest.mock import AsyncMock, patch +from io import BytesIO +from pathlib import Path + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.playlists import router as playlists_router +from core.dependencies import get_playlist_service, get_jellyfin_library_service, get_local_files_service, get_navidrome_library_service +from core.exceptions import PlaylistNotFoundError, InvalidPlaylistDataError, ResourceNotFoundError, ValidationError +from core.exception_handlers import resource_not_found_handler, validation_error_handler, general_exception_handler +from repositories.playlist_repository import PlaylistRecord, PlaylistSummaryRecord, PlaylistTrackRecord + + +def _playlist(id="p-1", name="Test", cover_image_path=None) -> PlaylistRecord: + return PlaylistRecord( + id=id, name=name, cover_image_path=cover_image_path, + created_at="2025-01-01T00:00:00+00:00", + updated_at="2025-01-01T00:00:00+00:00", + ) + + +def _summary(id="p-1", name="Test") -> PlaylistSummaryRecord: + return PlaylistSummaryRecord( + id=id, name=name, track_count=3, total_duration=600, + cover_urls=["http://example.com/cover.jpg"], + cover_image_path=None, + created_at="2025-01-01T00:00:00+00:00", + updated_at="2025-01-01T00:00:00+00:00", + ) + + +def _track(id="t-1", playlist_id="p-1", position=0) -> PlaylistTrackRecord: + return PlaylistTrackRecord( + id=id, playlist_id=playlist_id, position=position, + track_name="Song", artist_name="Artist", album_name="Album", + album_id=None, artist_id=None, track_source_id=None, cover_url="http://img/1", + source_type="local", available_sources=None, format=None, + track_number=1, disc_number=2, duration=180, + created_at="2025-01-01T00:00:00+00:00", + ) + + +@pytest.fixture +def mock_playlist_service(): + mock = AsyncMock() + mock.create_playlist.return_value = _playlist() + mock.get_playlist.return_value = _playlist() + mock.get_all_playlists.return_value = [_summary()] + mock.get_playlist_with_tracks.return_value = (_playlist(), [_track()]) + mock.update_playlist.return_value = _playlist() + mock.update_playlist_with_detail.return_value = (_playlist(), [_track()]) + mock.delete_playlist.return_value = None + mock.add_tracks.return_value = [_track()] + mock.remove_track.return_value = None + mock.reorder_track.return_value = 2 + mock.update_track_source.return_value = _track() + mock.get_tracks.return_value = [_track()] + mock.upload_cover.return_value = "/api/v1/playlists/p-1/cover" + mock.get_cover_path.return_value = None + mock.remove_cover.return_value = None + return mock + + +@pytest.fixture +def mock_jf_service(): + return AsyncMock() + + +@pytest.fixture +def mock_local_service(): + return AsyncMock() + + +@pytest.fixture +def mock_nd_service(): + return AsyncMock() + + +@pytest.fixture +def client(mock_playlist_service, mock_jf_service, mock_local_service, mock_nd_service): + app = FastAPI() + app.include_router(playlists_router) + app.dependency_overrides[get_playlist_service] = lambda: mock_playlist_service + app.dependency_overrides[get_jellyfin_library_service] = lambda: mock_jf_service + app.dependency_overrides[get_local_files_service] = lambda: mock_local_service + app.dependency_overrides[get_navidrome_library_service] = lambda: mock_nd_service + app.add_exception_handler(ResourceNotFoundError, resource_not_found_handler) + app.add_exception_handler(ValidationError, validation_error_handler) + app.add_exception_handler(Exception, general_exception_handler) + return TestClient(app) + + +class TestListPlaylists: + def test_success(self, client): + resp = client.get("/playlists") + assert resp.status_code == 200 + data = resp.json() + assert len(data["playlists"]) == 1 + assert data["playlists"][0]["name"] == "Test" + assert data["playlists"][0]["track_count"] == 3 + + def test_empty(self, client, mock_playlist_service): + mock_playlist_service.get_all_playlists.return_value = [] + resp = client.get("/playlists") + assert resp.status_code == 200 + assert len(resp.json()["playlists"]) == 0 + + +class TestCreatePlaylist: + def test_success(self, client): + resp = client.post( + "/playlists", + content=b'{"name": "My Playlist"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["id"] == "p-1" + assert data["tracks"] == [] + assert "cover_image_path" not in data + + def test_validation_error(self, client, mock_playlist_service): + mock_playlist_service.create_playlist.side_effect = InvalidPlaylistDataError("empty name") + resp = client.post( + "/playlists", + content=b'{"name": ""}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + + +class TestGetPlaylist: + def test_success(self, client): + resp = client.get("/playlists/p-1") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == "p-1" + assert len(data["tracks"]) == 1 + assert data["track_count"] == 1 + assert data["tracks"][0]["disc_number"] == 2 + assert "cover_image_path" not in data + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.get_playlist_with_tracks.side_effect = PlaylistNotFoundError("nope") + resp = client.get("/playlists/nonexistent") + assert resp.status_code == 404 + + +class TestUpdatePlaylist: + def test_success(self, client): + resp = client.put( + "/playlists/p-1", + content=b'{"name": "Renamed"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + assert resp.json()["id"] == "p-1" + assert "cover_image_path" not in resp.json() + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.update_playlist_with_detail.side_effect = PlaylistNotFoundError("nope") + resp = client.put( + "/playlists/nonexistent", + content=b'{"name": "X"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 404 + + +class TestDeletePlaylist: + def test_success(self, client): + resp = client.delete("/playlists/p-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.delete_playlist.side_effect = PlaylistNotFoundError("nope") + resp = client.delete("/playlists/nonexistent") + assert resp.status_code == 404 + + +class TestAddTracks: + def test_success(self, client): + body = { + "tracks": [ + {"track_name": "S", "artist_name": "A", "album_name": "AL", "disc_number": 2}, + ], + } + resp = client.post( + "/playlists/p-1/tracks", + content=__import__("json").dumps(body).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 201 + assert len(resp.json()["tracks"]) == 1 + assert resp.json()["tracks"][0]["disc_number"] == 2 + + def test_empty_tracks(self, client, mock_playlist_service): + mock_playlist_service.add_tracks.side_effect = InvalidPlaylistDataError("empty") + resp = client.post( + "/playlists/p-1/tracks", + content=b'{"tracks": []}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + + def test_playlist_not_found(self, client, mock_playlist_service): + mock_playlist_service.add_tracks.side_effect = PlaylistNotFoundError("nope") + body = {"tracks": [{"track_name": "S", "artist_name": "A", "album_name": "AL"}]} + resp = client.post( + "/playlists/nonexistent/tracks", + content=__import__("json").dumps(body).encode(), + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 404 + + +class TestRemoveTrack: + def test_success(self, client): + resp = client.delete("/playlists/p-1/tracks/t-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.remove_track.side_effect = PlaylistNotFoundError("nope") + resp = client.delete("/playlists/p-1/tracks/nonexistent") + assert resp.status_code == 404 + + +class TestReorderTrack: + def test_success(self, client): + resp = client.patch( + "/playlists/p-1/tracks/reorder", + content=b'{"track_id": "t-1", "new_position": 2}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["actual_position"] == 2 + + def test_invalid_position(self, client, mock_playlist_service): + mock_playlist_service.reorder_track.side_effect = InvalidPlaylistDataError("neg") + resp = client.patch( + "/playlists/p-1/tracks/reorder", + content=b'{"track_id": "t-1", "new_position": -1}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 400 + + +class TestUpdateTrack: + def test_success(self, client, mock_playlist_service): + updated = _track() + updated.source_type = "youtube" + mock_playlist_service.update_track_source.return_value = updated + resp = client.patch( + "/playlists/p-1/tracks/t-1", + content=b'{"source_type": "youtube"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + assert resp.json()["source_type"] == "youtube" + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.update_track_source.side_effect = PlaylistNotFoundError("nope") + resp = client.patch( + "/playlists/p-1/tracks/nonexistent", + content=b'{"source_type": "youtube"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 404 + + +class TestUploadCover: + def test_success(self, client): + resp = client.post( + "/playlists/p-1/cover", + files={"cover_image": ("cover.png", b"PNG_DATA", "image/png")}, + ) + assert resp.status_code == 200 + assert resp.json()["cover_url"] == "/api/v1/playlists/p-1/cover" + + def test_invalid_type(self, client, mock_playlist_service): + mock_playlist_service.upload_cover.side_effect = InvalidPlaylistDataError("bad type") + resp = client.post( + "/playlists/p-1/cover", + files={"cover_image": ("doc.pdf", b"data", "application/pdf")}, + ) + assert resp.status_code == 400 + + +class TestGetCover: + def test_no_cover(self, client): + resp = client.get("/playlists/p-1/cover") + assert resp.status_code == 404 + + def test_with_cover(self, client, mock_playlist_service, tmp_path): + cover_file = tmp_path / "cover.png" + cover_file.write_bytes(b"\x89PNG\r\n\x1a\n") + mock_playlist_service.get_cover_path.return_value = cover_file + resp = client.get("/playlists/p-1/cover") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + assert "max-age=3600" in resp.headers.get("cache-control", "") + + +class TestRemoveCover: + def test_success(self, client): + resp = client.delete("/playlists/p-1/cover") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +class TestCheckTrackMembership: + def test_success(self, client, mock_playlist_service): + mock_playlist_service.check_track_membership.return_value = { + "p-1": [0, 1], + "p-2": [1], + } + resp = client.post( + "/playlists/check-tracks", + json={ + "tracks": [ + {"track_name": "Song A", "artist_name": "Artist", "album_name": "Album"}, + {"track_name": "Song B", "artist_name": "Artist2", "album_name": "Album2"}, + ] + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["membership"]["p-1"] == [0, 1] + assert data["membership"]["p-2"] == [1] + + def test_empty_tracks(self, client, mock_playlist_service): + mock_playlist_service.check_track_membership.return_value = {} + resp = client.post( + "/playlists/check-tracks", + json={"tracks": []}, + ) + assert resp.status_code == 200 + assert resp.json()["membership"] == {} + + +class TestResolveSources: + def test_success(self, client, mock_playlist_service): + mock_playlist_service.resolve_track_sources.return_value = { + "t-1": ["jellyfin", "local"], + "t-2": ["jellyfin"], + } + resp = client.post("/playlists/p-1/resolve-sources") + assert resp.status_code == 200 + data = resp.json() + assert data["sources"]["t-1"] == ["jellyfin", "local"] + assert data["sources"]["t-2"] == ["jellyfin"] + + def test_empty_sources(self, client, mock_playlist_service): + mock_playlist_service.resolve_track_sources.return_value = {} + resp = client.post("/playlists/p-1/resolve-sources") + assert resp.status_code == 200 + assert resp.json()["sources"] == {} + + def test_not_found(self, client, mock_playlist_service): + mock_playlist_service.resolve_track_sources.side_effect = PlaylistNotFoundError("nope") + resp = client.post("/playlists/p-1/resolve-sources") + assert resp.status_code == 404 + + +class TestUpdateTrackSourceResolution: + def test_returns_updated_track_source_id(self, client, mock_playlist_service): + updated = _track() + updated.source_type = "jellyfin" + updated.track_source_id = "jf-resolved-id" + updated.available_sources = ["jellyfin", "local"] + mock_playlist_service.update_track_source.return_value = updated + resp = client.patch( + "/playlists/p-1/tracks/t-1", + content=b'{"source_type": "jellyfin"}', + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["source_type"] == "jellyfin" + assert data["track_source_id"] == "jf-resolved-id" + assert data["available_sources"] == ["jellyfin", "local"] diff --git a/backend/tests/routes/test_primary_source_routes.py b/backend/tests/routes/test_primary_source_routes.py new file mode 100644 index 0000000..c9dd17e --- /dev/null +++ b/backend/tests/routes/test_primary_source_routes.py @@ -0,0 +1,88 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.settings import PrimaryMusicSourceSettings +from api.v1.routes.settings import router +from core.dependencies import get_preferences_service, get_settings_service + + +@pytest.fixture +def mock_prefs(): + mock = MagicMock() + mock.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source="listenbrainz") + mock.save_primary_music_source = MagicMock() + return mock + + +@pytest.fixture +def mock_settings_service(): + mock = MagicMock() + mock.clear_home_cache = AsyncMock(return_value=5) + mock.clear_source_resolution_cache = AsyncMock(return_value=3) + return mock + + +@pytest.fixture +def client(mock_prefs, mock_settings_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_preferences_service] = lambda: mock_prefs + app.dependency_overrides[get_settings_service] = lambda: mock_settings_service + yield TestClient(app) + + +class TestGetPrimarySource: + def test_returns_current_source(self, client, mock_prefs): + resp = client.get("/settings/primary-source") + assert resp.status_code == 200 + assert resp.json()["source"] == "listenbrainz" + + def test_returns_lastfm_when_configured(self, client, mock_prefs): + mock_prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings( + source="lastfm" + ) + resp = client.get("/settings/primary-source") + assert resp.status_code == 200 + assert resp.json()["source"] == "lastfm" + + +class TestUpdatePrimarySource: + def test_updates_to_lastfm(self, client, mock_prefs): + mock_prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings( + source="lastfm" + ) + resp = client.put( + "/settings/primary-source", + json={"source": "lastfm"}, + ) + assert resp.status_code == 200 + assert resp.json()["source"] == "lastfm" + mock_prefs.save_primary_music_source.assert_called_once() + + def test_updates_to_listenbrainz(self, client, mock_prefs): + resp = client.put( + "/settings/primary-source", + json={"source": "listenbrainz"}, + ) + assert resp.status_code == 200 + mock_prefs.save_primary_music_source.assert_called_once() + + def test_rejects_invalid_source(self, client, mock_prefs): + resp = client.put( + "/settings/primary-source", + json={"source": "invalid"}, + ) + assert resp.status_code == 422 + mock_prefs.save_primary_music_source.assert_not_called() + + def test_clears_cache_on_update(self, client, mock_prefs, mock_settings_service): + resp = client.put( + "/settings/primary-source", + json={"source": "lastfm"}, + ) + assert resp.status_code == 200 + mock_settings_service.clear_home_cache.assert_awaited_once() + mock_settings_service.clear_source_resolution_cache.assert_awaited_once() diff --git a/backend/tests/routes/test_scrobble_routes.py b/backend/tests/routes/test_scrobble_routes.py new file mode 100644 index 0000000..483a75f --- /dev/null +++ b/backend/tests/routes/test_scrobble_routes.py @@ -0,0 +1,186 @@ +import time + +import pytest +from unittest.mock import AsyncMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.scrobble import router as scrobble_router +from api.v1.schemas.scrobble import ScrobbleResponse, ServiceResult +from core.dependencies import get_scrobble_service +from core.exceptions import ConfigurationError, ExternalServiceError +from tests.helpers import build_test_client + + +def _success_response() -> ScrobbleResponse: + return ScrobbleResponse( + accepted=True, + services={ + "lastfm": ServiceResult(success=True), + "listenbrainz": ServiceResult(success=True), + }, + ) + + +def _failure_response() -> ScrobbleResponse: + return ScrobbleResponse( + accepted=False, + services={ + "lastfm": ServiceResult(success=False, error="API down"), + }, + ) + + +@pytest.fixture +def mock_scrobble_service(): + mock = AsyncMock() + mock.report_now_playing.return_value = _success_response() + mock.submit_scrobble.return_value = _success_response() + return mock + + +@pytest.fixture +def client(mock_scrobble_service): + app = FastAPI() + app.include_router(scrobble_router) + app.dependency_overrides[get_scrobble_service] = lambda: mock_scrobble_service + return build_test_client(app) + + +class TestNowPlaying: + def test_success(self, client): + resp = client.post( + "/scrobble/now-playing", + json={"track_name": "Song", "artist_name": "Artist", "album_name": "Album", "duration_ms": 200000}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["accepted"] is True + assert "lastfm" in data["services"] + + def test_missing_required_field(self, client): + resp = client.post( + "/scrobble/now-playing", + json={"artist_name": "Artist"}, + ) + assert resp.status_code == 422 + + def test_config_error_returns_400(self, client, mock_scrobble_service): + mock_scrobble_service.report_now_playing.side_effect = ConfigurationError("No API key") + resp = client.post( + "/scrobble/now-playing", + json={"track_name": "Song", "artist_name": "Artist", "album_name": "Album", "duration_ms": 200000}, + ) + assert resp.status_code == 400 + assert "not configured" in resp.json()["error"]["message"].lower() + + def test_external_error_returns_502(self, client, mock_scrobble_service): + mock_scrobble_service.report_now_playing.side_effect = ExternalServiceError("timeout") + resp = client.post( + "/scrobble/now-playing", + json={"track_name": "Song", "artist_name": "Artist", "album_name": "Album", "duration_ms": 200000}, + ) + assert resp.status_code == 502 + + def test_unexpected_error_returns_500(self, client, mock_scrobble_service): + mock_scrobble_service.report_now_playing.side_effect = RuntimeError("crash") + resp = client.post( + "/scrobble/now-playing", + json={"track_name": "Song", "artist_name": "Artist", "album_name": "Album", "duration_ms": 200000}, + ) + assert resp.status_code == 500 + assert resp.json()["error"]["message"] == "Internal server error" + + +class TestSubmitScrobble: + def test_success(self, client): + ts = int(time.time()) - 60 + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": ts, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["accepted"] is True + + def test_future_timestamp_rejected(self, client): + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": int(time.time()) + 3600, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 422 + + def test_old_timestamp_rejected(self, client): + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": int(time.time()) - 15 * 86400, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 422 + + def test_config_error_returns_400(self, client, mock_scrobble_service): + mock_scrobble_service.submit_scrobble.side_effect = ConfigurationError("bad config") + ts = int(time.time()) - 60 + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": ts, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 400 + + def test_external_error_returns_502(self, client, mock_scrobble_service): + mock_scrobble_service.submit_scrobble.side_effect = ExternalServiceError("bad gateway") + ts = int(time.time()) - 60 + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": ts, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 502 + + def test_response_includes_service_details(self, client, mock_scrobble_service): + mock_scrobble_service.submit_scrobble.return_value = _failure_response() + ts = int(time.time()) - 60 + resp = client.post( + "/scrobble/submit", + json={ + "track_name": "Song", + "artist_name": "Artist", + "album_name": "Album", + "timestamp": ts, + "duration_ms": 200000, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["accepted"] is False + assert data["services"]["lastfm"]["success"] is False + assert "API down" in data["services"]["lastfm"]["error"] diff --git a/backend/tests/routes/test_scrobble_settings_routes.py b/backend/tests/routes/test_scrobble_settings_routes.py new file mode 100644 index 0000000..f590dc0 --- /dev/null +++ b/backend/tests/routes/test_scrobble_settings_routes.py @@ -0,0 +1,78 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.settings import router +from api.v1.schemas.settings import ScrobbleSettings +from core.dependencies import get_preferences_service +from core.exceptions import ConfigurationError +from tests.helpers import build_test_client + + +def _default_scrobble_settings() -> ScrobbleSettings: + return ScrobbleSettings(scrobble_to_lastfm=True, scrobble_to_listenbrainz=False) + + +@pytest.fixture +def mock_prefs(): + mock = MagicMock() + mock.get_scrobble_settings.return_value = _default_scrobble_settings() + mock.save_scrobble_settings = MagicMock() + return mock + + +@pytest.fixture +def client(mock_prefs): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_preferences_service] = lambda: mock_prefs + yield build_test_client(app) + + +class TestGetScrobbleSettings: + def test_returns_settings(self, client): + resp = client.get("/settings/scrobble") + assert resp.status_code == 200 + data = resp.json() + assert data["scrobble_to_lastfm"] is True + assert data["scrobble_to_listenbrainz"] is False + + def test_server_error(self, client, mock_prefs): + mock_prefs.get_scrobble_settings.side_effect = RuntimeError("boom") + resp = client.get("/settings/scrobble") + assert resp.status_code == 500 + assert resp.json()["error"]["message"] == "Internal server error" + + +class TestUpdateScrobbleSettings: + def test_saves_and_returns(self, client, mock_prefs): + updated = ScrobbleSettings(scrobble_to_lastfm=False, scrobble_to_listenbrainz=True) + mock_prefs.get_scrobble_settings.return_value = updated + resp = client.put( + "/settings/scrobble", + json={"scrobble_to_lastfm": False, "scrobble_to_listenbrainz": True}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["scrobble_to_lastfm"] is False + assert data["scrobble_to_listenbrainz"] is True + mock_prefs.save_scrobble_settings.assert_called_once() + + def test_config_error_returns_400(self, client, mock_prefs): + mock_prefs.save_scrobble_settings.side_effect = ConfigurationError("bad") + resp = client.put( + "/settings/scrobble", + json={"scrobble_to_lastfm": True, "scrobble_to_listenbrainz": True}, + ) + assert resp.status_code == 400 + + def test_server_error_returns_500(self, client, mock_prefs): + mock_prefs.save_scrobble_settings.side_effect = RuntimeError("disk full") + resp = client.put( + "/settings/scrobble", + json={"scrobble_to_lastfm": True, "scrobble_to_listenbrainz": True}, + ) + assert resp.status_code == 500 + assert resp.json()["error"]["message"] == "Internal server error" diff --git a/backend/tests/routes/test_search_routes.py b/backend/tests/routes/test_search_routes.py new file mode 100644 index 0000000..c5fe654 --- /dev/null +++ b/backend/tests/routes/test_search_routes.py @@ -0,0 +1,160 @@ +import pytest +import logging +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.search import SuggestResponse, SuggestResult, SearchResponse, SearchResult +from api.v1.routes.search import router +from core.dependencies import get_search_service, get_coverart_repository, get_search_enrichment_service + + +@pytest.fixture +def mock_search_service(): + mock_svc = MagicMock() + mock_svc.suggest = AsyncMock(return_value=SuggestResponse(results=[])) + return mock_svc + + +@pytest.fixture +def client(mock_search_service): + test_app = FastAPI() + test_app.include_router(router) + test_app.dependency_overrides[get_search_service] = lambda: mock_search_service + return TestClient(test_app) + + +def test_suggest_rejects_single_char_query(client, mock_search_service): + response = client.get("/search/suggest?q=a") + + assert response.status_code == 422 + mock_search_service.suggest.assert_not_called() + + +def test_suggest_rejects_empty_query(client, mock_search_service): + response = client.get("/search/suggest?q=") + + assert response.status_code == 422 + mock_search_service.suggest.assert_not_called() + + +def test_suggest_rejects_missing_query(client, mock_search_service): + response = client.get("/search/suggest") + + assert response.status_code == 422 + mock_search_service.suggest.assert_not_called() + + +def test_suggest_accepts_two_char_query(client, mock_search_service): + response = client.get("/search/suggest?q=ab") + + assert response.status_code == 200 + assert response.json() == {"results": []} + + +def test_suggest_limit_lower_bound(client, mock_search_service): + response = client.get("/search/suggest?q=test&limit=0") + + assert response.status_code == 422 + + +def test_suggest_limit_upper_bound(client, mock_search_service): + response = client.get("/search/suggest?q=test&limit=11") + + assert response.status_code == 422 + + +def test_suggest_limit_defaults_to_five(client, mock_search_service): + response = client.get("/search/suggest?q=test") + + assert response.status_code == 200 + mock_search_service.suggest.assert_called_once_with(query="test", limit=5) + + +def test_suggest_custom_limit(client, mock_search_service): + response = client.get("/search/suggest?q=test&limit=3") + + assert response.status_code == 200 + mock_search_service.suggest.assert_called_once_with(query="test", limit=3) + + +def test_suggest_whitespace_padded_short_input_returns_empty(client, mock_search_service): + """Whitespace-padded query that is < 2 chars after strip returns empty at route level.""" + response = client.get("/search/suggest?q=%20%20a%20%20") + + assert response.status_code == 200 + assert response.json() == {"results": []} + mock_search_service.suggest.assert_not_called() + + +def test_suggest_whitespace_padded_valid_input_strips(client, mock_search_service): + """Whitespace-padded query that is >= 2 chars after strip passes stripped value to service.""" + response = client.get("/search/suggest?q=%20%20ab%20%20") + + assert response.status_code == 200 + mock_search_service.suggest.assert_called_once_with(query="ab", limit=5) + + +def test_suggest_debug_log_contains_fields_without_query_text(client, mock_search_service, caplog): + """Debug log includes query_len, results, time_ms but not the raw query text.""" + mock_search_service.suggest = AsyncMock( + return_value=SuggestResponse(results=[ + SuggestResult( + type="artist", title="Muse", musicbrainz_id="mb-1", + in_library=False, requested=False, score=90, + ) + ]) + ) + + with caplog.at_level(logging.DEBUG, logger="api.v1.routes.search"): + response = client.get("/search/suggest?q=muse") + + assert response.status_code == 200 + log_messages = [r.message for r in caplog.records if "Suggest" in r.message] + assert len(log_messages) >= 1 + log_msg = log_messages[0] + assert "query_len=4" in log_msg + assert "results=1" in log_msg + assert "time_ms=" in log_msg + assert "muse" not in log_msg + + +def test_search_response_tolerates_additive_score_field(): + """Existing /api/search consumers tolerate the additive score field on SearchResult.""" + mock_search_service = MagicMock() + mock_search_service.search = AsyncMock(return_value=SearchResponse( + artists=[ + SearchResult( + type="artist", title="Muse", musicbrainz_id="mb-1", + in_library=False, requested=False, score=90, + ) + ], + albums=[ + SearchResult( + type="album", title="Absolution", musicbrainz_id="mb-2", + artist="Muse", in_library=True, requested=False, score=85, + ) + ], + )) + mock_search_service.schedule_cover_prefetch = MagicMock(return_value=[]) + + mock_coverart = MagicMock() + mock_enrichment = MagicMock() + + test_app = FastAPI() + test_app.include_router(router) + test_app.dependency_overrides[get_search_service] = lambda: mock_search_service + test_app.dependency_overrides[get_coverart_repository] = lambda: mock_coverart + test_app.dependency_overrides[get_search_enrichment_service] = lambda: mock_enrichment + search_client = TestClient(test_app) + + response = search_client.get("/search?q=muse") + + assert response.status_code == 200 + data = response.json() + assert "artists" in data + assert "albums" in data + assert data["artists"][0]["score"] == 90 + assert data["albums"][0]["score"] == 85 + assert data["artists"][0]["title"] == "Muse" diff --git a/backend/tests/routes/test_settings_audiodb_key.py b/backend/tests/routes/test_settings_audiodb_key.py new file mode 100644 index 0000000..ffe7399 --- /dev/null +++ b/backend/tests/routes/test_settings_audiodb_key.py @@ -0,0 +1,112 @@ +"""Route-level tests for PUT /api/settings/advanced — AudioDB API key merge.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.schemas.advanced_settings import ( + AdvancedSettings, + AdvancedSettingsFrontend, + _mask_api_key, +) +from api.v1.routes.settings import router +from core.dependencies import get_preferences_service, get_settings_service + + +def _stored_backend(api_key: str = "originalsecret") -> AdvancedSettings: + return AdvancedSettings(audiodb_api_key=api_key) + + +@pytest.fixture +def mock_prefs(): + mock = MagicMock() + mock.get_advanced_settings.return_value = _stored_backend("originalsecret") + mock.save_advanced_settings = MagicMock() + return mock + + +@pytest.fixture +def mock_settings_service(): + mock = MagicMock() + mock.on_coverart_settings_changed = AsyncMock() + return mock + + +@pytest.fixture +def client(mock_prefs, mock_settings_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_preferences_service] = lambda: mock_prefs + app.dependency_overrides[get_settings_service] = lambda: mock_settings_service + yield TestClient(app) + + +def _default_frontend_payload(**overrides) -> dict: + base = AdvancedSettingsFrontend() + data = {f: getattr(base, f) for f in base.__struct_fields__} + data.update(overrides) + return data + + +class TestPutAdvancedSettingsApiKeyMerge: + def test_masked_key_preserves_stored_key(self, client, mock_prefs): + masked = _mask_api_key("originalsecret") + payload = _default_frontend_payload(audiodb_api_key=masked) + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + saved = mock_prefs.save_advanced_settings.call_args[0][0] + assert saved.audiodb_api_key == "originalsecret" + + def test_new_plaintext_key_is_saved(self, client, mock_prefs): + payload = _default_frontend_payload(audiodb_api_key="newkey456") + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + saved = mock_prefs.save_advanced_settings.call_args[0][0] + assert saved.audiodb_api_key == "newkey456" + + def test_short_masked_key_preserves_stored_key(self, client, mock_prefs): + payload = _default_frontend_payload(audiodb_api_key="***") + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + saved = mock_prefs.save_advanced_settings.call_args[0][0] + assert saved.audiodb_api_key == "originalsecret" + + def test_response_contains_masked_key_not_plaintext(self, client, mock_prefs): + payload = _default_frontend_payload(audiodb_api_key="newkey456") + mock_prefs.get_advanced_settings.return_value = _stored_backend("newkey456") + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["audiodb_api_key"] == _mask_api_key("newkey456") + assert "newkey456" not in data["audiodb_api_key"] + + def test_response_after_masked_submit_returns_masked(self, client, mock_prefs): + masked = _mask_api_key("originalsecret") + payload = _default_frontend_payload(audiodb_api_key=masked) + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["audiodb_api_key"] == _mask_api_key("originalsecret") + + def test_response_reflects_backend_normalization(self, client, mock_prefs): + """Empty key is coerced to '123' by __post_init__, response should reflect that.""" + mock_prefs.get_advanced_settings.return_value = _stored_backend("123") + payload = _default_frontend_payload(audiodb_api_key="") + + response = client.put("/settings/advanced", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["audiodb_api_key"] == _mask_api_key("123") diff --git a/backend/tests/routes/test_stream_routes.py b/backend/tests/routes/test_stream_routes.py new file mode 100644 index 0000000..626127a --- /dev/null +++ b/backend/tests/routes/test_stream_routes.py @@ -0,0 +1,183 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.v1.routes.stream import router +from core.dependencies import get_jellyfin_playback_service, get_jellyfin_repository +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError +from repositories.jellyfin_models import PlaybackUrlResult + + +@pytest.fixture +def mock_jellyfin_repo(): + mock = MagicMock() + mock.get_playback_url = AsyncMock( + return_value=PlaybackUrlResult( + url="http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key", + seekable=True, + play_session_id="sess-1", + play_method="DirectPlay", + ) + ) + return mock + + +@pytest.fixture +def mock_playback_service(): + mock = MagicMock() + mock.start_playback = AsyncMock(return_value="sess-start") + return mock + + +@pytest.fixture +def client(mock_jellyfin_repo, mock_playback_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_jellyfin_repository] = lambda: mock_jellyfin_repo + app.dependency_overrides[get_jellyfin_playback_service] = lambda: mock_playback_service + return TestClient(app) + + +def test_get_stream_returns_json_with_seekable_and_session(client): + response = client.get("/stream/jellyfin/item-1") + + assert response.status_code == 200 + assert response.json() == { + "url": "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key", + "seekable": True, + "playSessionId": "sess-1", + } + + +def test_get_stream_transcode_returns_non_seekable(client, mock_jellyfin_repo): + mock_jellyfin_repo.get_playback_url = AsyncMock( + return_value=PlaybackUrlResult( + url="http://jellyfin:8096/Audio/item-2/universal?container=opus", + seekable=False, + play_session_id="sess-2", + play_method="Transcode", + ) + ) + + response = client.get("/stream/jellyfin/item-2") + + assert response.status_code == 200 + assert response.json()["seekable"] is False + assert "/universal" in response.json()["url"] + + +def test_get_stream_returns_404_when_item_missing(client, mock_jellyfin_repo): + mock_jellyfin_repo.get_playback_url.side_effect = ResourceNotFoundError("missing") + + response = client.get("/stream/jellyfin/missing-item") + + assert response.status_code == 404 + + +def test_get_stream_returns_403_when_playback_not_allowed(client, mock_jellyfin_repo): + mock_jellyfin_repo.get_playback_url.side_effect = PlaybackNotAllowedError("NotAllowed") + + response = client.get("/stream/jellyfin/item-denied") + + assert response.status_code == 403 + + +def test_get_stream_returns_502_on_external_error(client, mock_jellyfin_repo): + mock_jellyfin_repo.get_playback_url.side_effect = ExternalServiceError("jellyfin down") + + response = client.get("/stream/jellyfin/item-err") + + assert response.status_code == 502 + + +def test_head_stream_returns_redirect(client): + response = client.request("HEAD", "/stream/jellyfin/item-1", follow_redirects=False) + + assert response.status_code == 302 + assert response.headers["location"] == "http://jellyfin:8096/Audio/item-1/stream?static=true&api_key=test-key" + + +def test_head_stream_sets_no_referrer_policy(client): + response = client.request("HEAD", "/stream/jellyfin/item-1", follow_redirects=False) + + assert response.status_code == 302 + assert response.headers["referrer-policy"] == "no-referrer" + + +def test_start_stream_uses_existing_play_session_id(client, mock_playback_service): + response = client.post( + "/stream/jellyfin/item-1/start", + json={"play_session_id": "sess-existing"}, + ) + + assert response.status_code == 200 + assert response.json() == { + "play_session_id": "sess-start", + "item_id": "item-1", + } + mock_playback_service.start_playback.assert_awaited_once_with( + "item-1", + play_session_id="sess-existing", + ) + + +def test_start_stream_without_payload_uses_service_default(client, mock_playback_service): + response = client.post("/stream/jellyfin/item-2/start") + + assert response.status_code == 200 + assert response.json()["item_id"] == "item-2" + mock_playback_service.start_playback.assert_awaited_once_with( + "item-2", + play_session_id=None, + ) + + + +from core.dependencies import get_local_files_service + + +@pytest.fixture +def mock_local_service(): + mock = MagicMock() + mock.head_track = AsyncMock( + return_value={ + "Content-Type": "audio/flac", + "Content-Length": "30000000", + "Accept-Ranges": "bytes", + } + ) + return mock + + +@pytest.fixture +def local_client(mock_local_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_local_files_service] = lambda: mock_local_service + return TestClient(app) + + +def test_head_local_returns_200_with_headers(local_client, mock_local_service): + response = local_client.request("HEAD", "/stream/local/42") + + assert response.status_code == 200 + assert response.headers["accept-ranges"] == "bytes" + mock_local_service.head_track.assert_awaited_once_with(42) + + +def test_head_local_returns_404_when_not_found(local_client, mock_local_service): + mock_local_service.head_track.side_effect = ResourceNotFoundError("not found") + + response = local_client.request("HEAD", "/stream/local/999") + + assert response.status_code == 404 + + +def test_head_local_returns_403_on_permission_error(local_client, mock_local_service): + mock_local_service.head_track.side_effect = PermissionError("outside dir") + + response = local_client.request("HEAD", "/stream/local/42") + + assert response.status_code == 403 diff --git a/backend/tests/services/__init__.py b/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/services/test_album_audiodb_population.py b/backend/tests/services/test_album_audiodb_population.py new file mode 100644 index 0000000..2367afb --- /dev/null +++ b/backend/tests/services/test_album_audiodb_population.py @@ -0,0 +1,223 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.album import AlbumInfo, AlbumBasicInfo +from repositories.audiodb_models import AudioDBAlbumImages +from services.album_service import AlbumService + + +SAMPLE_IMAGES = AudioDBAlbumImages( + album_thumb_url="https://cdn.example.com/album_thumb.jpg", + album_back_url="https://cdn.example.com/album_back.jpg", + album_cdart_url="https://cdn.example.com/album_cdart.png", + album_spine_url="https://cdn.example.com/album_spine.jpg", + album_3d_case_url="https://cdn.example.com/3d_case.png", + album_3d_flat_url="https://cdn.example.com/3d_flat.png", + album_3d_face_url="https://cdn.example.com/3d_face.png", + album_3d_thumb_url="https://cdn.example.com/3d_thumb.png", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +TEST_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + + +def _make_album_info(**overrides) -> AlbumInfo: + defaults = dict( + title="Parachutes", + musicbrainz_id=TEST_MBID, + artist_name="Coldplay", + artist_id="cc197bad-dc9c-440d-a5b5-d52ba2e14234", + ) + defaults.update(overrides) + return AlbumInfo(**defaults) + + +def _make_service( + audiodb_service: MagicMock | None = None, +) -> AlbumService: + if audiodb_service is None: + audiodb_service = MagicMock() + return AlbumService( + lidarr_repo=MagicMock(), + mb_repo=MagicMock(), + library_db=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + preferences_service=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +class TestApplyAudioDBAlbumImages: + + @pytest.mark.asyncio + async def test_populates_all_fields(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + assert result.album_back_url == "https://cdn.example.com/album_back.jpg" + assert result.album_cdart_url == "https://cdn.example.com/album_cdart.png" + assert result.album_spine_url == "https://cdn.example.com/album_spine.jpg" + assert result.album_3d_case_url == "https://cdn.example.com/3d_case.png" + assert result.album_3d_flat_url == "https://cdn.example.com/3d_flat.png" + assert result.album_3d_face_url == "https://cdn.example.com/3d_face.png" + assert result.album_3d_thumb_url == "https://cdn.example.com/3d_thumb.png" + + @pytest.mark.asyncio + async def test_cover_url_unchanged(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + album = _make_album_info(cover_url="https://coverart.example.com/cover.jpg") + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.cover_url == "https://coverart.example.com/cover.jpg" + + @pytest.mark.asyncio + async def test_no_service_returns_unchanged(self): + svc = _make_service(audiodb_service=None) + svc._audiodb_image_service = None + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url is None + + @pytest.mark.asyncio + async def test_cache_miss_returns_unchanged(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=None) + svc = _make_service(audiodb) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=False, + ) + + assert result.album_thumb_url is None + audiodb.get_cached_album_images.assert_awaited_once_with(TEST_MBID) + + @pytest.mark.asyncio + async def test_negative_cache_returns_unchanged(self): + negative = AudioDBAlbumImages.negative(lookup_source="mbid") + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(return_value=negative) + svc = _make_service(audiodb) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url is None + + @pytest.mark.asyncio + async def test_fetch_mode_calls_fetch(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + album = _make_album_info() + + await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", + allow_fetch=True, is_monitored=True, + ) + + audiodb.fetch_and_cache_album_images.assert_awaited_once_with( + TEST_MBID, "Coldplay", "Parachutes", is_monitored=True, + ) + + @pytest.mark.asyncio + async def test_cache_only_mode_calls_get_cached(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + album = _make_album_info() + + await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=False, + ) + + audiodb.get_cached_album_images.assert_awaited_once_with(TEST_MBID) + + @pytest.mark.asyncio + async def test_exception_safe(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(side_effect=RuntimeError("boom")) + svc = _make_service(audiodb) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url is None + assert result.title == "Parachutes" + + +class TestGetAudioDBAlbumThumb: + + @pytest.mark.asyncio + async def test_returns_thumb_from_cache(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + + result = await svc._get_audiodb_album_thumb(TEST_MBID) + + assert result == "https://cdn.example.com/album_thumb.jpg" + + @pytest.mark.asyncio + async def test_returns_none_on_miss(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=None) + svc = _make_service(audiodb) + + result = await svc._get_audiodb_album_thumb(TEST_MBID) + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_negative(self): + negative = AudioDBAlbumImages.negative(lookup_source="mbid") + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=negative) + svc = _make_service(audiodb) + + result = await svc._get_audiodb_album_thumb(TEST_MBID) + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_service(self): + svc = _make_service(audiodb_service=None) + svc._audiodb_image_service = None + + result = await svc._get_audiodb_album_thumb(TEST_MBID) + + assert result is None + + @pytest.mark.asyncio + async def test_exception_safe(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(side_effect=RuntimeError("boom")) + svc = _make_service(audiodb) + + result = await svc._get_audiodb_album_thumb(TEST_MBID) + + assert result is None diff --git a/backend/tests/services/test_album_enrichment_service.py b/backend/tests/services/test_album_enrichment_service.py new file mode 100644 index 0000000..6fadf31 --- /dev/null +++ b/backend/tests/services/test_album_enrichment_service.py @@ -0,0 +1,96 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from repositories.lastfm_models import LastFmAlbumInfo, LastFmTag +from services.album_enrichment_service import AlbumEnrichmentService + + +def _make_lastfm_repo() -> AsyncMock: + repo = AsyncMock() + repo.get_album_info = AsyncMock( + return_value=LastFmAlbumInfo( + name="OK Computer", + artist_name="Radiohead", + mbid="album-mbid-123", + listeners=2_000_000, + playcount=100_000_000, + url="https://www.last.fm/music/Radiohead/OK+Computer", + summary="OK Computer is the third studio album by Radiohead.", + tags=[ + LastFmTag(name="alternative rock", url="https://last.fm/tag/alternative+rock"), + LastFmTag(name="90s", url="https://last.fm/tag/90s"), + ], + ) + ) + return repo + + +def _make_preferences(enabled: bool = True) -> MagicMock: + prefs = MagicMock() + prefs.is_lastfm_enabled.return_value = enabled + return prefs + + +@pytest.mark.asyncio +async def test_album_enrichment_returns_data_when_enabled(): + repo = _make_lastfm_repo() + prefs = _make_preferences(enabled=True) + svc = AlbumEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("Radiohead", "OK Computer", "album-mbid-123") + + assert result is not None + assert result.listeners == 2_000_000 + assert result.playcount == 100_000_000 + assert result.url == "https://www.last.fm/music/Radiohead/OK+Computer" + assert len(result.tags) == 2 + assert result.tags[0].name == "alternative rock" + + +@pytest.mark.asyncio +async def test_album_enrichment_strips_html_from_summary(): + repo = _make_lastfm_repo() + prefs = _make_preferences(enabled=True) + svc = AlbumEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("Radiohead", "OK Computer") + + assert result is not None + assert "" not in (result.summary or "") + assert "OK Computer is the third studio album by Radiohead." in (result.summary or "") + + +@pytest.mark.asyncio +async def test_album_enrichment_returns_none_when_disabled(): + repo = _make_lastfm_repo() + prefs = _make_preferences(enabled=False) + svc = AlbumEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("Radiohead", "OK Computer") + + assert result is None + repo.get_album_info.assert_not_called() + + +@pytest.mark.asyncio +async def test_album_enrichment_returns_none_when_api_returns_none(): + repo = AsyncMock() + repo.get_album_info = AsyncMock(return_value=None) + prefs = _make_preferences(enabled=True) + svc = AlbumEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("Radiohead", "OK Computer") + + assert result is None + + +@pytest.mark.asyncio +async def test_album_enrichment_returns_none_on_exception(): + repo = AsyncMock() + repo.get_album_info = AsyncMock(side_effect=Exception("API error")) + prefs = _make_preferences(enabled=True) + svc = AlbumEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("Radiohead", "OK Computer") + + assert result is None diff --git a/backend/tests/services/test_album_service.py b/backend/tests/services/test_album_service.py new file mode 100644 index 0000000..28204b5 --- /dev/null +++ b/backend/tests/services/test_album_service.py @@ -0,0 +1,156 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.album import AlbumInfo +from services.album_service import AlbumService + + +def _make_service() -> tuple[AlbumService, MagicMock, MagicMock]: + lidarr_repo = MagicMock() + mb_repo = MagicMock() + library_db = MagicMock() + memory_cache = MagicMock() + disk_cache = MagicMock() + preferences_service = MagicMock() + audiodb_image_service = MagicMock() + + service = AlbumService( + lidarr_repo=lidarr_repo, + mb_repo=mb_repo, + library_db=library_db, + memory_cache=memory_cache, + disk_cache=disk_cache, + preferences_service=preferences_service, + audiodb_image_service=audiodb_image_service, + ) + return service, lidarr_repo, library_db + + +@pytest.mark.asyncio +async def test_revalidate_library_status_keeps_value_when_lidarr_details_unavailable(): + service, lidarr_repo, _ = _make_service() + lidarr_repo.get_album_details = AsyncMock(return_value=None) + lidarr_repo.get_library_mbids = AsyncMock(return_value={"should-not-be-used"}) + service._save_album_to_cache = AsyncMock() + + album_info = AlbumInfo( + title="Test", + musicbrainz_id="4549a80c-efe6-4386-b3a2-4b4a918eb31f", + artist_name="Artist", + artist_id="artist-id", + in_library=True, + ) + + result = await service._revalidate_library_status(album_info.musicbrainz_id, album_info) + + assert result.in_library is True + service._save_album_to_cache.assert_not_called() + lidarr_repo.get_library_mbids.assert_not_called() + + +@pytest.mark.asyncio +async def test_revalidate_library_status_uses_lidarr_details_and_updates_cache_on_change(): + service, lidarr_repo, _ = _make_service() + lidarr_repo.get_album_details = AsyncMock( + return_value={"monitored": False, "statistics": {"trackFileCount": 0}} + ) + service._save_album_to_cache = AsyncMock() + + album_info = AlbumInfo( + title="Test", + musicbrainz_id="8e1e9e51-38dc-4df3-8027-a0ada37d4674", + artist_name="Artist", + artist_id="artist-id", + in_library=True, + ) + + result = await service._revalidate_library_status(album_info.musicbrainz_id, album_info) + + assert result.in_library is False + service._save_album_to_cache.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_get_album_basic_info_does_not_use_library_cache_when_lidarr_payload_exists(): + service, lidarr_repo, library_db = _make_service() + service._get_cached_album_info = AsyncMock(return_value=None) + service._fetch_release_group = AsyncMock( + return_value={ + "title": "Album", + "first-release-date": "2024-01-01", + "primary-type": "Album", + "disambiguation": "", + "artist-credit": [], + } + ) + + lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + lidarr_repo.get_album_details = AsyncMock( + return_value={"monitored": False, "statistics": {"trackFileCount": 20}} + ) + library_db.get_album_by_mbid = AsyncMock(return_value={"mbid": "from-cache"}) + + result = await service.get_album_basic_info("8e1e9e51-38dc-4df3-8027-a0ada37d4674") + + assert result.in_library is False + library_db.get_album_by_mbid.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_get_album_tracks_info_preserves_disc_numbers_from_lidarr(): + service, lidarr_repo, _ = _make_service() + service._get_cached_album_info = AsyncMock(return_value=None) + lidarr_repo.is_configured.return_value = True + lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True}) + lidarr_repo.get_album_tracks = AsyncMock( + return_value=[ + { + "track_number": 1, + "disc_number": 1, + "title": "Disc One", + "duration_ms": 1000, + }, + { + "track_number": 1, + "disc_number": 2, + "title": "Disc Two", + "duration_ms": 2000, + }, + ] + ) + + result = await service.get_album_tracks_info("8e1e9e51-38dc-4df3-8027-a0ada37d4674") + + assert [(track.disc_number, track.position, track.title) for track in result.tracks] == [ + (1, 1, "Disc One"), + (2, 1, "Disc Two"), + ] + assert result.total_length == 3000 + + +@pytest.mark.asyncio +async def test_get_album_tracks_info_multi_disc_same_track_numbers(): + """Verify tracks with same track_number but different disc_number are kept distinct.""" + service, lidarr_repo, _ = _make_service() + service._get_cached_album_info = AsyncMock(return_value=None) + lidarr_repo.is_configured.return_value = True + lidarr_repo.get_album_details = AsyncMock(return_value={"id": 42, "monitored": True}) + lidarr_repo.get_album_tracks = AsyncMock( + return_value=[ + {"track_number": 1, "disc_number": 1, "title": "Intro", "duration_ms": 1000}, + {"track_number": 2, "disc_number": 1, "title": "Main", "duration_ms": 2000}, + {"track_number": 1, "disc_number": 2, "title": "Intro II", "duration_ms": 1500}, + {"track_number": 2, "disc_number": 2, "title": "Finale", "duration_ms": 3000}, + ] + ) + + result = await service.get_album_tracks_info("8e1e9e51-38dc-4df3-8027-a0ada37d4674") + + assert len(result.tracks) == 4 + disc_track_pairs = [(track.disc_number, track.position, track.title) for track in result.tracks] + assert (1, 1, "Intro") in disc_track_pairs + assert (1, 2, "Main") in disc_track_pairs + assert (2, 1, "Intro II") in disc_track_pairs + assert (2, 2, "Finale") in disc_track_pairs + assert result.total_length == 7500 diff --git a/backend/tests/services/test_album_singleflight.py b/backend/tests/services/test_album_singleflight.py new file mode 100644 index 0000000..c5732d0 --- /dev/null +++ b/backend/tests/services/test_album_singleflight.py @@ -0,0 +1,180 @@ +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from api.v1.schemas.album import AlbumInfo +from core.exceptions import ResourceNotFoundError +from services.album_service import AlbumService + + +MBID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +def _fake_album_info() -> AlbumInfo: + return AlbumInfo( + title="Test Album", + artist_name="Test Artist", + musicbrainz_id=MBID, + artist_id="artist-" + MBID, + release_date="2024", + ) + + +def _make_service() -> AlbumService: + lidarr = AsyncMock() + lidarr.is_configured.return_value = False + mb = AsyncMock() + lib_cache = AsyncMock() + mem_cache = AsyncMock() + mem_cache.get = AsyncMock(return_value=None) + mem_cache.set = AsyncMock() + disk_cache = MagicMock() + disk_cache.get_album = AsyncMock(return_value=None) + disk_cache.set_album = AsyncMock() + prefs = MagicMock() + audiodb_img = MagicMock() + audiodb_img.fetch_and_cache_album_images = AsyncMock(return_value=None) + + svc = AlbumService( + lidarr_repo=lidarr, + mb_repo=mb, + library_db=lib_cache, + memory_cache=mem_cache, + disk_cache=disk_cache, + preferences_service=prefs, + audiodb_image_service=audiodb_img, + ) + return svc + + +class TestAlbumSingleflight: + @pytest.mark.asyncio + async def test_concurrent_calls_fetch_once(self): + """Multiple concurrent get_album_info calls for the same ID + should only invoke _do_get_album_info once.""" + svc = _make_service() + call_count = 0 + fake = _fake_album_info() + + async def counting_fetch(*args, **kwargs): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return fake + + svc._do_get_album_info = counting_fetch + + results = await asyncio.gather( + svc.get_album_info(MBID), + svc.get_album_info(MBID), + svc.get_album_info(MBID), + ) + + assert call_count == 1 + assert all(r.title == "Test Album" for r in results) + + @pytest.mark.asyncio + async def test_singleflight_cleared_after_completion(self): + """After completion, the in-flight dict should be empty.""" + svc = _make_service() + fake = _fake_album_info() + + async def quick_fetch(*args, **kwargs): + return fake + + svc._do_get_album_info = quick_fetch + + await svc.get_album_info(MBID) + assert MBID not in svc._album_in_flight + + @pytest.mark.asyncio + async def test_singleflight_propagates_exception(self): + """If fetch raises, all concurrent callers should get the exception.""" + svc = _make_service() + + async def failing_fetch(*args, **kwargs): + await asyncio.sleep(0.05) + raise RuntimeError("upstream timeout") + + svc._do_get_album_info = failing_fetch + + results = await asyncio.gather( + svc.get_album_info(MBID), + svc.get_album_info(MBID), + svc.get_album_info(MBID), + return_exceptions=True, + ) + + assert all(isinstance(r, ResourceNotFoundError) for r in results) + assert MBID not in svc._album_in_flight + + @pytest.mark.asyncio + async def test_cache_hit_bypasses_singleflight(self): + """Cache hit should not trigger _do_get_album_info at all.""" + svc = _make_service() + fake = _fake_album_info() + svc._get_cached_album_info = AsyncMock(return_value=fake) + svc._apply_audiodb_album_images = AsyncMock(return_value=fake) + call_count = 0 + + async def should_not_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + return fake + + svc._do_get_album_info = should_not_run + + result = await svc.get_album_info(MBID) + assert result.title == "Test Album" + assert call_count == 0 + + @pytest.mark.asyncio + async def test_different_ids_run_independently(self): + """Different release_group_ids should run in parallel.""" + svc = _make_service() + call_ids: list[str] = [] + + async def tracking_fetch(rgid, *args, **kwargs): + call_ids.append(rgid) + await asyncio.sleep(0.02) + return _fake_album_info() + + svc._do_get_album_info = tracking_fetch + + mbid_a = "aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" + mbid_b = "bbbb2222-bbbb-cccc-dddd-eeeeeeeeeeee" + await asyncio.gather( + svc.get_album_info(mbid_a), + svc.get_album_info(mbid_b), + ) + + assert len(call_ids) == 2 + assert mbid_a in call_ids + assert mbid_b in call_ids + + @pytest.mark.asyncio + async def test_follower_cancellation_does_not_break_leader(self): + """Cancelling a follower task must not poison the shared future.""" + svc = _make_service() + gate = asyncio.Event() + + async def slow_fetch(*args, **kwargs): + await gate.wait() + return _fake_album_info() + + svc._do_get_album_info = slow_fetch + + leader_task = asyncio.create_task(svc.get_album_info(MBID)) + await asyncio.sleep(0) + follower_task = asyncio.create_task(svc.get_album_info(MBID)) + await asyncio.sleep(0) + + follower_task.cancel() + with pytest.raises(asyncio.CancelledError): + await follower_task + + gate.set() + result = await leader_task + assert isinstance(result, AlbumInfo) + assert MBID not in svc._album_in_flight diff --git a/backend/tests/services/test_album_utils.py b/backend/tests/services/test_album_utils.py new file mode 100644 index 0000000..f513b6c --- /dev/null +++ b/backend/tests/services/test_album_utils.py @@ -0,0 +1,44 @@ +from services.album_utils import extract_tracks + + +def test_extract_tracks_preserves_disc_numbers_and_track_positions(): + release_data = { + "media": [ + { + "position": "1", + "tracks": [ + { + "position": "1", + "title": "Disc One Intro", + "length": 1000, + "recording": {"id": "rec-1", "title": "Disc One Intro"}, + }, + { + "position": "2", + "title": "Disc One Main", + "recording": {"id": "rec-2", "title": "Disc One Main", "length": 2000}, + }, + ], + }, + { + "position": "2", + "tracks": [ + { + "position": "1", + "title": "Disc Two Outro", + "length": 3000, + "recording": {"id": "rec-3", "title": "Disc Two Outro"}, + } + ], + }, + ] + } + + tracks, total_length = extract_tracks(release_data) + + assert [(track.disc_number, track.position, track.title, track.recording_id) for track in tracks] == [ + (1, 1, "Disc One Intro", "rec-1"), + (1, 2, "Disc One Main", "rec-2"), + (2, 1, "Disc Two Outro", "rec-3"), + ] + assert total_length == 6000 diff --git a/backend/tests/services/test_artist_audiodb_population.py b/backend/tests/services/test_artist_audiodb_population.py new file mode 100644 index 0000000..f5aae90 --- /dev/null +++ b/backend/tests/services/test_artist_audiodb_population.py @@ -0,0 +1,213 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.artist import ArtistInfo +from repositories.audiodb_models import AudioDBArtistImages +from services.artist_service import ArtistService + + +SAMPLE_IMAGES = AudioDBArtistImages( + thumb_url="https://cdn.example.com/thumb.jpg", + fanart_url="https://cdn.example.com/fanart1.jpg", + fanart_url_2="https://cdn.example.com/fanart2.jpg", + fanart_url_3="https://cdn.example.com/fanart3.jpg", + fanart_url_4="https://cdn.example.com/fanart4.jpg", + wide_thumb_url="https://cdn.example.com/wide.jpg", + banner_url="https://cdn.example.com/banner.jpg", + logo_url="https://cdn.example.com/logo.png", + clearart_url="https://cdn.example.com/clearart.png", + cutout_url="https://cdn.example.com/cutout.png", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +def _make_artist_info(**overrides) -> ArtistInfo: + defaults = dict( + name="Coldplay", + musicbrainz_id=TEST_MBID, + ) + defaults.update(overrides) + return ArtistInfo(**defaults) + + +def _make_service( + audiodb_service: MagicMock | None = None, +) -> ArtistService: + if audiodb_service is None: + audiodb_service = MagicMock() + return ArtistService( + mb_repo=MagicMock(), + lidarr_repo=MagicMock(), + wikidata_repo=MagicMock(), + preferences_service=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +class TestApplyAudioDBArtistImages: + + @pytest.mark.asyncio + async def test_populates_all_fields(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url == "https://cdn.example.com/thumb.jpg" + assert result.fanart_url == "https://cdn.example.com/fanart1.jpg" + assert result.fanart_url_2 == "https://cdn.example.com/fanart2.jpg" + assert result.fanart_url_3 == "https://cdn.example.com/fanart3.jpg" + assert result.fanart_url_4 == "https://cdn.example.com/fanart4.jpg" + assert result.wide_thumb_url == "https://cdn.example.com/wide.jpg" + assert result.banner_url == "https://cdn.example.com/banner.jpg" + assert result.logo_url == "https://cdn.example.com/logo.png" + assert result.clearart_url == "https://cdn.example.com/clearart.png" + assert result.cutout_url == "https://cdn.example.com/cutout.png" + + @pytest.mark.asyncio + async def test_lidarr_fanart_not_overridden(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info(fanart_url="https://lidarr.example.com/fanart.jpg") + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.fanart_url == "https://lidarr.example.com/fanart.jpg" + + @pytest.mark.asyncio + async def test_lidarr_banner_not_overridden(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info(banner_url="https://lidarr.example.com/banner.jpg") + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.banner_url == "https://lidarr.example.com/banner.jpg" + + @pytest.mark.asyncio + async def test_fills_missing_fanart(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info(fanart_url=None) + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.fanart_url == "https://cdn.example.com/fanart1.jpg" + + @pytest.mark.asyncio + async def test_fills_missing_banner(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info(banner_url=None) + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.banner_url == "https://cdn.example.com/banner.jpg" + + @pytest.mark.asyncio + async def test_no_service_returns_unchanged(self): + svc = _make_service(audiodb_service=None) + svc._audiodb_image_service = None + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + assert result.fanart_url_2 is None + + @pytest.mark.asyncio + async def test_cache_miss_returns_unchanged(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=None) + svc = _make_service(audiodb) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=False, + ) + + assert result.thumb_url is None + audiodb.get_cached_artist_images.assert_awaited_once_with(TEST_MBID) + + @pytest.mark.asyncio + async def test_negative_cache_returns_unchanged(self): + negative = AudioDBArtistImages.negative(lookup_source="mbid") + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=negative) + svc = _make_service(audiodb) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + + @pytest.mark.asyncio + async def test_fetch_mode_calls_fetch(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info() + + await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", + allow_fetch=True, is_monitored=True, + ) + + audiodb.fetch_and_cache_artist_images.assert_awaited_once_with( + TEST_MBID, "Coldplay", is_monitored=True, + ) + + @pytest.mark.asyncio + async def test_cache_only_mode_calls_get_cached(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=SAMPLE_IMAGES) + svc = _make_service(audiodb) + artist = _make_artist_info() + + await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=False, + ) + + audiodb.get_cached_artist_images.assert_awaited_once_with(TEST_MBID) + audiodb.fetch_and_cache_artist_images.assert_not_called() + + @pytest.mark.asyncio + async def test_exception_logged_not_raised(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_artist_images = AsyncMock(side_effect=RuntimeError("boom")) + svc = _make_service(audiodb) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + assert result.name == "Coldplay" diff --git a/backend/tests/services/test_artist_discovery_service.py b/backend/tests/services/test_artist_discovery_service.py new file mode 100644 index 0000000..e43f975 --- /dev/null +++ b/backend/tests/services/test_artist_discovery_service.py @@ -0,0 +1,492 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +from repositories.lastfm_models import LastFmAlbum, LastFmSimilarArtist, LastFmTrack +from repositories.listenbrainz_models import ListenBrainzRecording, ListenBrainzReleaseGroup +from services.artist_discovery_service import ArtistDiscoveryService + + +def _make_lb_repo(configured: bool = True) -> MagicMock: + repo = MagicMock() + repo.is_configured.return_value = configured + repo.get_similar_artists = AsyncMock(return_value=[]) + repo.get_artist_top_recordings = AsyncMock(return_value=[]) + repo.get_artist_top_release_groups = AsyncMock(return_value=[]) + return repo + + +def _make_lastfm_repo(enabled: bool = True) -> AsyncMock: + repo = AsyncMock() + repo.get_similar_artists = AsyncMock(return_value=[]) + repo.get_artist_top_tracks = AsyncMock(return_value=[]) + repo.get_artist_top_albums = AsyncMock(return_value=[]) + return repo + + +def _make_prefs(enabled: bool = True) -> MagicMock: + prefs = MagicMock() + prefs.is_lastfm_enabled.return_value = enabled + return prefs + + +def _make_library_db() -> AsyncMock: + cache = AsyncMock() + cache.get_all_artist_mbids = AsyncMock(return_value=set()) + return cache + + +def _make_memory_cache() -> AsyncMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return cache + + +def _make_service( + lb_configured: bool = True, + lastfm_enabled: bool = True, +) -> tuple[ArtistDiscoveryService, MagicMock, AsyncMock, MagicMock]: + lb_repo = _make_lb_repo(configured=lb_configured) + lastfm_repo = _make_lastfm_repo(enabled=lastfm_enabled) + prefs = _make_prefs(enabled=lastfm_enabled) + library_db = _make_library_db() + memory_cache = _make_memory_cache() + mb_repo = AsyncMock() + lidarr_repo = AsyncMock() + + svc = ArtistDiscoveryService( + listenbrainz_repo=lb_repo, + musicbrainz_repo=mb_repo, + library_db=library_db, + lidarr_repo=lidarr_repo, + memory_cache=memory_cache, + lastfm_repo=lastfm_repo, + preferences_service=prefs, + ) + return svc, lb_repo, lastfm_repo, prefs + + +class TestGetSimilarArtistsSource: + @pytest.mark.asyncio + async def test_default_source_uses_listenbrainz(self): + svc, lb_repo, lastfm_repo, _ = _make_service() + + result = await svc.get_similar_artists("mbid-123", count=5) + + assert result.source == "listenbrainz" + lb_repo.get_similar_artists.assert_called_once() + lastfm_repo.get_similar_artists.assert_not_called() + + @pytest.mark.asyncio + async def test_source_lastfm_calls_lastfm(self): + lastfm_similar = [ + LastFmSimilarArtist(name="Artist A", mbid="mbid-a", match=0.9, url=""), + LastFmSimilarArtist(name="Artist B", mbid="mbid-b", match=0.8, url=""), + ] + svc, lb_repo, lastfm_repo, _ = _make_service() + lastfm_repo.get_similar_artists.return_value = lastfm_similar + + result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm") + + assert result.source == "lastfm" + lastfm_repo.get_similar_artists.assert_called_once() + lb_repo.get_similar_artists.assert_not_called() + assert len(result.similar_artists) == 2 + assert result.similar_artists[0].name == "Artist A" + assert result.similar_artists[0].musicbrainz_id == "mbid-a" + + @pytest.mark.asyncio + async def test_source_lastfm_filters_artists_without_mbid(self): + lastfm_similar = [ + LastFmSimilarArtist(name="Has MBID", mbid="mbid-a", match=0.9, url=""), + LastFmSimilarArtist(name="No MBID", mbid=None, match=0.8, url=""), + LastFmSimilarArtist(name="Empty MBID", mbid="", match=0.7, url=""), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_similar_artists.return_value = lastfm_similar + + result = await svc.get_similar_artists("mbid-123", count=10, source="lastfm") + + assert len(result.similar_artists) == 1 + assert result.similar_artists[0].name == "Has MBID" + + @pytest.mark.asyncio + async def test_source_lastfm_disabled_returns_not_configured(self): + svc, _, _, _ = _make_service(lastfm_enabled=False) + + result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm") + + assert result.source == "lastfm" + assert result.configured is False + assert result.similar_artists == [] + + @pytest.mark.asyncio + async def test_source_lastfm_handles_exception(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_similar_artists.side_effect = Exception("API error") + + result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm") + + assert result.source == "lastfm" + assert result.similar_artists == [] + + @pytest.mark.asyncio + async def test_lastfm_exception_result_is_cached(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_similar_artists.side_effect = Exception("API error") + + await svc.get_similar_artists("mbid-123", count=5, source="lastfm") + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_exception_result_is_cached(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_similar_artists.side_effect = Exception("LB error") + + await svc.get_similar_artists("mbid-123", count=5) + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_not_configured_returns_not_configured(self): + svc, _, _, _ = _make_service(lb_configured=False) + + result = await svc.get_similar_artists("mbid-123", count=5) + + assert result.configured is False + + @pytest.mark.asyncio + async def test_source_lastfm_marks_in_library(self): + lastfm_similar = [ + LastFmSimilarArtist(name="In Lib", mbid="lib-mbid", match=0.9, url=""), + LastFmSimilarArtist(name="Not In Lib", mbid="other-mbid", match=0.8, url=""), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_similar_artists.return_value = lastfm_similar + svc._library_db.get_all_artist_mbids.return_value = {"lib-mbid"} + + result = await svc.get_similar_artists("mbid-123", count=10, source="lastfm") + + assert result.similar_artists[0].in_library is True + assert result.similar_artists[1].in_library is False + + @pytest.mark.asyncio + async def test_cache_key_includes_count_for_similar(self): + svc, lb_repo, _, _ = _make_service() + + await svc.get_similar_artists("mbid-123", count=5) + await svc.get_similar_artists("mbid-123", count=10) + + assert lb_repo.get_similar_artists.await_count == 2 + + @pytest.mark.asyncio + async def test_same_count_hits_cache_for_similar(self): + svc, lb_repo, _, _ = _make_service() + svc._cache.get.side_effect = [ + None, + MagicMock(similar_artists=[]), + ] + + await svc.get_similar_artists("mbid-123", count=5) + await svc.get_similar_artists("mbid-123", count=5) + + assert lb_repo.get_similar_artists.await_count == 1 + + +class TestGetTopSongsSource: + @pytest.mark.asyncio + async def test_source_lastfm_returns_tracks(self): + lastfm_tracks = [ + LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000), + LastFmTrack(name="Song B", artist_name="Artist", mbid="rec-b", playcount=3000), + ] + svc, lb_repo, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks + + result = await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.configured is True + assert len(result.songs) == 2 + assert result.songs[0].title == "Song A" + assert result.songs[0].listen_count == 5000 + assert result.songs[1].title == "Song B" + lastfm_repo.get_artist_top_tracks.assert_called_once() + lb_repo.get_artist_top_recordings.assert_not_called() + + @pytest.mark.asyncio + async def test_source_lastfm_disabled_returns_not_configured(self): + svc, _, _, _ = _make_service(lastfm_enabled=False) + + result = await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.songs == [] + assert result.configured is False + + @pytest.mark.asyncio + async def test_source_lastfm_handles_exception(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_tracks.side_effect = Exception("API error") + + result = await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.songs == [] + + @pytest.mark.asyncio + async def test_lastfm_exception_result_is_cached(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_tracks.side_effect = Exception("API error") + + await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_exception_result_is_cached(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_artist_top_recordings.side_effect = Exception("LB error") + + await svc.get_top_songs("mbid-123", count=10) + + assert svc._cache.set.await_count == 1 + + +class TestGetTopAlbumsSource: + @pytest.mark.asyncio + async def test_source_lastfm_returns_albums(self): + lastfm_albums = [ + LastFmAlbum(name="Album X", artist_name="Artist", mbid="alb-x", playcount=8000), + LastFmAlbum(name="Album Y", artist_name="Artist", mbid="alb-y", playcount=4000), + ] + svc, lb_repo, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.return_value = lastfm_albums + svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"alb-x"}) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.configured is True + assert len(result.albums) == 2 + assert result.albums[0].title == "Album X" + assert result.albums[0].listen_count == 8000 + assert result.albums[0].in_library is True + assert result.albums[1].in_library is False + lastfm_repo.get_artist_top_albums.assert_called_once() + lb_repo.get_artist_top_release_groups.assert_not_called() + + @pytest.mark.asyncio + async def test_source_lastfm_disabled_returns_not_configured(self): + svc, _, _, _ = _make_service(lastfm_enabled=False) + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.albums == [] + assert result.configured is False + + @pytest.mark.asyncio + async def test_source_lastfm_handles_exception(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.side_effect = Exception("API error") + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.source == "lastfm" + assert result.albums == [] + + @pytest.mark.asyncio + async def test_lastfm_exception_result_is_cached(self): + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.side_effect = Exception("API error") + + await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_exception_result_is_cached(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_artist_top_release_groups.side_effect = Exception("LB error") + + await svc.get_top_albums("mbid-123", count=10) + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_empty_result_is_cached(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_artist_top_release_groups.return_value = [] + + await svc.get_top_albums("mbid-123", count=10) + + assert svc._cache.set.await_count == 1 + + @pytest.mark.asyncio + async def test_lb_empty_release_groups_falls_back_to_recordings(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_artist_top_release_groups.return_value = [] + lb_repo.get_artist_top_recordings.return_value = [ + ListenBrainzRecording( + track_name="Track A1", + artist_name="Artist", + listen_count=12, + release_name="Album A", + release_mbid="rel-a", + ), + ListenBrainzRecording( + track_name="Track A2", + artist_name="Artist", + listen_count=9, + release_name="Album A", + release_mbid="rel-a", + ), + ListenBrainzRecording( + track_name="Track B1", + artist_name="Artist", + listen_count=7, + release_name="Album B", + release_mbid="rel-b", + ), + ] + + svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"rg-a"}) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value={"rg-b"}) + + async def _resolve_release_group(release_mbid: str): + return {"rel-a": "rg-a", "rel-b": "rg-b"}.get(release_mbid) + + svc._mb_repo.get_release_group_id_from_release = _resolve_release_group + + result = await svc.get_top_albums("mbid-123", count=10) + + assert len(result.albums) == 2 + assert result.albums[0].title == "Album A" + assert result.albums[0].listen_count == 21 + assert result.albums[0].release_group_mbid == "rg-a" + assert result.albums[0].in_library is True + assert result.albums[1].title == "Album B" + assert result.albums[1].release_group_mbid == "rg-b" + assert result.albums[1].requested is True + + @pytest.mark.asyncio + async def test_lb_top_albums_survive_lidarr_lookup_failure(self): + svc, lb_repo, _, _ = _make_service() + lb_repo.get_artist_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Album 1", + artist_name="Artist", + listen_count=42, + release_group_mbid="rg-1", + ) + ] + svc._lidarr_repo.get_library_mbids = AsyncMock(side_effect=Exception("lidarr down")) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + result = await svc.get_top_albums("mbid-123", count=10) + + assert len(result.albums) == 1 + assert result.albums[0].title == "Album 1" + assert result.albums[0].in_library is False + assert result.albums[0].requested is False + + @pytest.mark.asyncio + async def test_source_lastfm_normalizes_mbids(self): + lastfm_albums = [ + LastFmAlbum(name="Upper", artist_name="Artist", mbid="ALB-UPPER", playcount=100), + LastFmAlbum(name="Spaced", artist_name="Artist", mbid=" alb-spaced ", playcount=50), + LastFmAlbum(name="No MBID", artist_name="Artist", mbid=None, playcount=10), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.return_value = lastfm_albums + svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"alb-upper"}) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value={"alb-spaced"}) + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.albums[0].release_group_mbid == "alb-upper" + assert result.albums[0].in_library is True + assert result.albums[1].release_group_mbid == "alb-spaced" + assert result.albums[1].requested is True + assert result.albums[2].release_group_mbid is None + assert result.albums[2].in_library is False + assert result.albums[2].requested is False + + @pytest.mark.asyncio + async def test_source_lastfm_resolves_release_mbids_to_release_groups(self): + lastfm_albums = [ + LastFmAlbum(name="Album A", artist_name="Artist", mbid="release-mbid-a", playcount=100), + LastFmAlbum(name="Album B", artist_name="Artist", mbid="release-mbid-b", playcount=50), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.return_value = lastfm_albums + svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"rg-resolved-a"}) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + async def mock_resolve(rid): + return {"release-mbid-a": "rg-resolved-a", "release-mbid-b": "rg-resolved-b"}.get(rid) + + svc._mb_repo.get_release_group_id_from_release = mock_resolve + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.albums[0].release_group_mbid == "rg-resolved-a" + assert result.albums[0].in_library is True + assert result.albums[1].release_group_mbid == "rg-resolved-b" + assert result.albums[1].in_library is False + + @pytest.mark.asyncio + async def test_source_lastfm_keeps_original_mbid_when_resolution_fails(self): + lastfm_albums = [ + LastFmAlbum(name="Album A", artist_name="Artist", mbid="already-rg-mbid", playcount=100), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_albums.return_value = lastfm_albums + svc._lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + svc._mb_repo.get_release_group_id_from_release = AsyncMock(return_value=None) + + result = await svc.get_top_albums("mbid-123", count=10, source="lastfm") + + assert result.albums[0].release_group_mbid == "already-rg-mbid" + + +class TestGetTopSongsLastFmNoAlbumResolution: + @pytest.mark.asyncio + async def test_source_lastfm_returns_null_album_fields(self): + lastfm_tracks = [ + LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000), + LastFmTrack(name="Song B", artist_name="Artist", mbid="rec-b", playcount=3000), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks + + result = await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert len(result.songs) == 2 + assert result.source == "lastfm" + for song in result.songs: + assert song.release_group_mbid is None + assert song.release_name is None + + @pytest.mark.asyncio + async def test_source_lastfm_preserves_track_metadata(self): + lastfm_tracks = [ + LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000), + LastFmTrack(name="Song B", artist_name="Artist", mbid=None, playcount=3000), + ] + svc, _, lastfm_repo, _ = _make_service() + lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks + + result = await svc.get_top_songs("mbid-123", count=10, source="lastfm") + + assert result.songs[0].title == "Song A" + assert result.songs[0].recording_mbid == "rec-a" + assert result.songs[0].listen_count == 5000 + assert result.songs[1].title == "Song B" + assert result.songs[1].recording_mbid is None + assert result.songs[1].listen_count == 3000 \ No newline at end of file diff --git a/backend/tests/services/test_artist_enrichment_service.py b/backend/tests/services/test_artist_enrichment_service.py new file mode 100644 index 0000000..686edc0 --- /dev/null +++ b/backend/tests/services/test_artist_enrichment_service.py @@ -0,0 +1,103 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from repositories.lastfm_models import LastFmArtistInfo, LastFmTag, LastFmSimilarArtist +from services.artist_enrichment_service import ArtistEnrichmentService + + +def _make_lastfm_repo() -> AsyncMock: + repo = AsyncMock() + repo.get_artist_info = AsyncMock( + return_value=LastFmArtistInfo( + name="Radiohead", + mbid="a74b1b7f-71a5-4011-9441-d0b5e4122711", + listeners=5_000_000, + playcount=300_000_000, + url="https://www.last.fm/music/Radiohead", + bio_summary="Radiohead are an English rock band from Abingdon.", + tags=[ + LastFmTag(name="alternative", url="https://last.fm/tag/alternative"), + LastFmTag(name="rock", url="https://last.fm/tag/rock"), + ], + similar=[ + LastFmSimilarArtist( + name="Thom Yorke", mbid="abc-123", match=0.95, url="https://last.fm/thom" + ), + ], + ) + ) + return repo + + +def _make_preferences(enabled: bool = True) -> MagicMock: + prefs = MagicMock() + prefs.is_lastfm_enabled.return_value = enabled + return prefs + + +@pytest.mark.asyncio +async def test_enrichment_returns_data_when_enabled(): + repo = _make_lastfm_repo() + prefs = _make_preferences(enabled=True) + svc = ArtistEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("a74b1b7f", "Radiohead") + + assert result is not None + assert result.listeners == 5_000_000 + assert result.playcount == 300_000_000 + assert result.url == "https://www.last.fm/music/Radiohead" + assert len(result.tags) == 2 + assert result.tags[0].name == "alternative" + assert len(result.similar_artists) == 1 + assert result.similar_artists[0].name == "Thom Yorke" + + +@pytest.mark.asyncio +async def test_enrichment_strips_html_from_bio(): + repo = _make_lastfm_repo() + prefs = _make_preferences(enabled=True) + svc = ArtistEnrichmentService(lastfm_repo=repo, preferences_service=prefs) + + result = await svc.get_lastfm_enrichment("a74b1b7f", "Radiohead") + + assert result is not None + assert "" not in (result.bio or "") + assert " ArtistInfo: + return ArtistInfo( + name="Test Artist", + musicbrainz_id=MBID, + ) + + +def _make_service() -> ArtistService: + mb = AsyncMock() + lidarr = AsyncMock() + lidarr.is_configured.return_value = False + wikidata = AsyncMock() + prefs = MagicMock() + mem_cache = AsyncMock() + mem_cache.get = AsyncMock(return_value=None) + mem_cache.set = AsyncMock() + disk_cache = MagicMock() + disk_cache.get_artist = AsyncMock(return_value=None) + disk_cache.set_artist = AsyncMock() + audiodb_img = MagicMock() + audiodb_img.fetch_and_cache_artist_images = AsyncMock(return_value=None) + + svc = ArtistService( + mb_repo=mb, + lidarr_repo=lidarr, + wikidata_repo=wikidata, + preferences_service=prefs, + memory_cache=mem_cache, + disk_cache=disk_cache, + audiodb_image_service=audiodb_img, + ) + return svc + + +class TestArtistSingleflight: + @pytest.mark.asyncio + async def test_concurrent_calls_fetch_once(self): + """Multiple concurrent get_artist_info calls should invoke + _do_get_artist_info once.""" + svc = _make_service() + call_count = 0 + fake = _fake_artist_info() + + async def counting_fetch(*args, **kwargs): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return fake + + svc._do_get_artist_info = counting_fetch + + results = await asyncio.gather( + svc.get_artist_info(MBID), + svc.get_artist_info(MBID), + svc.get_artist_info(MBID), + ) + + assert call_count == 1 + assert all(r.name == "Test Artist" for r in results) + + @pytest.mark.asyncio + async def test_singleflight_cleared_after_completion(self): + """After completion, the in-flight dict should be empty.""" + svc = _make_service() + fake = _fake_artist_info() + + async def quick_fetch(*args, **kwargs): + return fake + + svc._do_get_artist_info = quick_fetch + + await svc.get_artist_info(MBID) + assert MBID not in svc._artist_in_flight + + @pytest.mark.asyncio + async def test_singleflight_propagates_exception(self): + """If fetch raises, all concurrent callers should get the exception.""" + svc = _make_service() + + async def failing_fetch(*args, **kwargs): + await asyncio.sleep(0.05) + raise RuntimeError("upstream timeout") + + svc._do_get_artist_info = failing_fetch + + results = await asyncio.gather( + svc.get_artist_info(MBID), + svc.get_artist_info(MBID), + svc.get_artist_info(MBID), + return_exceptions=True, + ) + + assert all(isinstance(r, ResourceNotFoundError) for r in results) + assert MBID not in svc._artist_in_flight + + @pytest.mark.asyncio + async def test_cache_hit_bypasses_singleflight(self): + """Cache hit should skip the fetch entirely.""" + svc = _make_service() + fake = _fake_artist_info() + svc._cache.get = AsyncMock(return_value=fake) + call_count = 0 + + async def should_not_run(*args, **kwargs): + nonlocal call_count + call_count += 1 + return fake + + svc._do_get_artist_info = should_not_run + + result = await svc.get_artist_info(MBID) + assert result.name == "Test Artist" + assert call_count == 0 + + @pytest.mark.asyncio + async def test_different_ids_run_independently(self): + """Different artist_ids should run in parallel.""" + svc = _make_service() + call_ids: list[str] = [] + + async def tracking_fetch(aid, *args, **kwargs): + call_ids.append(aid) + await asyncio.sleep(0.02) + return _fake_artist_info() + + svc._do_get_artist_info = tracking_fetch + + mbid_a = "aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" + mbid_b = "bbbb2222-bbbb-cccc-dddd-eeeeeeeeeeee" + await asyncio.gather( + svc.get_artist_info(mbid_a), + svc.get_artist_info(mbid_b), + ) + + assert len(call_ids) == 2 + assert mbid_a in call_ids + assert mbid_b in call_ids + + @pytest.mark.asyncio + async def test_basic_uses_separate_inflight_dict(self): + """get_artist_info_basic should use a separate in-flight dict from + get_artist_info, so they don't cross-contaminate results.""" + svc = _make_service() + fake = _fake_artist_info() + + full_count = 0 + basic_count = 0 + + async def full_fetch(*args, **kwargs): + nonlocal full_count + full_count += 1 + await asyncio.sleep(0.05) + return fake + + svc._do_get_artist_info = full_fetch + + original_build = svc._build_artist_from_musicbrainz + + async def basic_build(*args, **kwargs): + nonlocal basic_count + basic_count += 1 + await asyncio.sleep(0.05) + return fake + + svc._build_artist_from_musicbrainz = basic_build + + results = await asyncio.gather( + svc.get_artist_info(MBID), + svc.get_artist_info_basic(MBID), + ) + + assert full_count == 1 + assert basic_count == 1 + assert all(r.name == "Test Artist" for r in results) + + @pytest.mark.asyncio + async def test_basic_concurrent_calls_fetch_once(self): + """Multiple concurrent get_artist_info_basic calls should only fetch once.""" + svc = _make_service() + call_count = 0 + fake = _fake_artist_info() + + async def counting_build(*args, **kwargs): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return fake + + svc._build_artist_from_musicbrainz = counting_build + + results = await asyncio.gather( + svc.get_artist_info_basic(MBID), + svc.get_artist_info_basic(MBID), + svc.get_artist_info_basic(MBID), + ) + + assert call_count == 1 + assert all(r.name == "Test Artist" for r in results) diff --git a/backend/tests/services/test_audiodb_browse_queue.py b/backend/tests/services/test_audiodb_browse_queue.py new file mode 100644 index 0000000..5396eda --- /dev/null +++ b/backend/tests/services/test_audiodb_browse_queue.py @@ -0,0 +1,135 @@ +import asyncio +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from services.audiodb_browse_queue import AudioDBBrowseQueue, BrowseQueueItem + + +@pytest.fixture +def queue(): + return AudioDBBrowseQueue() + + +@pytest.fixture +def mock_audiodb_svc(): + svc = AsyncMock() + svc.fetch_and_cache_artist_images = AsyncMock(return_value=None) + svc.fetch_and_cache_album_images = AsyncMock(return_value=None) + return svc + + +@pytest.fixture +def mock_prefs(): + prefs = MagicMock() + settings = MagicMock() + settings.audiodb_enabled = True + prefs.get_advanced_settings.return_value = settings + return prefs + + +class TestEnqueue: + @pytest.mark.asyncio + async def test_enqueue_adds_item(self, queue: AudioDBBrowseQueue): + await queue.enqueue("artist", "abc-123", name="Coldplay") + assert queue._queue.qsize() == 1 + + @pytest.mark.asyncio + async def test_enqueue_dedup_same_mbid(self, queue: AudioDBBrowseQueue): + await queue.enqueue("artist", "abc-123") + await queue.enqueue("artist", "abc-123") + assert queue._queue.qsize() == 1 + + @pytest.mark.asyncio + async def test_enqueue_different_mbids(self, queue: AudioDBBrowseQueue): + await queue.enqueue("artist", "abc-123") + await queue.enqueue("album", "def-456") + assert queue._queue.qsize() == 2 + + @pytest.mark.asyncio + async def test_enqueue_full_queue_drops(self, queue: AudioDBBrowseQueue): + queue._queue = asyncio.Queue(maxsize=2) + await queue.enqueue("artist", "a") + await queue.enqueue("artist", "b") + await queue.enqueue("artist", "c") + assert queue._queue.qsize() == 2 + + @pytest.mark.asyncio + async def test_dedup_expires(self, queue: AudioDBBrowseQueue): + await queue.enqueue("artist", "abc-123") + queue._recent["abc-123"] -= 4000 + await queue.enqueue("artist", "abc-123") + assert queue._queue.qsize() == 2 + + +class TestConsumer: + @pytest.mark.asyncio + async def test_consumer_processes_artist(self, queue, mock_audiodb_svc, mock_prefs): + await queue.enqueue("artist", "abc-123", name="Coldplay") + + task = queue.start_consumer(mock_audiodb_svc, mock_prefs) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + mock_audiodb_svc.fetch_and_cache_artist_images.assert_called_once_with( + "abc-123", "Coldplay", is_monitored=False, + ) + + @pytest.mark.asyncio + async def test_consumer_processes_album(self, queue, mock_audiodb_svc, mock_prefs): + await queue.enqueue("album", "def-456", name="Parachutes", artist_name="Coldplay") + + task = queue.start_consumer(mock_audiodb_svc, mock_prefs) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + mock_audiodb_svc.fetch_and_cache_album_images.assert_called_once_with( + "def-456", artist_name="Coldplay", album_name="Parachutes", is_monitored=False, + ) + + @pytest.mark.asyncio + async def test_consumer_skips_when_disabled(self, queue, mock_audiodb_svc, mock_prefs): + mock_prefs.get_advanced_settings.return_value.audiodb_enabled = False + await queue.enqueue("artist", "abc-123", name="Coldplay") + + task = queue.start_consumer(mock_audiodb_svc, mock_prefs) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + mock_audiodb_svc.fetch_and_cache_artist_images.assert_not_called() + + @pytest.mark.asyncio + async def test_consumer_handles_item_error(self, queue, mock_audiodb_svc, mock_prefs, caplog): + mock_audiodb_svc.fetch_and_cache_artist_images.side_effect = RuntimeError("boom") + await queue.enqueue("artist", "abc-123", name="Coldplay") + + caplog.set_level(logging.ERROR, logger="services.audiodb_browse_queue") + task = queue.start_consumer(mock_audiodb_svc, mock_prefs) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + assert queue._queue.qsize() == 0 + assert any( + record.levelno == logging.ERROR + and "audiodb.browse_queue action=item_error" in record.message + and "entity_type=artist" in record.message + and "mbid=abc-123" in record.message + for record in caplog.records + ) diff --git a/backend/tests/services/test_audiodb_byte_caching_integration.py b/backend/tests/services/test_audiodb_byte_caching_integration.py new file mode 100644 index 0000000..772f92d --- /dev/null +++ b/backend/tests/services/test_audiodb_byte_caching_integration.py @@ -0,0 +1,169 @@ +import asyncio +import time +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, call + +import pytest + +from repositories.audiodb_models import AudioDBAlbumImages, AudioDBArtistImages +from repositories.coverart_album import AlbumCoverFetcher +from repositories.coverart_artist import ArtistImageFetcher + +AUDIODB_CDN_URL = "https://r2.theaudiodb.com/test.jpg" +AUDIODB_ARTIST_CDN_URL = "https://r2.theaudiodb.com/artist.jpg" +CAA_URL_PREFIX = "https://coverartarchive.org" + + +def _response( + status_code: int = 200, + content_type: str = "image/jpeg", + content: bytes = b"img", +) -> MagicMock: + response = MagicMock() + response.status_code = status_code + response.headers = {"content-type": content_type} + response.content = content + return response + + +@pytest.mark.asyncio +async def test_album_byte_path_cache_hit_downloads_and_caches(): + """8.5.a — Full chain: AudioDB cache hit → CDN download → disk write. + CoverArtArchive is NOT called (short-circuited).""" + audiodb_response = _response(content=b"fake-jpeg-bytes") + http_get = AsyncMock(return_value=audiodb_response) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages( + album_thumb_url=AUDIODB_CDN_URL, + is_negative=False, + cached_at=time.time(), + ), + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", "500", Path("/tmp/album.bin"), + ) + + assert result == (b"fake-jpeg-bytes", "image/jpeg", "audiodb") + http_get.assert_awaited_once() + assert http_get.call_args[0][0] == AUDIODB_CDN_URL + await asyncio.sleep(0) + assert write_cache.await_count == 1 + write_meta = write_cache.call_args[0][3] + assert write_meta == {"source": "audiodb"} + + +@pytest.mark.asyncio +async def test_artist_byte_path_cache_hit_downloads_and_caches(): + """8.5.b — Full chain: AudioDB cache hit → CDN download → disk write. + Wikidata is NOT called (short-circuited).""" + http_get = AsyncMock( + return_value=_response(content=b"fake-artist-bytes"), + ) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock( + return_value=AudioDBArtistImages( + thumb_url=AUDIODB_ARTIST_CDN_URL, + is_negative=False, + cached_at=time.time(), + ), + ) + fetcher = ArtistImageFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + + result = await fetcher.fetch_artist_image( + "artist-id-00", 500, Path("/tmp/artist.bin"), + ) + + assert result == (b"fake-artist-bytes", "image/jpeg", "audiodb") + http_get.assert_awaited_once() + assert http_get.call_args[0][0] == AUDIODB_ARTIST_CDN_URL + await asyncio.sleep(0) + assert write_cache.await_count == 1 + write_meta = write_cache.call_args[0][3] + assert write_meta == {"source": "audiodb"} + + +@pytest.mark.asyncio +async def test_album_byte_path_cdn_404_falls_through_to_caa(): + """8.5.c — CDN returns 404 → AudioDB falls through → CoverArtArchive is called.""" + cdn_404 = _response(status_code=404) + caa_ok = _response(content=b"caa-bytes", content_type="image/png") + + async def _route_http_get(url, *args, **kwargs): + if "theaudiodb.com" in url: + return cdn_404 + return caa_ok + + http_get = AsyncMock(side_effect=_route_http_get) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages( + album_thumb_url=AUDIODB_CDN_URL, + is_negative=False, + cached_at=time.time(), + ), + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", "500", Path("/tmp/album.bin"), + ) + + assert result == (b"caa-bytes", "image/png", "cover-art-archive") + urls_called = [c[0][0] for c in http_get.call_args_list] + assert AUDIODB_CDN_URL in urls_called + assert any(CAA_URL_PREFIX in u for u in urls_called) + await asyncio.sleep(0) + + +@pytest.mark.asyncio +async def test_album_byte_path_cdn_invalid_content_type_falls_through(): + """8.5.d — CDN returns text/html → AudioDB falls through → CoverArtArchive called.""" + cdn_html = _response(status_code=200, content_type="text/html") + caa_ok = _response(content=b"caa-bytes", content_type="image/jpeg") + + async def _route_http_get(url, *args, **kwargs): + if "theaudiodb.com" in url: + return cdn_html + return caa_ok + + http_get = AsyncMock(side_effect=_route_http_get) + write_cache = AsyncMock() + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages( + album_thumb_url=AUDIODB_CDN_URL, + is_negative=False, + cached_at=time.time(), + ), + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=audiodb_service, + ) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", "500", Path("/tmp/album.bin"), + ) + + assert result == (b"caa-bytes", "image/jpeg", "cover-art-archive") + await asyncio.sleep(0) diff --git a/backend/tests/services/test_audiodb_detail_flows.py b/backend/tests/services/test_audiodb_detail_flows.py new file mode 100644 index 0000000..7dd4df9 --- /dev/null +++ b/backend/tests/services/test_audiodb_detail_flows.py @@ -0,0 +1,229 @@ +"""Integration-level tests for the artist/album detail → AudioDB enrichment flows. + +Covers the critical paths identified in Phase 3 peer review: +- Cached artist/album objects still receive AudioDB enrichment (allow_fetch=True) +- Album basic info endpoint performs on-demand AudioDB fetch +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api.v1.schemas.artist import ArtistInfo +from api.v1.schemas.album import AlbumInfo, AlbumBasicInfo +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages +from services.artist_service import ArtistService +from services.album_service import AlbumService + + +TEST_ARTIST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + +ARTIST_IMAGES = AudioDBArtistImages( + thumb_url="https://cdn.example.com/thumb.jpg", + fanart_url="https://cdn.example.com/fanart1.jpg", + fanart_url_2="https://cdn.example.com/fanart2.jpg", + fanart_url_3=None, + fanart_url_4=None, + wide_thumb_url=None, + banner_url="https://cdn.example.com/banner.jpg", + logo_url=None, + clearart_url=None, + cutout_url=None, + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +ALBUM_IMAGES = AudioDBAlbumImages( + album_thumb_url="https://cdn.example.com/album_thumb.jpg", + album_back_url=None, + album_cdart_url=None, + album_spine_url=None, + album_3d_case_url=None, + album_3d_flat_url=None, + album_3d_face_url=None, + album_3d_thumb_url=None, + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + + +def _cached_artist(**overrides) -> ArtistInfo: + defaults = dict(name="Coldplay", musicbrainz_id=TEST_ARTIST_MBID, in_library=True) + defaults.update(overrides) + return ArtistInfo(**defaults) + + +def _cached_album(**overrides) -> AlbumInfo: + defaults = dict( + title="Parachutes", + musicbrainz_id=TEST_ALBUM_MBID, + artist_name="Coldplay", + artist_id=TEST_ARTIST_MBID, + in_library=False, + ) + defaults.update(overrides) + return AlbumInfo(**defaults) + + +def _artist_service(audiodb=None) -> ArtistService: + if audiodb is None: + audiodb = MagicMock() + prefs = MagicMock() + adv = MagicMock() + adv.cache_ttl_artist_library = 86400 + adv.cache_ttl_artist_non_library = 3600 + prefs.get_advanced_settings.return_value = adv + return ArtistService( + mb_repo=MagicMock(), + lidarr_repo=MagicMock(), + wikidata_repo=MagicMock(), + preferences_service=prefs, + memory_cache=MagicMock(), + disk_cache=MagicMock(), + audiodb_image_service=audiodb, + ) + + +def _album_service(audiodb=None) -> AlbumService: + if audiodb is None: + audiodb = MagicMock() + prefs = MagicMock() + adv = MagicMock() + adv.cache_ttl_album_library = 86400 + adv.cache_ttl_album_non_library = 3600 + prefs.get_advanced_settings.return_value = adv + return AlbumService( + lidarr_repo=MagicMock(), + mb_repo=MagicMock(), + library_db=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + preferences_service=prefs, + audiodb_image_service=audiodb, + ) + + +class TestArtistDetailCacheHitEnrichment: + """get_artist_info() must apply AudioDB images from cache on cache hit.""" + + @pytest.mark.asyncio + async def test_cached_artist_gets_audiodb_enrichment(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _artist_service(audiodb) + cached = _cached_artist() + svc._cache = MagicMock() + svc._cache.get = AsyncMock(return_value=cached) + + result = await svc.get_artist_info(TEST_ARTIST_MBID) + + assert result.thumb_url == "https://cdn.example.com/thumb.jpg" + assert result.fanart_url == "https://cdn.example.com/fanart1.jpg" + assert result.fanart_url_2 == "https://cdn.example.com/fanart2.jpg" + audiodb.get_cached_artist_images.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cached_artist_preserves_existing_fanart(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _artist_service(audiodb) + cached = _cached_artist(fanart_url="https://lidarr.example.com/fanart.jpg") + svc._cache = MagicMock() + svc._cache.get = AsyncMock(return_value=cached) + + result = await svc.get_artist_info(TEST_ARTIST_MBID) + + assert result.fanart_url == "https://lidarr.example.com/fanart.jpg" + assert result.thumb_url == "https://cdn.example.com/thumb.jpg" + + @pytest.mark.asyncio + async def test_cached_artist_audiodb_failure_returns_cached(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(side_effect=RuntimeError("unavailable")) + svc = _artist_service(audiodb) + cached = _cached_artist() + svc._cache = MagicMock() + svc._cache.get = AsyncMock(return_value=cached) + + result = await svc.get_artist_info(TEST_ARTIST_MBID) + + assert result.name == "Coldplay" + assert result.thumb_url is None + + +class TestAlbumDetailCacheHitEnrichment: + """get_album_info() must apply AudioDB images even on cache hit.""" + + @pytest.mark.asyncio + async def test_cached_album_gets_audiodb_enrichment(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _album_service(audiodb) + cached = _cached_album() + svc._get_cached_album_info = AsyncMock(return_value=cached) + + result = await svc.get_album_info(TEST_ALBUM_MBID) + + assert result.album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + audiodb.fetch_and_cache_album_images.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cached_album_audiodb_failure_returns_cached(self): + audiodb = MagicMock() + audiodb.fetch_and_cache_album_images = AsyncMock(side_effect=RuntimeError("unavailable")) + svc = _album_service(audiodb) + cached = _cached_album() + svc._get_cached_album_info = AsyncMock(return_value=cached) + + result = await svc.get_album_info(TEST_ALBUM_MBID) + + assert result.title == "Parachutes" + assert result.album_thumb_url is None + + +class TestAlbumBasicInfoOnDemandFetch: + """get_album_basic_info() applies cached AudioDB images (no network fetch on critical path).""" + + @pytest.mark.asyncio + async def test_basic_info_cache_hit_fetches_audiodb_thumb(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _album_service(audiodb) + cached = _cached_album(album_thumb_url=None) + svc._get_cached_album_info = AsyncMock(return_value=cached) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + result = await svc.get_album_basic_info(TEST_ALBUM_MBID) + + assert result.album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + audiodb.get_cached_album_images.assert_awaited_once_with(TEST_ALBUM_MBID) + + @pytest.mark.asyncio + async def test_basic_info_cache_hit_keeps_existing_thumb(self): + audiodb = MagicMock() + svc = _album_service(audiodb) + cached = _cached_album(album_thumb_url="https://existing.example.com/thumb.jpg") + svc._get_cached_album_info = AsyncMock(return_value=cached) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + result = await svc.get_album_basic_info(TEST_ALBUM_MBID) + + assert result.album_thumb_url == "https://existing.example.com/thumb.jpg" + audiodb.fetch_and_cache_album_images.assert_not_called() + + @pytest.mark.asyncio + async def test_basic_info_audiodb_failure_returns_none_thumb(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(side_effect=RuntimeError("boom")) + svc = _album_service(audiodb) + cached = _cached_album(album_thumb_url=None) + svc._get_cached_album_info = AsyncMock(return_value=cached) + svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set()) + + result = await svc.get_album_basic_info(TEST_ALBUM_MBID) + + assert result.album_thumb_url is None + assert result.title == "Parachutes" diff --git a/backend/tests/services/test_audiodb_fallback_gating.py b/backend/tests/services/test_audiodb_fallback_gating.py new file mode 100644 index 0000000..9608193 --- /dev/null +++ b/backend/tests/services/test_audiodb_fallback_gating.py @@ -0,0 +1,156 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages +from services.audiodb_image_service import AudioDBImageService + + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + + +def _make_settings( + enabled: bool = True, + name_search_fallback: bool = False, +) -> MagicMock: + s = MagicMock() + s.audiodb_enabled = enabled + s.audiodb_name_search_fallback = name_search_fallback + s.cache_ttl_audiodb_found = 604800 + s.cache_ttl_audiodb_not_found = 86400 + s.cache_ttl_audiodb_library = 1209600 + return s + + +def _make_service( + settings: MagicMock | None = None, + disk_cache: AsyncMock | None = None, + repo: AsyncMock | None = None, +) -> AudioDBImageService: + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + if disk_cache is None: + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=None) + disk_cache.get_audiodb_album = AsyncMock(return_value=None) + disk_cache.set_audiodb_artist = AsyncMock() + disk_cache.set_audiodb_album = AsyncMock() + if repo is None: + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=None) + repo.get_album_by_mbid = AsyncMock(return_value=None) + repo.search_album_by_name = AsyncMock(return_value=None) + return AudioDBImageService( + audiodb_repo=repo, + disk_cache=disk_cache, + preferences_service=prefs, + ) + + +class TestNameSearchFallbackGating: + @pytest.mark.asyncio + async def test_monitored_artist_always_gets_fallback(self): + """Monitored artists get name-search fallback even when setting is False.""" + settings = _make_settings(name_search_fallback=False) + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, repo=repo) + + await svc.fetch_and_cache_artist_images(TEST_MBID, "Coldplay", is_monitored=True) + + repo.search_artist_by_name.assert_called_once_with("Coldplay") + + @pytest.mark.asyncio + async def test_non_monitored_artist_no_fallback_when_disabled(self): + """Non-monitored artists don't get fallback when setting is False.""" + settings = _make_settings(name_search_fallback=False) + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, repo=repo) + + await svc.fetch_and_cache_artist_images(TEST_MBID, "Coldplay", is_monitored=False) + + repo.search_artist_by_name.assert_not_called() + + @pytest.mark.asyncio + async def test_non_monitored_artist_gets_fallback_when_enabled(self): + """Non-monitored artists get fallback when setting is True.""" + settings = _make_settings(name_search_fallback=True) + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, repo=repo) + + await svc.fetch_and_cache_artist_images(TEST_MBID, "Coldplay", is_monitored=False) + + repo.search_artist_by_name.assert_called_once_with("Coldplay") + + @pytest.mark.asyncio + async def test_monitored_album_always_gets_fallback(self): + """Monitored albums get name-search fallback even when setting is False.""" + settings = _make_settings(name_search_fallback=False) + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=None) + repo.search_album_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, repo=repo) + + await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", is_monitored=True, + ) + + repo.search_album_by_name.assert_called_once() + + @pytest.mark.asyncio + async def test_non_monitored_album_no_fallback_when_disabled(self): + """Non-monitored albums don't get fallback when setting is False.""" + settings = _make_settings(name_search_fallback=False) + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=None) + repo.search_album_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, repo=repo) + + await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", is_monitored=False, + ) + + repo.search_album_by_name.assert_not_called() + + +class TestNameSearchFallbackWithCachedNegative: + @pytest.mark.asyncio + async def test_monitored_retries_with_name_on_mbid_negative(self): + """When cached negative is from MBID lookup, monitored items retry with name.""" + settings = _make_settings(name_search_fallback=False) + negative = AudioDBArtistImages.negative(lookup_source="mbid") + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=negative) + disk_cache.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, disk_cache=disk_cache, repo=repo) + + await svc.fetch_and_cache_artist_images(TEST_MBID, "Coldplay", is_monitored=True) + + repo.search_artist_by_name.assert_called_once_with("Coldplay") + + @pytest.mark.asyncio + async def test_non_monitored_skips_name_retry_on_mbid_negative(self): + """When cached negative is from MBID lookup, non-monitored items skip name retry (setting=False).""" + settings = _make_settings(name_search_fallback=False) + negative = AudioDBArtistImages.negative(lookup_source="mbid") + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=negative) + disk_cache.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service(settings=settings, disk_cache=disk_cache, repo=repo) + + await svc.fetch_and_cache_artist_images(TEST_MBID, "Coldplay", is_monitored=False) + + repo.search_artist_by_name.assert_not_called() diff --git a/backend/tests/services/test_audiodb_fallback_integration.py b/backend/tests/services/test_audiodb_fallback_integration.py new file mode 100644 index 0000000..bcbb5cd --- /dev/null +++ b/backend/tests/services/test_audiodb_fallback_integration.py @@ -0,0 +1,165 @@ +"""Integration tests: provider chain falls through correctly when AudioDB has no data.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.artist import ArtistInfo +from repositories.audiodb_models import AudioDBAlbumImages, AudioDBArtistImages +from repositories.coverart_album import AlbumCoverFetcher +from repositories.coverart_artist import ArtistImageFetcher +from services.artist_service import ArtistService + + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +def _response( + status_code: int = 200, + content_type: str = "image/jpeg", + content: bytes = b"img", +) -> MagicMock: + resp = MagicMock() + resp.status_code = status_code + resp.headers = {"content-type": content_type} + resp.content = content + return resp + + +def _make_artist_info(**overrides) -> ArtistInfo: + defaults = dict(name="Coldplay", musicbrainz_id=TEST_MBID) + defaults.update(overrides) + return ArtistInfo(**defaults) + + +def _make_artist_service(audiodb_service=None) -> ArtistService: + return ArtistService( + mb_repo=MagicMock(), + lidarr_repo=MagicMock(), + wikidata_repo=MagicMock(), + preferences_service=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +@pytest.mark.asyncio +async def test_album_chain_audiodb_none_falls_to_coverart_archive(): + http_get = AsyncMock(return_value=_response(content=b"caa-cover")) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock(return_value=None) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + audiodb_service=audiodb_service, + ) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._get_cover_from_best_release = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", None, Path("/tmp/album.bin"), + ) + + assert result is not None + assert result == (b"caa-cover", "image/jpeg", "cover-art-archive") + audiodb_service.fetch_and_cache_album_images.assert_awaited_once_with("release-group-id") + http_get.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_album_chain_audiodb_negative_falls_to_coverart_archive(): + http_get = AsyncMock(return_value=_response(content=b"caa-cover")) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock( + return_value=AudioDBAlbumImages(is_negative=True), + ) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + audiodb_service=audiodb_service, + ) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._get_cover_from_best_release = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", None, Path("/tmp/album.bin"), + ) + + assert result is not None + assert result == (b"caa-cover", "image/jpeg", "cover-art-archive") + audiodb_service.fetch_and_cache_album_images.assert_awaited_once_with("release-group-id") + + +@pytest.mark.asyncio +async def test_artist_chain_audiodb_none_falls_to_wikidata(): + audiodb_service = MagicMock() + audiodb_service.get_cached_artist_images = AsyncMock(return_value=None) + fetcher = ArtistImageFetcher( + http_get_fn=AsyncMock(), + write_cache_fn=AsyncMock(), + cache=MagicMock(), + audiodb_service=audiodb_service, + ) + fetcher._fetch_local_sources = AsyncMock(return_value=(None, False)) + fetcher._fetch_from_wikidata = AsyncMock( + return_value=(b"wiki-img", "image/jpeg", "wikidata"), + ) + + result = await fetcher.fetch_artist_image( + "artist-id", 300, Path("/tmp/artist.bin"), + ) + + assert result is not None + assert result[2] == "wikidata" + assert result[0] == b"wiki-img" + fetcher._fetch_from_wikidata.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_full_chain_audiodb_and_coverart_both_empty(): + http_get = AsyncMock(return_value=_response(status_code=404)) + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_album_images = AsyncMock(return_value=None) + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=AsyncMock(), + audiodb_service=audiodb_service, + ) + fetcher._fetch_release_group_local_sources = AsyncMock(return_value=None) + fetcher._get_cover_from_best_release = AsyncMock(return_value=None) + + result = await fetcher.fetch_release_group_cover( + "release-group-id", None, Path("/tmp/album.bin"), + ) + + assert result is None + audiodb_service.fetch_and_cache_album_images.assert_awaited_once() + fetcher._get_cover_from_best_release.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_artist_detail_audiodb_no_data_fields_none(): + audiodb_service = MagicMock() + audiodb_service.fetch_and_cache_artist_images = AsyncMock(return_value=None) + svc = _make_artist_service(audiodb_service) + artist = _make_artist_info( + fanart_url="https://lidarr.example.com/fanart.jpg", + banner_url="https://lidarr.example.com/banner.jpg", + ) + + result = await svc._apply_audiodb_artist_images( + artist, TEST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + assert result.fanart_url_2 is None + assert result.fanart_url_3 is None + assert result.fanart_url_4 is None + assert result.wide_thumb_url is None + assert result.logo_url is None + assert result.clearart_url is None + assert result.cutout_url is None + assert result.fanart_url == "https://lidarr.example.com/fanart.jpg" + assert result.banner_url == "https://lidarr.example.com/banner.jpg" diff --git a/backend/tests/services/test_audiodb_image_service.py b/backend/tests/services/test_audiodb_image_service.py new file mode 100644 index 0000000..320e349 --- /dev/null +++ b/backend/tests/services/test_audiodb_image_service.py @@ -0,0 +1,641 @@ +from unittest.mock import AsyncMock, MagicMock + +import msgspec +import pytest + +from repositories.audiodb_models import ( + AudioDBArtistImages, + AudioDBArtistResponse, + AudioDBAlbumImages, + AudioDBAlbumResponse, +) +from services.audiodb_image_service import AudioDBImageService + + +SAMPLE_ARTIST_RESP = AudioDBArtistResponse( + idArtist="111239", + strArtist="Coldplay", + strMusicBrainzID="cc197bad-dc9c-440d-a5b5-d52ba2e14234", + strArtistThumb="https://example.com/thumb.jpg", + strArtistFanart="https://example.com/fanart.jpg", +) + +SAMPLE_ALBUM_RESP = AudioDBAlbumResponse( + idAlbum="2115888", + strAlbum="Parachutes", + strMusicBrainzID="1dc4c347-a1db-32aa-b14f-bc9cc507b843", + strAlbumThumb="https://example.com/album_thumb.jpg", + strAlbumBack="https://example.com/album_back.jpg", +) + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + + +def _make_settings( + enabled: bool = True, + name_search_fallback: bool = False, + ttl_found: int = 604800, + ttl_not_found: int = 86400, + ttl_library: int = 1209600, +) -> MagicMock: + s = MagicMock() + s.audiodb_enabled = enabled + s.audiodb_name_search_fallback = name_search_fallback + s.cache_ttl_audiodb_found = ttl_found + s.cache_ttl_audiodb_not_found = ttl_not_found + s.cache_ttl_audiodb_library = ttl_library + return s + + +def _make_service( + settings: MagicMock | None = None, + disk_cache: AsyncMock | None = None, + repo: AsyncMock | None = None, + memory_cache: AsyncMock | None = None, +) -> AudioDBImageService: + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + if disk_cache is None: + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=None) + disk_cache.get_audiodb_album = AsyncMock(return_value=None) + disk_cache.set_audiodb_artist = AsyncMock() + disk_cache.set_audiodb_album = AsyncMock() + if repo is None: + repo = AsyncMock() + return AudioDBImageService( + audiodb_repo=repo, + disk_cache=disk_cache, + preferences_service=prefs, + memory_cache=memory_cache, + ) + + + + +class TestGetCachedArtistImages: + @pytest.mark.asyncio + async def test_returns_none_when_disabled(self): + svc = _make_service(settings=_make_settings(enabled=False)) + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_empty_mbid(self): + svc = _make_service() + assert await svc.get_cached_artist_images("") is None + assert await svc.get_cached_artist_images(" ") is None + + @pytest.mark.asyncio + async def test_returns_none_on_cache_miss(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + svc = _make_service(disk_cache=disk) + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is None + disk.get_audiodb_artist.assert_awaited_once_with(TEST_MBID) + + @pytest.mark.asyncio + async def test_returns_images_on_cache_hit(self): + images = AudioDBArtistImages.from_response(SAMPLE_ARTIST_RESP, lookup_source="mbid") + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + svc = _make_service(disk_cache=disk) + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is not None + assert result.thumb_url == "https://example.com/thumb.jpg" + assert result.is_negative is False + + @pytest.mark.asyncio + async def test_returns_none_on_corrupt_cache_data(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value={"lookup_source": 999}) + disk.delete_entity = AsyncMock() + svc = _make_service(disk_cache=disk) + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is None + disk.delete_entity.assert_awaited_once_with("audiodb_artist", TEST_MBID) + + + + +class TestGetCachedAlbumImages: + @pytest.mark.asyncio + async def test_returns_none_when_disabled(self): + svc = _make_service(settings=_make_settings(enabled=False)) + result = await svc.get_cached_album_images(TEST_ALBUM_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_cache_miss(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + svc = _make_service(disk_cache=disk) + result = await svc.get_cached_album_images(TEST_ALBUM_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_returns_images_on_cache_hit(self): + images = AudioDBAlbumImages.from_response(SAMPLE_ALBUM_RESP, lookup_source="mbid") + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=raw) + svc = _make_service(disk_cache=disk) + result = await svc.get_cached_album_images(TEST_ALBUM_MBID) + assert result is not None + assert result.album_thumb_url == "https://example.com/album_thumb.jpg" + assert result.is_negative is False + + + + +class TestFetchAndCacheArtistImages: + @pytest.mark.asyncio + async def test_returns_none_when_disabled(self): + svc = _make_service(settings=_make_settings(enabled=False)) + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_empty_mbid(self): + svc = _make_service() + result = await svc.fetch_and_cache_artist_images("") + assert result is None + + @pytest.mark.asyncio + async def test_returns_cached_positive_without_fetching(self): + images = AudioDBArtistImages.from_response(SAMPLE_ARTIST_RESP, lookup_source="mbid") + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is not None + assert result.thumb_url == "https://example.com/thumb.jpg" + repo.get_artist_by_mbid.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fetches_by_mbid_and_caches_on_hit(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.thumb_url == "https://example.com/thumb.jpg" + assert result.lookup_source == "mbid" + disk.set_audiodb_artist.assert_awaited_once() + call_args = disk.set_audiodb_artist.call_args + assert call_args[1]["ttl_seconds"] == 604800 + + @pytest.mark.asyncio + async def test_monitored_uses_library_ttl(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, is_monitored=True) + assert result is not None + call_args = disk.set_audiodb_artist.call_args + assert call_args[1]["ttl_seconds"] == 1209600 + assert call_args[1]["is_monitored"] is True + + @pytest.mark.asyncio + async def test_caches_negative_on_mbid_miss(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is not None + assert result.is_negative is True + assert result.lookup_source == "mbid" + disk.set_audiodb_artist.assert_awaited_once() + call_args = disk.set_audiodb_artist.call_args + assert call_args[1]["ttl_seconds"] == 86400 + + @pytest.mark.asyncio + async def test_falls_back_to_name_search_on_mbid_miss(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is False + assert result.lookup_source == "name" + assert disk.set_audiodb_artist.await_count == 2 + + @pytest.mark.asyncio + async def test_no_name_search_when_fallback_disabled(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + svc = _make_service( + settings=_make_settings(name_search_fallback=False), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is True + repo.search_artist_by_name.assert_not_awaited() + + @pytest.mark.asyncio + async def test_returns_none_on_repo_exception(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(side_effect=Exception("API error")) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_returns_negative_on_name_search_exception(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(side_effect=Exception("API error")) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is True + assert result.lookup_source == "mbid" + + @pytest.mark.asyncio + async def test_skips_refetch_on_cached_negative_with_name_source(self): + negative_name = AudioDBArtistImages.negative(lookup_source="name") + raw = msgspec.structs.asdict(negative_name) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + repo = AsyncMock() + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is True + repo.get_artist_by_mbid.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cached_negative_mbid_skips_mbid_call_and_tries_name(self): + negative_mbid = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative_mbid) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.search_artist_by_name = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is False + assert result.lookup_source == "name" + repo.get_artist_by_mbid.assert_not_awaited() + repo.search_artist_by_name.assert_awaited_once_with("Coldplay") + + @pytest.mark.asyncio + async def test_cached_negative_mbid_returns_cached_when_no_name(self): + negative_mbid = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative_mbid) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + repo = AsyncMock() + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is not None + assert result.is_negative is True + repo.get_artist_by_mbid.assert_not_awaited() + repo.search_artist_by_name.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cached_negative_mbid_returns_cached_when_fallback_disabled(self): + negative_mbid = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative_mbid) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + repo = AsyncMock() + svc = _make_service( + settings=_make_settings(name_search_fallback=False), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + assert result is not None + assert result.is_negative is True + repo.get_artist_by_mbid.assert_not_awaited() + repo.search_artist_by_name.assert_not_awaited() + + +class TestFetchAndCacheAlbumImages: + @pytest.mark.asyncio + async def test_returns_none_when_disabled(self): + svc = _make_service(settings=_make_settings(enabled=False)) + result = await svc.fetch_and_cache_album_images(TEST_ALBUM_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_fetches_by_mbid_and_caches(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=SAMPLE_ALBUM_RESP) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", + ) + assert result is not None + assert result.album_thumb_url == "https://example.com/album_thumb.jpg" + assert result.lookup_source == "mbid" + + @pytest.mark.asyncio + async def test_caches_negative_on_mbid_miss(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=None) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_album_images(TEST_ALBUM_MBID) + assert result is not None + assert result.is_negative is True + + @pytest.mark.asyncio + async def test_falls_back_to_name_search_for_album(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=None) + repo.search_album_by_name = AsyncMock(return_value=SAMPLE_ALBUM_RESP) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", + ) + assert result is not None + assert result.is_negative is False + assert result.lookup_source == "name" + + @pytest.mark.asyncio + async def test_no_name_fallback_without_both_names(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=None) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name=None, + ) + assert result is not None + assert result.is_negative is True + repo.search_album_by_name.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cached_negative_mbid_skips_mbid_call_and_tries_name_album(self): + negative_mbid = AudioDBAlbumImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative_mbid) + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=raw) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.search_album_by_name = AsyncMock(return_value=SAMPLE_ALBUM_RESP) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", + ) + assert result is not None + assert result.is_negative is False + assert result.lookup_source == "name" + repo.get_album_by_mbid.assert_not_awaited() + repo.search_album_by_name.assert_awaited_once_with("Coldplay", "Parachutes") + + @pytest.mark.asyncio + async def test_cached_negative_mbid_returns_cached_when_fallback_disabled_album(self): + negative_mbid = AudioDBAlbumImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative_mbid) + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=raw) + repo = AsyncMock() + svc = _make_service( + settings=_make_settings(name_search_fallback=False), + disk_cache=disk, repo=repo, + ) + + result = await svc.fetch_and_cache_album_images( + TEST_ALBUM_MBID, artist_name="Coldplay", album_name="Parachutes", + ) + assert result is not None + assert result.is_negative is True + repo.get_album_by_mbid.assert_not_awaited() + repo.search_album_by_name.assert_not_awaited() + + @pytest.mark.asyncio + async def test_returns_none_on_repo_exception(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(side_effect=Exception("fail")) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_album_images(TEST_ALBUM_MBID) + assert result is None + + @pytest.mark.asyncio + async def test_monitored_album_uses_library_ttl(self): + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=None) + disk.set_audiodb_album = AsyncMock() + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=SAMPLE_ALBUM_RESP) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_album_images(TEST_ALBUM_MBID, is_monitored=True) + assert result is not None + call_args = disk.set_audiodb_album.call_args + assert call_args[1]["ttl_seconds"] == 1209600 + + + + +class TestMemoryCacheReadThrough: + @pytest.mark.asyncio + async def test_disk_hit_promotes_to_memory_artist(self): + images = AudioDBArtistImages.from_response(SAMPLE_ARTIST_RESP, lookup_source="mbid") + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + mem = AsyncMock() + mem.get = AsyncMock(return_value=None) + mem.set = AsyncMock() + svc = _make_service(disk_cache=disk, memory_cache=mem) + + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is not None + assert result.thumb_url == "https://example.com/thumb.jpg" + mem.set.assert_awaited_once() + set_key = mem.set.call_args[0][0] + assert set_key == f"audiodb_artist:{TEST_MBID}" + + @pytest.mark.asyncio + async def test_memory_hit_skips_disk_artist(self): + images = AudioDBArtistImages.from_response(SAMPLE_ARTIST_RESP, lookup_source="mbid") + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + mem = AsyncMock() + mem.get = AsyncMock(return_value=images) + mem.set = AsyncMock() + svc = _make_service(disk_cache=disk, memory_cache=mem) + + result = await svc.get_cached_artist_images(TEST_MBID) + assert result is not None + assert result.thumb_url == "https://example.com/thumb.jpg" + disk.get_audiodb_artist.assert_not_awaited() + + @pytest.mark.asyncio + async def test_disk_hit_promotes_to_memory_album(self): + images = AudioDBAlbumImages.from_response(SAMPLE_ALBUM_RESP, lookup_source="mbid") + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_album = AsyncMock(return_value=raw) + mem = AsyncMock() + mem.get = AsyncMock(return_value=None) + mem.set = AsyncMock() + svc = _make_service(disk_cache=disk, memory_cache=mem) + + result = await svc.get_cached_album_images(TEST_ALBUM_MBID) + assert result is not None + assert result.album_thumb_url == "https://example.com/album_thumb.jpg" + mem.set.assert_awaited_once() + set_key = mem.set.call_args[0][0] + assert set_key == f"audiodb_album:{TEST_ALBUM_MBID}" + + @pytest.mark.asyncio + async def test_fetch_and_cache_promotes_to_memory(self): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + mem = AsyncMock() + mem.get = AsyncMock(return_value=None) + mem.set = AsyncMock() + svc = _make_service(disk_cache=disk, repo=repo, memory_cache=mem) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + assert result is not None + assert mem.set.await_count >= 1 + set_key = mem.set.call_args[0][0] + assert set_key == f"audiodb_artist:{TEST_MBID}" + + + + +class TestClearAudioDBRoute: + @pytest.mark.asyncio + async def test_clear_audiodb_endpoint_pattern(self): + from api.v1.routes.cache import router + + paths = [r.path for r in router.routes] + assert "/cache/clear/audiodb" in paths + + +class TestClearAudioDBService: + @pytest.mark.asyncio + async def test_clear_audiodb_clears_disk_and_memory(self): + from services.cache_service import CacheService + + disk_cache = AsyncMock() + disk_cache.get_stats = MagicMock(return_value={ + "audiodb_artist_count": 5, + "audiodb_album_count": 3, + }) + disk_cache.clear_audiodb = AsyncMock() + mem_cache = AsyncMock() + mem_cache.clear_prefix = AsyncMock(return_value=4) + library_db = MagicMock() + svc = CacheService(cache=mem_cache, library_db=library_db, disk_cache=disk_cache) + + result = await svc.clear_audiodb() + assert result.success is True + assert result.cleared_disk_files == 8 + assert result.cleared_memory_entries == 4 + disk_cache.clear_audiodb.assert_awaited_once() + mem_cache.clear_prefix.assert_awaited_once_with("audiodb_") + + @pytest.mark.asyncio + async def test_clear_audiodb_invalidates_cached_stats(self): + from services.cache_service import CacheService + + disk_cache = AsyncMock() + disk_cache.get_stats = MagicMock(return_value={ + "audiodb_artist_count": 0, + "audiodb_album_count": 0, + }) + disk_cache.clear_audiodb = AsyncMock() + mem_cache = AsyncMock() + mem_cache.clear_prefix = AsyncMock(return_value=0) + library_db = MagicMock() + svc = CacheService(cache=mem_cache, library_db=library_db, disk_cache=disk_cache) + svc._cached_stats = {"some": "stats"} + + await svc.clear_audiodb() + assert svc._cached_stats is None diff --git a/backend/tests/services/test_audiodb_negative_cache_expiry.py b/backend/tests/services/test_audiodb_negative_cache_expiry.py new file mode 100644 index 0000000..5731985 --- /dev/null +++ b/backend/tests/services/test_audiodb_negative_cache_expiry.py @@ -0,0 +1,208 @@ +import time +from unittest.mock import AsyncMock, MagicMock + +import msgspec +import pytest + +from repositories.audiodb_models import ( + AudioDBArtistImages, + AudioDBArtistResponse, +) +from services.audiodb_image_service import AudioDBImageService + +SAMPLE_ARTIST_RESP = AudioDBArtistResponse( + idArtist="111239", + strArtist="Coldplay", + strMusicBrainzID="cc197bad-dc9c-440d-a5b5-d52ba2e14234", + strArtistThumb="https://example.com/thumb.jpg", + strArtistFanart="https://example.com/fanart.jpg", +) + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +def _make_settings( + enabled: bool = True, + name_search_fallback: bool = False, + ttl_found: int = 604800, + ttl_not_found: int = 86400, + ttl_library: int = 1209600, +) -> MagicMock: + s = MagicMock() + s.audiodb_enabled = enabled + s.audiodb_name_search_fallback = name_search_fallback + s.cache_ttl_audiodb_found = ttl_found + s.cache_ttl_audiodb_not_found = ttl_not_found + s.cache_ttl_audiodb_library = ttl_library + return s + + +def _make_service( + settings: MagicMock | None = None, + disk_cache: AsyncMock | None = None, + repo: AsyncMock | None = None, +) -> AudioDBImageService: + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + if disk_cache is None: + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=None) + disk_cache.get_audiodb_album = AsyncMock(return_value=None) + disk_cache.set_audiodb_artist = AsyncMock() + disk_cache.set_audiodb_album = AsyncMock() + if repo is None: + repo = AsyncMock() + return AudioDBImageService( + audiodb_repo=repo, + disk_cache=disk_cache, + preferences_service=prefs, + memory_cache=None, + ) + + +class TestNegativeCacheExpiry: + @pytest.mark.asyncio + async def test_negative_entry_cached_with_correct_structure(self): + """MBID miss produces a negative entry with the right shape and TTL.""" + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + ttl_not_found = 86400 + svc = _make_service( + settings=_make_settings(ttl_not_found=ttl_not_found), + disk_cache=disk, + repo=repo, + ) + + before = time.time() + await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + + disk.set_audiodb_artist.assert_awaited() + call_kwargs = disk.set_audiodb_artist.call_args + cached_entry: AudioDBArtistImages = call_kwargs[0][1] + ttl_arg = call_kwargs.kwargs.get("ttl_seconds", call_kwargs[1].get("ttl_seconds") if len(call_kwargs) > 1 and isinstance(call_kwargs[1], dict) else None) + + assert cached_entry.is_negative is True + assert cached_entry.lookup_source == "mbid" + assert cached_entry.cached_at >= before + assert cached_entry.cached_at <= time.time() + 5 + assert cached_entry.thumb_url is None + assert cached_entry.fanart_url is None + assert cached_entry.wide_thumb_url is None + assert cached_entry.banner_url is None + assert cached_entry.logo_url is None + assert cached_entry.cutout_url is None + assert cached_entry.clearart_url is None + assert ttl_arg == ttl_not_found + + @pytest.mark.asyncio + async def test_valid_negative_entry_prevents_api_call(self): + """A fresh negative cache entry prevents re-fetching from AudioDB.""" + negative = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + svc = _make_service( + settings=_make_settings(name_search_fallback=False), + disk_cache=disk, + repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID) + + assert result is not None + assert result.is_negative is True + repo.get_artist_by_mbid.assert_not_awaited() + + @pytest.mark.asyncio + async def test_expired_negative_entry_triggers_refetch(self): + """When the disk cache returns None for an expired/evicted negative + entry, a fresh API call is made and the cache is updated.""" + expired_negative = AudioDBArtistImages.negative(lookup_source="mbid") + expired_negative = AudioDBArtistImages( + is_negative=True, + lookup_source="mbid", + cached_at=time.time() - 200_000, + ) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=None) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service(disk_cache=disk, repo=repo) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + + assert result is not None + assert result.is_negative is False + assert result.thumb_url == "https://example.com/thumb.jpg" + assert result.lookup_source == "mbid" + repo.get_artist_by_mbid.assert_awaited_once_with(TEST_MBID) + disk.set_audiodb_artist.assert_awaited_once() + written_entry: AudioDBArtistImages = disk.set_audiodb_artist.call_args[0][1] + assert written_entry.is_negative is False + assert written_entry.thumb_url == "https://example.com/thumb.jpg" + + @pytest.mark.asyncio + async def test_mbid_negative_triggers_name_search_when_enabled(self): + """A cached MBID-negative entry skips MBID lookup and falls back to + name search when audiodb_name_search_fallback is enabled.""" + negative = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_service( + settings=_make_settings(name_search_fallback=True), + disk_cache=disk, + repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images(TEST_MBID, name="Coldplay") + + assert result is not None + assert result.is_negative is False + assert result.lookup_source == "name" + assert result.thumb_url == "https://example.com/thumb.jpg" + repo.get_artist_by_mbid.assert_not_awaited() + repo.search_artist_by_name.assert_awaited_once_with("Coldplay") + disk.set_audiodb_artist.assert_awaited() + final_entry: AudioDBArtistImages = disk.set_audiodb_artist.call_args[0][1] + assert final_entry.lookup_source == "name" + assert final_entry.is_negative is False + + @pytest.mark.asyncio + async def test_mbid_negative_no_name_search_when_disabled(self): + """A cached MBID-negative entry is returned as-is when name-search + fallback is disabled and the caller is not monitored.""" + negative = AudioDBArtistImages.negative(lookup_source="mbid") + raw = msgspec.structs.asdict(negative) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + disk.set_audiodb_artist = AsyncMock() + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=None) + repo.search_artist_by_name = AsyncMock(return_value=None) + svc = _make_service( + settings=_make_settings(name_search_fallback=False), + disk_cache=disk, + repo=repo, + ) + + result = await svc.fetch_and_cache_artist_images( + TEST_MBID, name="Coldplay", is_monitored=False, + ) + + assert result is not None + assert result.is_negative is True + repo.search_artist_by_name.assert_not_awaited() diff --git a/backend/tests/services/test_audiodb_prewarm.py b/backend/tests/services/test_audiodb_prewarm.py new file mode 100644 index 0000000..9d7a0b7 --- /dev/null +++ b/backend/tests/services/test_audiodb_prewarm.py @@ -0,0 +1,185 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages + + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + + +def _make_settings(audiodb_enabled: bool = True, name_search_fallback: bool = False): + s = MagicMock() + s.audiodb_enabled = audiodb_enabled + s.audiodb_name_search_fallback = name_search_fallback + return s + + +def _make_prefs(settings=None): + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + return prefs + + +def _make_status_service(): + status = MagicMock() + status.update_phase = AsyncMock() + status.update_progress = AsyncMock() + status.persist_progress = AsyncMock() + status.skip_phase = AsyncMock() + status.is_cancelled.return_value = False + return status + + +def _make_precache_service(audiodb_svc=None, prefs=None, cover_repo=None): + from services.library_precache_service import LibraryPrecacheService + + if audiodb_svc is None: + audiodb_svc = AsyncMock() + audiodb_svc.get_cached_artist_images = AsyncMock(return_value=None) + audiodb_svc.get_cached_album_images = AsyncMock(return_value=None) + audiodb_svc.fetch_and_cache_artist_images = AsyncMock(return_value=None) + audiodb_svc.fetch_and_cache_album_images = AsyncMock(return_value=None) + if prefs is None: + prefs = _make_prefs() + if cover_repo is None: + cover_repo = AsyncMock() + return LibraryPrecacheService( + lidarr_repo=AsyncMock(), + cover_repo=cover_repo, + preferences_service=prefs, + sync_state_store=AsyncMock(), + genre_index=AsyncMock(), + library_db=AsyncMock(), + audiodb_image_service=audiodb_svc, + ) + + +class TestCheckAudioDBCacheNeeds: + @pytest.mark.asyncio + async def test_skips_cached_artists(self): + svc = AsyncMock() + images = AudioDBArtistImages(thumb_url="https://x.com/t.jpg", is_negative=False) + svc.get_cached_artist_images = AsyncMock(return_value=images) + svc.get_cached_album_images = AsyncMock(return_value=None) + precache = _make_precache_service(audiodb_svc=svc) + + artists = [{"mbid": TEST_MBID, "name": "Coldplay"}] + needed_artists, needed_albums = await precache._check_audiodb_cache_needs(artists, []) + + assert len(needed_artists) == 0 + + @pytest.mark.asyncio + async def test_includes_uncached_artists(self): + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.get_cached_album_images = AsyncMock(return_value=None) + precache = _make_precache_service(audiodb_svc=svc) + + artists = [{"mbid": TEST_MBID, "name": "Coldplay"}] + needed_artists, _ = await precache._check_audiodb_cache_needs(artists, []) + + assert len(needed_artists) == 1 + assert needed_artists[0]["mbid"] == TEST_MBID + + @pytest.mark.asyncio + async def test_skips_unknown_mbid(self): + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + precache = _make_precache_service(audiodb_svc=svc) + + artists = [{"mbid": "unknown_abc123", "name": "Unknown"}] + needed_artists, _ = await precache._check_audiodb_cache_needs(artists, []) + + assert len(needed_artists) == 0 + + +class TestPrecacheAudioDBData: + @pytest.mark.asyncio + async def test_skips_when_disabled(self): + settings = _make_settings(audiodb_enabled=False) + prefs = _make_prefs(settings) + svc = AsyncMock() + precache = _make_precache_service(audiodb_svc=svc, prefs=prefs) + + status = _make_status_service() + await precache._precache_audiodb_data( + [{"mbid": TEST_MBID, "name": "Coldplay"}], [], status, + ) + + svc.fetch_and_cache_artist_images.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_all_cached(self): + svc = AsyncMock() + images = AudioDBArtistImages(thumb_url="https://x.com/t.jpg", is_negative=False) + svc.get_cached_artist_images = AsyncMock(return_value=images) + svc.get_cached_album_images = AsyncMock(return_value=images) + precache = _make_precache_service(audiodb_svc=svc) + + status = _make_status_service() + await precache._precache_audiodb_data( + [{"mbid": TEST_MBID, "name": "Coldplay"}], [], status, + ) + + svc.fetch_and_cache_artist_images.assert_not_called() + + @pytest.mark.asyncio + async def test_processes_uncached_artists(self): + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.get_cached_album_images = AsyncMock(return_value=None) + result = AudioDBArtistImages(thumb_url="https://x.com/t.jpg", is_negative=False) + svc.fetch_and_cache_artist_images = AsyncMock(return_value=result) + precache = _make_precache_service(audiodb_svc=svc) + + status = _make_status_service() + with patch.object(precache._audiodb_phase, 'download_bytes', new_callable=AsyncMock, return_value=True): + await precache._precache_audiodb_data( + [{"mbid": TEST_MBID, "name": "Coldplay"}], [], status, + ) + + svc.fetch_and_cache_artist_images.assert_called_once() + + @pytest.mark.asyncio + async def test_respects_cancellation(self): + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.get_cached_album_images = AsyncMock(return_value=None) + precache = _make_precache_service(audiodb_svc=svc) + + status = _make_status_service() + status.is_cancelled.return_value = True + + await precache._precache_audiodb_data( + [{"mbid": TEST_MBID, "name": "Coldplay"}], [], status, + ) + + svc.fetch_and_cache_artist_images.assert_not_called() + + +class TestSortByCoverPriority: + def test_coverless_first(self, tmp_path): + from repositories.coverart_disk_cache import get_cache_filename + + fake_cache_dir = tmp_path / "covers" + fake_cache_dir.mkdir() + + cover_repo = MagicMock() + cover_repo.cache_dir = fake_cache_dir + precache = _make_precache_service(cover_repo=cover_repo) + + identifier_a = "artist_a_500" + file_name = f"{get_cache_filename(identifier_a, 'img')}.bin" + (fake_cache_dir / file_name).write_bytes(b"fake") + + artists = [ + {"mbid": "a", "name": "A"}, + {"mbid": "b", "name": "B"}, + ] + sorted_list = precache._sort_by_cover_priority(artists, "artist") + assert sorted_list[0]["mbid"] == "b" diff --git a/backend/tests/services/test_audiodb_sweep.py b/backend/tests/services/test_audiodb_sweep.py new file mode 100644 index 0000000..3068b26 --- /dev/null +++ b/backend/tests/services/test_audiodb_sweep.py @@ -0,0 +1,107 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" + + +def _make_settings(audiodb_enabled: bool = True): + s = MagicMock() + s.audiodb_enabled = audiodb_enabled + return s + + +def _make_prefs(settings=None, cursor=None): + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + prefs.get_setting = MagicMock(return_value=cursor) + prefs.save_setting = MagicMock() + return prefs + + +def _make_library_db(artists=None, albums=None): + cache = AsyncMock() + cache.get_artists = AsyncMock(return_value=artists or []) + cache.get_albums = AsyncMock(return_value=albums or []) + return cache + + +class TestSweepSkipsWhenDisabled: + @pytest.mark.asyncio + async def test_disabled_skips(self): + from core.tasks import warm_audiodb_cache_periodically + + settings = _make_settings(audiodb_enabled=False) + prefs = _make_prefs(settings) + svc = AsyncMock() + cache = _make_library_db() + + task = asyncio.create_task( + warm_audiodb_cache_periodically(svc, cache, prefs) + ) + # Replace sleep to avoid 120s wait + with patch('core.tasks.asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + mock_sleep.side_effect = [None, asyncio.CancelledError()] + try: + await task + except asyncio.CancelledError: + pass + + svc.fetch_and_cache_artist_images.assert_not_called() + + +class TestSweepSkipsEmptyLibrary: + @pytest.mark.asyncio + async def test_empty_library_skips(self): + from core.tasks import warm_audiodb_cache_periodically + + prefs = _make_prefs() + svc = AsyncMock() + cache = _make_library_db(artists=[], albums=[]) + + task = asyncio.create_task( + warm_audiodb_cache_periodically(svc, cache, prefs) + ) + with patch('core.tasks.asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + mock_sleep.side_effect = [None, asyncio.CancelledError()] + try: + await task + except asyncio.CancelledError: + pass + + svc.get_cached_artist_images.assert_not_called() + + +class TestSweepCursorPersistence: + @pytest.mark.asyncio + async def test_cursor_saved_on_completion(self): + from core.tasks import warm_audiodb_cache_periodically + + prefs = _make_prefs() + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.fetch_and_cache_artist_images = AsyncMock(return_value=None) + + artists = [{"mbid": TEST_MBID, "name": "Coldplay"}] + cache = _make_library_db(artists=artists) + + call_count = 0 + async def smart_sleep(duration): + nonlocal call_count + call_count += 1 + if call_count >= 4: + raise asyncio.CancelledError() + + with patch('core.tasks.asyncio.sleep', side_effect=smart_sleep): + try: + await warm_audiodb_cache_periodically(svc, cache, prefs) + except asyncio.CancelledError: + pass + + save_calls = prefs.save_setting.call_args_list + cursor_clears = [c for c in save_calls if c[0] == ('audiodb_sweep_cursor', None)] + assert len(cursor_clears) >= 1 diff --git a/backend/tests/services/test_audiodb_url_only_integration.py b/backend/tests/services/test_audiodb_url_only_integration.py new file mode 100644 index 0000000..8df8d82 --- /dev/null +++ b/backend/tests/services/test_audiodb_url_only_integration.py @@ -0,0 +1,171 @@ +"""Integration tests verifying the URL-only path for non-library items. + +Search/list endpoints must carry AudioDB CDN URLs in responses WITHOUT +triggering byte downloads. These tests confirm that: +- Search overlay populates URLs from cache, never fetches. +- Artist list (allow_fetch=False) uses cache only. +- Album list (allow_fetch=False) uses cache only. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.album import AlbumInfo +from api.v1.schemas.artist import ArtistInfo +from api.v1.schemas.search import SearchResult +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages +from services.album_service import AlbumService +from services.artist_service import ArtistService +from services.search_service import SearchService + + +TEST_ARTIST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + +CACHED_ARTIST_IMAGES = AudioDBArtistImages( + thumb_url="https://r2.theaudiodb.com/artist.jpg", + fanart_url="https://r2.theaudiodb.com/fanart.jpg", + banner_url="https://r2.theaudiodb.com/banner.jpg", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +CACHED_ALBUM_IMAGES = AudioDBAlbumImages( + album_thumb_url="https://r2.theaudiodb.com/album_thumb.jpg", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + + + +def _artist_result(**overrides) -> SearchResult: + defaults = dict(type="artist", title="Coldplay", musicbrainz_id=TEST_ARTIST_MBID, score=100) + defaults.update(overrides) + return SearchResult(**defaults) + + +def _search_service(audiodb: MagicMock | None = None) -> SearchService: + mb_repo = MagicMock() + lidarr_repo = MagicMock() + lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) + lidarr_repo.get_queue = AsyncMock(return_value=[]) + coverart_repo = MagicMock() + prefs = MagicMock() + prefs.get_preferences.return_value = MagicMock(secondary_types=[]) + return SearchService(mb_repo, lidarr_repo, coverart_repo, prefs, audiodb) + + +def _make_artist_info(**overrides) -> ArtistInfo: + defaults = dict(name="Coldplay", musicbrainz_id=TEST_ARTIST_MBID) + defaults.update(overrides) + return ArtistInfo(**defaults) + + +def _make_artist_service(audiodb_service: MagicMock | None = None) -> ArtistService: + if audiodb_service is None: + audiodb_service = MagicMock() + return ArtistService( + mb_repo=MagicMock(), + lidarr_repo=MagicMock(), + wikidata_repo=MagicMock(), + preferences_service=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +def _make_album_info(**overrides) -> AlbumInfo: + defaults = dict( + title="Parachutes", + musicbrainz_id=TEST_ALBUM_MBID, + artist_name="Coldplay", + artist_id=TEST_ARTIST_MBID, + ) + defaults.update(overrides) + return AlbumInfo(**defaults) + + +def _make_album_service(audiodb_service: MagicMock | None = None) -> AlbumService: + if audiodb_service is None: + audiodb_service = MagicMock() + return AlbumService( + lidarr_repo=MagicMock(), + mb_repo=MagicMock(), + library_db=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + preferences_service=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + + +class TestSearchOverlayURLsWithoutByteDownload: + """Search overlay must populate AudioDB CDN URLs from cache only.""" + + @pytest.mark.asyncio + async def test_search_overlay_populates_urls_without_byte_download(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=CACHED_ARTIST_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url == "https://r2.theaudiodb.com/artist.jpg" + assert results[0].fanart_url == "https://r2.theaudiodb.com/fanart.jpg" + assert results[0].banner_url == "https://r2.theaudiodb.com/banner.jpg" + + audiodb.fetch_and_cache_artist_images.assert_not_called() + + audiodb.get_cached_artist_images.assert_awaited_once_with(TEST_ARTIST_MBID) + + + +class TestArtistListCacheOnlyNoAPICall: + """Artist service with allow_fetch=False must use cache, never API.""" + + @pytest.mark.asyncio + async def test_artist_list_cache_only_no_api_call(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=CACHED_ARTIST_IMAGES) + svc = _make_artist_service(audiodb) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_ARTIST_MBID, "Coldplay", allow_fetch=False, + ) + + assert result.thumb_url == "https://r2.theaudiodb.com/artist.jpg" + assert result.fanart_url == "https://r2.theaudiodb.com/fanart.jpg" + assert result.banner_url == "https://r2.theaudiodb.com/banner.jpg" + + audiodb.fetch_and_cache_artist_images.assert_not_called() + + audiodb.get_cached_artist_images.assert_awaited_once_with(TEST_ARTIST_MBID) + + + +class TestAlbumListCacheOnlyNoAPICall: + """Album service with allow_fetch=False must use cache, never API.""" + + @pytest.mark.asyncio + async def test_album_list_cache_only_no_api_call(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=CACHED_ALBUM_IMAGES) + svc = _make_album_service(audiodb) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_ALBUM_MBID, "Coldplay", "Parachutes", allow_fetch=False, + ) + + assert result.album_thumb_url == "https://r2.theaudiodb.com/album_thumb.jpg" + + audiodb.fetch_and_cache_album_images.assert_not_called() + + audiodb.get_cached_album_images.assert_awaited_once_with(TEST_ALBUM_MBID) diff --git a/backend/tests/services/test_cache_stats_nonblocking.py b/backend/tests/services/test_cache_stats_nonblocking.py new file mode 100644 index 0000000..ec83f9c --- /dev/null +++ b/backend/tests/services/test_cache_stats_nonblocking.py @@ -0,0 +1,76 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.cache_service import CacheService + + +def _make_service() -> CacheService: + cache = MagicMock() + cache.size.return_value = 10 + cache.estimate_memory_bytes.return_value = 1024 + lib_cache = AsyncMock() + lib_cache.get_stats = AsyncMock(return_value={ + "db_size_bytes": 0, + "artist_count": 0, + "album_count": 0, + }) + disk_cache = MagicMock() + disk_cache.get_stats.return_value = { + "total_count": 0, + "album_count": 0, + "artist_count": 0, + "audiodb_artist_count": 0, + "audiodb_album_count": 0, + } + return CacheService(cache=cache, library_db=lib_cache, disk_cache=disk_cache) + + +class TestCacheStatsNonblocking: + @pytest.mark.asyncio + async def test_get_stats_uses_to_thread(self): + """subprocess.run calls should be wrapped with asyncio.to_thread.""" + svc = _make_service() + + fake_du = MagicMock() + fake_du.returncode = 0 + fake_du.stdout = "12345\t/app/cache/covers" + + fake_find = MagicMock() + fake_find.returncode = 0 + fake_find.stdout = "file1.jpg\nfile2.jpg" + + call_count = 0 + + async def mock_to_thread(fn, *args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return fake_du + return fake_find + + with patch("services.cache_service.CACHE_DIR") as mock_dir, \ + patch("services.cache_service.shutil.which", return_value="/usr/bin/du"), \ + patch("services.cache_service.asyncio.to_thread", side_effect=mock_to_thread) as mock_tt: + mock_dir.exists.return_value = True + mock_dir.__str__ = lambda s: "/app/cache/covers" + + stats = await svc.get_stats() + + assert mock_tt.call_count == 2 + assert stats.disk_cover_count == 2 + assert stats.disk_cover_size_bytes == 12345 + + @pytest.mark.asyncio + async def test_get_stats_cached_response(self): + """Second call within TTL returns cached stats without subprocess.""" + svc = _make_service() + + with patch("services.cache_service.CACHE_DIR") as mock_dir: + mock_dir.exists.return_value = False + + stats1 = await svc.get_stats() + stats2 = await svc.get_stats() + + assert stats1 is stats2 diff --git a/backend/tests/services/test_discover_enrich_singleflight.py b/backend/tests/services/test_discover_enrich_singleflight.py new file mode 100644 index 0000000..5d9e50b --- /dev/null +++ b/backend/tests/services/test_discover_enrich_singleflight.py @@ -0,0 +1,189 @@ +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from api.v1.schemas.discover import DiscoverQueueEnrichment +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from services.discover_service import DiscoverService + + +def _make_prefs() -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = ListenBrainzConnectionSettings( + user_token="tok", username="u", enabled=True + ) + prefs.get_lastfm_connection.return_value = LastFmConnectionSettings( + api_key="k", shared_secret="s", session_key="sk", username="u", enabled=False + ) + prefs.is_lastfm_enabled.return_value = False + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source="listenbrainz") + jf = MagicMock() + jf.enabled = False + jf.jellyfin_url = "" + jf.api_key = "" + prefs.get_jellyfin_connection.return_value = jf + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + return prefs + + +def _make_service( + memory_cache: MagicMock | None = None, +) -> tuple[DiscoverService, AsyncMock]: + mb_repo = AsyncMock() + service = DiscoverService( + listenbrainz_repo=AsyncMock(), + jellyfin_repo=AsyncMock(), + lidarr_repo=AsyncMock(), + musicbrainz_repo=mb_repo, + preferences_service=_make_prefs(), + memory_cache=memory_cache, + ) + return service, mb_repo + + +FAKE_ENRICHMENT = DiscoverQueueEnrichment( + artist_mbid="artist-1", + tags=["rock"], + release_date="2020", +) + +MBID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + + +class TestEnrichSingleflight: + @pytest.mark.asyncio + async def test_concurrent_calls_run_enrichment_once(self): + """Multiple concurrent enrich_queue_item calls for the same mbid should only invoke + _do_enrich_queue_item once; all callers receive the same result.""" + service, mb_repo = _make_service(memory_cache=None) + call_count = 0 + original_do_enrich = service._enrichment._do_enrich_queue_item + + async def counting_enrich(release_group_mbid: str, cache_key: str): + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) + return FAKE_ENRICHMENT + + service._enrichment._do_enrich_queue_item = counting_enrich + + results = await asyncio.gather( + service.enrich_queue_item(MBID), + service.enrich_queue_item(MBID), + service.enrich_queue_item(MBID), + ) + + assert call_count == 1 + assert all(r == FAKE_ENRICHMENT for r in results) + + @pytest.mark.asyncio + async def test_singleflight_cleared_after_completion(self): + """After enrichment completes, the in-flight dict should be empty so a second call + runs the pipeline again (useful if the first result wasn't cached).""" + service, _ = _make_service(memory_cache=None) + + async def quick_enrich(release_group_mbid: str, cache_key: str): + return FAKE_ENRICHMENT + + service._enrichment._do_enrich_queue_item = quick_enrich + + await service.enrich_queue_item(MBID) + assert MBID not in service._enrichment._enrich_in_flight + + @pytest.mark.asyncio + async def test_singleflight_propagates_exception_to_all_waiters(self): + """If enrichment raises, all concurrent callers should receive the same exception.""" + service, _ = _make_service(memory_cache=None) + + async def failing_enrich(release_group_mbid: str, cache_key: str): + await asyncio.sleep(0.05) + raise RuntimeError("MB rate limit") + + service._enrichment._do_enrich_queue_item = failing_enrich + + results = await asyncio.gather( + service.enrich_queue_item(MBID), + service.enrich_queue_item(MBID), + service.enrich_queue_item(MBID), + return_exceptions=True, + ) + + assert all(isinstance(r, RuntimeError) for r in results) + assert all(str(r) == "MB rate limit" for r in results) + assert MBID not in service._enrichment._enrich_in_flight + + @pytest.mark.asyncio + async def test_memory_cache_hit_skips_singleflight(self): + """If the enrichment is in the memory cache, singleflight should not be consulted.""" + cache = AsyncMock() + cache.get = AsyncMock(return_value=FAKE_ENRICHMENT) + service, _ = _make_service(memory_cache=cache) + + call_count = 0 + + async def should_not_run(release_group_mbid: str, cache_key: str): + nonlocal call_count + call_count += 1 + return FAKE_ENRICHMENT + + service._enrichment._do_enrich_queue_item = should_not_run + + result = await service.enrich_queue_item(MBID) + assert result == FAKE_ENRICHMENT + assert call_count == 0 + + @pytest.mark.asyncio + async def test_memory_cache_miss_triggers_enrichment(self): + """If the memory cache returns None, the enrichment pipeline should run.""" + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + service, _ = _make_service(memory_cache=cache) + + async def simple_enrich(release_group_mbid: str, cache_key: str): + return FAKE_ENRICHMENT + + service._enrichment._do_enrich_queue_item = simple_enrich + + result = await service.enrich_queue_item(MBID) + assert result == FAKE_ENRICHMENT + + @pytest.mark.asyncio + async def test_different_mbids_run_independently(self): + """Enrichment for different mbids should run independently (no dedup).""" + service, _ = _make_service(memory_cache=None) + call_mbids: list[str] = [] + + async def tracking_enrich(release_group_mbid: str, cache_key: str): + call_mbids.append(release_group_mbid) + await asyncio.sleep(0.02) + return FAKE_ENRICHMENT + + service._enrichment._do_enrich_queue_item = tracking_enrich + + mbid_a = "aaaa1111-bbbb-cccc-dddd-eeeeeeeeeeee" + mbid_b = "bbbb2222-bbbb-cccc-dddd-eeeeeeeeeeee" + await asyncio.gather( + service.enrich_queue_item(mbid_a), + service.enrich_queue_item(mbid_b), + ) + + assert len(call_mbids) == 2 + assert mbid_a in call_mbids + assert mbid_b in call_mbids diff --git a/backend/tests/services/test_discover_queue_source.py b/backend/tests/services/test_discover_queue_source.py new file mode 100644 index 0000000..ed0256a --- /dev/null +++ b/backend/tests/services/test_discover_queue_source.py @@ -0,0 +1,334 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from api.v1.schemas.advanced_settings import AdvancedSettings +from repositories.lastfm_models import LastFmArtist, LastFmAlbum +from services.discover_service import DiscoverService + + +def _make_lb_settings( + enabled: bool = True, username: str = "lbuser" +) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", + username=username, + enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser" +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk", + username=username, + enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + prefs.get_advanced_settings.return_value = AdvancedSettings() + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + jf_settings.user_id = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + return prefs + + +def _make_service( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> tuple[DiscoverService, AsyncMock, AsyncMock, MagicMock]: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[]) + lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + lb_repo.get_user_fresh_releases = AsyncMock(return_value=None) + lb_repo.get_user_genre_activity = AsyncMock(return_value=None) + lb_repo.get_user_top_artists = AsyncMock(return_value=[]) + lb_repo.get_similar_artists = AsyncMock(return_value=[]) + lb_repo.get_artist_top_release_groups = AsyncMock(return_value=[]) + lb_repo.configure = MagicMock() + + lfm_repo = AsyncMock() + lfm_repo.get_global_top_artists = AsyncMock(return_value=[]) + lfm_repo.get_user_weekly_artist_chart = AsyncMock(return_value=[]) + lfm_repo.get_user_top_albums = AsyncMock(return_value=[]) + lfm_repo.get_user_recent_tracks = AsyncMock(return_value=[]) + lfm_repo.get_user_top_artists = AsyncMock(return_value=[]) + lfm_repo.get_similar_artists = AsyncMock(return_value=[]) + lfm_repo.get_artist_top_albums = AsyncMock(return_value=[]) + + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + mb_repo.search_release_groups_by_tag = AsyncMock(return_value=[]) + mb_repo.get_release_group_id_from_release = AsyncMock(return_value=None) + mb_repo.get_release_group_by_id = AsyncMock(return_value=None) + + prefs = _make_prefs( + lb_enabled=lb_enabled, + lfm_enabled=lfm_enabled, + primary_source=primary_source, + ) + + service = DiscoverService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + preferences_service=prefs, + lastfm_repo=lfm_repo, + ) + return service, lb_repo, lfm_repo, prefs + + +class TestBuildQueueSourceRouting: + @pytest.mark.asyncio + async def test_build_queue_uses_lastfm_when_source_is_lastfm(self): + """When source=lastfm and Last.fm is enabled, anonymous queue should call Last.fm APIs.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + lfm_repo.get_global_top_artists.return_value = [ + LastFmArtist(name="Artist1", mbid="mbid-1", playcount=1000, listeners=500), + ] + lfm_repo.get_artist_top_albums.return_value = [ + LastFmAlbum(name="Album1", mbid="album-mbid-1", playcount=100, artist_name="Artist1"), + ] + + result = await service.build_queue(count=5, source="lastfm") + assert result is not None + lfm_repo.get_global_top_artists.assert_awaited() + lb_repo.get_sitewide_top_release_groups.assert_not_awaited() + + @pytest.mark.asyncio + async def test_build_queue_uses_listenbrainz_when_source_is_lb(self): + """When source=listenbrainz, anonymous queue should call LB APIs.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + lb_repo.get_sitewide_top_release_groups.return_value = [] + + result = await service.build_queue(count=5, source="listenbrainz") + assert result is not None + lb_repo.get_sitewide_top_release_groups.assert_awaited() + lfm_repo.get_global_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_build_queue_none_source_uses_global_default(self): + """When source=None, queue should use the global primary source.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + lfm_repo.get_global_top_artists.return_value = [] + + result = await service.build_queue(count=5, source=None) + assert result is not None + lfm_repo.get_global_top_artists.assert_awaited() + + @pytest.mark.asyncio + async def test_build_queue_returns_valid_response(self): + """build_queue should return a DiscoverQueueResponse with queue_id.""" + service, _, _, _ = _make_service(lb_enabled=False, lfm_enabled=False) + result = await service.build_queue(count=5) + assert result is not None + assert result.queue_id + assert isinstance(result.items, list) + + +class TestBuildQueuePersonalizedSourceRouting: + @pytest.mark.asyncio + async def test_personalized_queue_lastfm_uses_lastfm_similar(self): + """Personalized queue with lastfm source should call Last.fm similar artists.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + lfm_repo.get_user_top_artists.return_value = [ + LastFmArtist(name="Seed", mbid="seed-mbid", playcount=500, listeners=100), + ] + lfm_repo.get_similar_artists.return_value = [] + + await service.build_queue(count=5, source="lastfm") + lfm_repo.get_user_top_artists.assert_awaited() + lfm_repo.get_similar_artists.assert_awaited() + lb_repo.get_similar_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_personalized_queue_lb_uses_lb_similar(self): + """Personalized queue with listenbrainz source should call LB similar artists.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + lb_repo.get_user_top_artists.return_value = [ + MagicMock(artist_name="Seed", artist_mbids=["seed-mbid"], listen_count=500), + ] + lb_repo.get_similar_artists.return_value = [] + + await service.build_queue(count=5, source="listenbrainz") + lb_repo.get_user_top_artists.assert_awaited() + lb_repo.get_similar_artists.assert_awaited() + lfm_repo.get_similar_artists.assert_not_awaited() + + +class TestLastFmQueueDataQuality: + @pytest.mark.asyncio + async def test_lastfm_queue_normalizes_release_mbids_to_release_groups(self): + service, _, lfm_repo, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + service._mbid_resolution._mb_repo.get_release_group_id_from_release.return_value = "rg-mbid-1" + + lfm_repo.get_global_top_artists.return_value = [ + LastFmArtist(name="Artist1", mbid="artist-mbid-1", playcount=1000, listeners=500), + ] + lfm_repo.get_artist_top_albums.return_value = [ + LastFmAlbum(name="Album1", mbid="release-mbid-1", playcount=100, artist_name="Artist1"), + ] + + result = await service.build_queue(count=5, source="lastfm") + + assert any(item.release_group_mbid == "rg-mbid-1" for item in result.items) + service._mbid_resolution._mb_repo.get_release_group_id_from_release.assert_awaited() + + @pytest.mark.asyncio + async def test_lastfm_seed_collection_does_not_call_listenbrainz_fallback(self): + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + lfm_repo.get_user_top_artists.return_value = [] + lfm_repo.get_global_top_artists.return_value = [] + + await service.build_queue(count=5, source="lastfm") + + lb_repo.get_user_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lastfm_queue_keeps_items_when_release_group_resolution_fails(self): + service, _, lfm_repo, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + service._mbid_resolution._mb_repo.get_release_group_id_from_release.return_value = None + service._mbid_resolution._mb_repo.get_release_group_by_id.return_value = None + + lfm_repo.get_global_top_artists.return_value = [ + LastFmArtist(name="Artist1", mbid="artist-mbid-1", playcount=1000, listeners=500), + ] + lfm_repo.get_artist_top_albums.return_value = [ + LastFmAlbum(name="Album1", mbid="release-mbid-1", playcount=100, artist_name="Artist1"), + ] + + result = await service.build_queue(count=5, source="lastfm") + + assert result.items + assert any(item.release_group_mbid == "release-mbid-1" for item in result.items) + + @pytest.mark.asyncio + async def test_lastfm_queue_items_are_deduplicated_by_release_group_mbid(self): + service, _, _, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + service._mbid_resolution.resolve_lastfm_release_group_mbids = AsyncMock(return_value={ + "release-a": "rg-shared", + "release-b": "rg-shared", + }) + + artist_a = LastFmArtist(name="Artist A", mbid="artist-a", playcount=100, listeners=10) + artist_b = LastFmArtist(name="Artist B", mbid="artist-b", playcount=120, listeners=12) + albums_a = [LastFmAlbum(name="Album A", mbid="release-a", playcount=50, artist_name="Artist A")] + albums_b = [LastFmAlbum(name="Album B", mbid="release-b", playcount=60, artist_name="Artist B")] + + items = await service._mbid_resolution.lastfm_albums_to_queue_items( + [(artist_a, albums_a), (artist_b, albums_b)], + exclude=set(), + target=5, + reason="Trending on Last.fm", + ) + + assert len(items) == 1 + assert items[0].release_group_mbid == "rg-shared" + + +class TestLastFmResolutionBehavior: + @pytest.mark.asyncio + async def test_lastfm_resolution_caps_musicbrainz_lookup_count(self): + service, _, _, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + album_mbids = [f"release-mbid-{idx}" for idx in range(10)] + + await service._mbid_resolution.resolve_lastfm_release_group_mbids(album_mbids, max_lookups=3) + + assert service._mbid_resolution._mb_repo.get_release_group_id_from_release.await_count == 3 + + +class TestLastFmQueueResilience: + @pytest.mark.asyncio + async def test_lastfm_queue_falls_back_to_decade_results_when_top_albums_sparse(self): + service, _, lfm_repo, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + lfm_repo.get_user_top_artists.return_value = [] + lfm_repo.get_global_top_artists.return_value = [ + LastFmArtist(name="Artist1", mbid="artist-mbid-1", playcount=1000, listeners=500), + ] + lfm_repo.get_artist_top_albums.return_value = [ + LastFmAlbum(name="Album No MBID", mbid=None, playcount=100, artist_name="Artist1"), + ] + + fallback_rg = MagicMock() + fallback_rg.musicbrainz_id = "rg-fallback-1" + fallback_rg.title = "Fallback Album" + fallback_rg.artist = "Fallback Artist" + + async def _search_release_groups_by_tag(tag, limit=25, offset=0): + if tag == "1990s" and offset == 0: + return [fallback_rg] + return [] + + service._queue._mb_repo.search_release_groups_by_tag = AsyncMock( + side_effect=_search_release_groups_by_tag + ) + + result = await service.build_queue(count=5, source="lastfm") + + assert result.items + assert any(item.release_group_mbid == "rg-fallback-1" for item in result.items) diff --git a/backend/tests/services/test_discover_service.py b/backend/tests/services/test_discover_service.py new file mode 100644 index 0000000..ce37cdc --- /dev/null +++ b/backend/tests/services/test_discover_service.py @@ -0,0 +1,658 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from api.v1.schemas.search import SearchResult +from repositories.listenbrainz_models import ( + ListenBrainzArtist, + ListenBrainzReleaseGroup, + ListenBrainzGenreActivity, + ListenBrainzFeedbackRecording, +) +from services.discover_service import DiscoverService + + +def _make_lb_settings(enabled: bool = True, username: str = "lbuser") -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok", + username=username, + enabled=enabled, + ) + + +def _make_lfm_settings( + enabled: bool = True, username: str = "lfmuser" +) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk", + username=username, + enabled=enabled, + ) + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled) + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + adv = MagicMock() + adv.discover_queue_size = 10 + adv.discover_queue_ttl = 3600 + adv.discover_queue_seed_artists = 3 + adv.discover_queue_wildcard_slots = 2 + adv.discover_queue_similar_artists_limit = 15 + adv.discover_queue_albums_per_similar = 3 + adv.discover_queue_enrich_ttl = 3600 + adv.discover_queue_lastfm_mbid_max_lookups = 10 + prefs.get_advanced_settings.return_value = adv + + return prefs + + +def _make_service( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> tuple[DiscoverService, AsyncMock, AsyncMock, MagicMock]: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[]) + lb_repo.get_user_fresh_releases = AsyncMock(return_value=None) + lb_repo.get_user_genre_activity = AsyncMock(return_value=None) + lb_repo.configure = MagicMock() + + lfm_repo = AsyncMock() + lfm_repo.get_global_top_artists = AsyncMock(return_value=[]) + lfm_repo.get_user_weekly_artist_chart = AsyncMock(return_value=[]) + lfm_repo.get_user_top_albums = AsyncMock(return_value=[]) + lfm_repo.get_user_recent_tracks = AsyncMock(return_value=[]) + + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + mb_repo = AsyncMock() + prefs = _make_prefs( + lb_enabled=lb_enabled, + lfm_enabled=lfm_enabled, + primary_source=primary_source, + ) + + service = DiscoverService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + preferences_service=prefs, + lastfm_repo=lfm_repo, + ) + return service, lb_repo, lfm_repo, prefs + + +class TestResolveSource: + def test_explicit_listenbrainz(self): + service, _, _, _ = _make_service(primary_source="lastfm") + assert service.resolve_source("listenbrainz") == "listenbrainz" + + def test_explicit_lastfm(self): + service, _, _, _ = _make_service(primary_source="listenbrainz") + assert service.resolve_source("lastfm") == "lastfm" + + def test_none_uses_global_setting(self): + service, _, _, _ = _make_service(primary_source="lastfm") + assert service.resolve_source(None) == "lastfm" + + def test_invalid_uses_global_setting(self): + service, _, _, _ = _make_service(primary_source="listenbrainz") + assert service.resolve_source("invalid") == "listenbrainz" + + +class TestLastFmSectionsAlwaysFetched: + @pytest.mark.asyncio + async def test_lastfm_tasks_fetched_when_source_is_listenbrainz(self): + """Last.fm weekly/recent tasks should be fetched even when primary source is listenbrainz.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + await service.build_discover_data(source="listenbrainz") + lfm_repo.get_user_weekly_artist_chart.assert_awaited_once() + lfm_repo.get_user_weekly_album_chart.assert_awaited_once() + lfm_repo.get_user_recent_tracks.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lastfm_tasks_fetched_when_source_is_lastfm(self): + """Last.fm weekly/recent tasks should also be fetched when primary source is lastfm.""" + service, _, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + await service.build_discover_data(source="lastfm") + lfm_repo.get_user_weekly_artist_chart.assert_awaited_once() + lfm_repo.get_user_weekly_album_chart.assert_awaited_once() + lfm_repo.get_user_recent_tracks.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lastfm_tasks_not_fetched_when_lfm_disabled(self): + """Last.fm tasks should NOT be fetched when Last.fm is disabled.""" + service, _, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=False, primary_source="listenbrainz" + ) + await service.build_discover_data(source="listenbrainz") + lfm_repo.get_user_weekly_artist_chart.assert_not_awaited() + lfm_repo.get_user_weekly_album_chart.assert_not_awaited() + lfm_repo.get_user_recent_tracks.assert_not_awaited() + + +class TestGloballyTrendingSourceSelection: + @pytest.mark.asyncio + async def test_listenbrainz_trending_when_source_is_lb(self): + """When source is listenbrainz, LB sitewide_top_artists should be called for trending.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + await service.build_discover_data(source="listenbrainz") + lb_repo.get_sitewide_top_artists.assert_awaited_once() + lfm_repo.get_global_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lastfm_trending_when_source_is_lastfm(self): + """When source is lastfm, LFM global_top_artists should be called for trending.""" + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + await service.build_discover_data(source="lastfm") + lfm_repo.get_global_top_artists.assert_awaited_once() + lb_repo.get_sitewide_top_artists.assert_not_awaited() + + +class TestCacheKeySourceAware: + def test_different_sources_produce_different_keys(self): + service, _, _, _ = _make_service() + key_lb = service._integration.get_discover_cache_key("listenbrainz") + key_lfm = service._integration.get_discover_cache_key("lastfm") + assert key_lb != key_lfm + assert "listenbrainz" in key_lb + assert "lastfm" in key_lfm + + def test_none_source_uses_global_default(self): + service, _, _, _ = _make_service(primary_source="lastfm") + key = service._integration.get_discover_cache_key(None) + assert "lastfm" in key + + +class TestBuildServicePrompts: + def _make_all_enabled(self) -> DiscoverService: + prefs = _make_prefs(lb_enabled=True, lfm_enabled=True) + jf = MagicMock() + jf.enabled = True + jf.jellyfin_url = "http://jf" + jf.api_key = "jf-key" + prefs.get_jellyfin_connection.return_value = jf + lidarr = MagicMock() + lidarr.lidarr_url = "http://lidarr" + lidarr.lidarr_api_key = "lidarr-key" + prefs.get_lidarr_connection.return_value = lidarr + service = DiscoverService( + listenbrainz_repo=AsyncMock(), + jellyfin_repo=AsyncMock(), + lidarr_repo=AsyncMock(), + musicbrainz_repo=AsyncMock(), + preferences_service=prefs, + lastfm_repo=AsyncMock(), + ) + return service + + def test_lastfm_prompt_shown_when_disabled(self): + service = self._make_all_enabled() + service._integration._preferences.is_lastfm_enabled.return_value = False + prompts = service._homepage._build_service_prompts() + services = [p.service for p in prompts] + assert "lastfm" in services + + def test_lastfm_prompt_hidden_when_enabled(self): + service = self._make_all_enabled() + prompts = service._homepage._build_service_prompts() + services = [p.service for p in prompts] + assert "lastfm" not in services + + def test_no_prompts_when_all_enabled(self): + service = self._make_all_enabled() + prompts = service._homepage._build_service_prompts() + assert prompts == [] + + def test_all_prompts_when_nothing_enabled(self): + service, _, _, _ = _make_service(lb_enabled=False, lfm_enabled=False) + prompts = service._homepage._build_service_prompts() + services = {p.service for p in prompts} + assert services == {"lidarr-connection", "listenbrainz", "jellyfin", "lastfm"} + + def test_lb_prompt_mentions_lastfm(self): + service = self._make_all_enabled() + lb_settings = _make_lb_settings(enabled=False) + service._integration._preferences.get_listenbrainz_connection.return_value = lb_settings + prompts = service._homepage._build_service_prompts() + lb_prompt = next(p for p in prompts if p.service == "listenbrainz") + assert "last.fm" in lb_prompt.description.lower() + + +class TestBuildArtistsYouMightLikeLastFm: + def test_handles_lastfm_similar_artist_shape(self): + """_build_artists_you_might_like must work with LastFmSimilarArtist (mbid/name, not artist_mbid/artist_name).""" + from repositories.lastfm_models import LastFmSimilarArtist + + service, _, _, _ = _make_service(primary_source="lastfm") + seed = MagicMock(artist_name="Radiohead", artist_mbids=["seed-mbid"]) + similar = [ + LastFmSimilarArtist(name="Muse", mbid="muse-mbid", match=0.9), + LastFmSimilarArtist(name="Coldplay", mbid="coldplay-mbid", match=0.7), + ] + results = {"similar_0": similar} + seen: set[str] = set() + section = service._homepage._build_artists_you_might_like( + [seed], results, set(), seen, resolved_source="lastfm" + ) + assert section is not None + assert len(section.items) == 2 + assert section.items[0].name == "Muse" + assert section.items[0].mbid == "muse-mbid" + assert section.items[1].name == "Coldplay" + assert section.source == "lastfm" + + def test_handles_listenbrainz_similar_artist_shape(self): + """_build_artists_you_might_like must also still work with LB similar artist objects.""" + service, _, _, _ = _make_service(primary_source="listenbrainz") + seed = MagicMock(artist_name="Radiohead", artist_mbids=["seed-mbid"]) + lb_similar = MagicMock( + artist_mbid="muse-mbid", + artist_name="Muse", + listen_count=500, + ) + lb_similar.mbid = None + lb_similar.name = None + lb_similar.playcount = None + results = {"similar_0": [lb_similar]} + seen: set[str] = set() + section = service._homepage._build_artists_you_might_like( + [seed], results, set(), seen, resolved_source="listenbrainz" + ) + assert section is not None + assert section.items[0].name == "Muse" + assert section.items[0].mbid == "muse-mbid" + assert section.source == "listenbrainz" + + def test_skips_artists_without_mbid(self): + """Artists with no mbid should be skipped.""" + from repositories.lastfm_models import LastFmSimilarArtist + + service, _, _, _ = _make_service() + seed = MagicMock(artist_name="Seed", artist_mbids=["seed-mbid"]) + similar = [ + LastFmSimilarArtist(name="NoMBID", mbid=None, match=0.5), + ] + results = {"similar_0": similar} + seen: set[str] = set() + section = service._homepage._build_artists_you_might_like( + [seed], results, set(), seen, resolved_source="lastfm" + ) + assert section is None + + +class TestDiscoverQueuePersonalization: + @pytest.mark.asyncio + async def test_queue_uses_non_wildcard_strategies_when_seed_similar_empty(self): + service, lb_repo, _, _ = _make_service(lb_enabled=True, lfm_enabled=False) + mb_repo = service._queue._mb_repo + + lb_repo.get_user_top_artists = AsyncMock( + return_value=[ + ListenBrainzArtist("Seed A", 100, ["seed-a"]), + ListenBrainzArtist("Seed B", 90, ["seed-b"]), + ListenBrainzArtist("Seed C", 80, ["seed-c"]), + ] + ) + lb_repo.get_similar_artists = AsyncMock(return_value=[]) + lb_repo.get_user_genre_activity = AsyncMock( + return_value=[ListenBrainzGenreActivity("post-rock", 150)] + ) + mb_repo.search_release_groups_by_tag = AsyncMock( + return_value=[ + SearchResult( + type="album", + title="Genre Album 1", + artist="Genre Artist", + musicbrainz_id="genre-rg-1", + ), + SearchResult( + type="album", + title="Genre Album 2", + artist="Genre Artist", + musicbrainz_id="genre-rg-2", + ), + ] + ) + lb_repo.get_user_fresh_releases = AsyncMock( + return_value=[ + { + "release_group_mbid": "fresh-rg-1", + "title": "Fresh Album", + "artist_credit_name": "Fresh Artist", + "artist_mbids": ["fresh-artist-1"], + } + ] + ) + lb_repo.get_user_loved_recordings = AsyncMock( + return_value=[ + ListenBrainzFeedbackRecording( + track_name="Loved Track", + artist_name="Loved Artist", + artist_mbids=["loved-artist-1"], + ) + ] + ) + + async def top_release_groups_side_effect( + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0, + ) -> list[ListenBrainzReleaseGroup]: + if range_ == "all_time": + return [] + return [ + ListenBrainzReleaseGroup( + release_group_name="Current Top", + artist_name="Top Artist", + listen_count=10, + release_group_mbid="top-current-rg", + artist_mbids=["top-artist-1"], + ) + ] + + lb_repo.get_user_top_release_groups = AsyncMock(side_effect=top_release_groups_side_effect) + + async def artist_release_groups_side_effect( + artist_mbid: str, + count: int = 10, + ) -> list[ListenBrainzReleaseGroup]: + if artist_mbid == "loved-artist-1": + return [ + ListenBrainzReleaseGroup( + release_group_name="Loved Artist Album", + artist_name="Loved Artist", + listen_count=50, + release_group_mbid="loved-rg-1", + ) + ] + if artist_mbid == "top-artist-1": + return [ + ListenBrainzReleaseGroup( + release_group_name="Deep Cut Album", + artist_name="Top Artist", + listen_count=20, + release_group_mbid="deep-cut-rg-1", + ) + ] + return [] + + lb_repo.get_artist_top_release_groups = AsyncMock(side_effect=artist_release_groups_side_effect) + + lb_repo.get_sitewide_top_release_groups = AsyncMock( + return_value=[ + ListenBrainzReleaseGroup( + release_group_name=f"Wildcard {i}", + artist_name=f"Wildcard Artist {i}", + listen_count=100 - i, + release_group_mbid=f"wild-rg-{i}", + artist_mbids=[f"wild-artist-{i}"], + ) + for i in range(20) + ] + ) + + queue = await service.build_queue(count=10, source="listenbrainz") + + assert len(queue.items) == 10 + assert any(not item.is_wildcard for item in queue.items) + assert any( + "Because you listen to" in item.recommendation_reason + or item.recommendation_reason == "New release for you" + or item.recommendation_reason == "From an artist you love" + for item in queue.items + ) + + @pytest.mark.asyncio + async def test_queue_all_wildcards_when_all_personalized_strategies_empty(self): + service, lb_repo, _, _ = _make_service(lb_enabled=True, lfm_enabled=False) + mb_repo = service._queue._mb_repo + + lb_repo.get_user_top_artists = AsyncMock( + return_value=[ + ListenBrainzArtist("Seed A", 100, ["seed-a"]), + ListenBrainzArtist("Seed B", 90, ["seed-b"]), + ListenBrainzArtist("Seed C", 80, ["seed-c"]), + ] + ) + lb_repo.get_similar_artists = AsyncMock(return_value=[]) + lb_repo.get_user_genre_activity = AsyncMock(return_value=[]) + mb_repo.search_release_groups_by_tag = AsyncMock(return_value=[]) + lb_repo.get_user_fresh_releases = AsyncMock(return_value=[]) + lb_repo.get_user_loved_recordings = AsyncMock(return_value=[]) + + async def no_top_release_groups( + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0, + ) -> list[ListenBrainzReleaseGroup]: + return [] + + lb_repo.get_user_top_release_groups = AsyncMock(side_effect=no_top_release_groups) + lb_repo.get_artist_top_release_groups = AsyncMock(return_value=[]) + lb_repo.get_sitewide_top_release_groups = AsyncMock( + return_value=[ + ListenBrainzReleaseGroup( + release_group_name=f"Wildcard {i}", + artist_name=f"Wildcard Artist {i}", + listen_count=100 - i, + release_group_mbid=f"wild-rg-{i}", + artist_mbids=[f"wild-artist-{i}"], + ) + for i in range(20) + ] + ) + + queue = await service.build_queue(count=10, source="listenbrainz") + + assert len(queue.items) == 10 + assert all(item.is_wildcard for item in queue.items) + + @pytest.mark.asyncio + async def test_deep_cuts_excludes_listened_release_groups(self): + """Deep cuts strategy should exclude listened release groups, other strategies should not.""" + service, lb_repo, _, _ = _make_service(lb_enabled=True, lfm_enabled=False) + mb_repo = service._queue._mb_repo + + lb_repo.get_user_top_artists = AsyncMock( + return_value=[ + ListenBrainzArtist("Seed A", 100, ["seed-a"]), + ] + ) + lb_repo.get_similar_artists = AsyncMock(return_value=[]) + lb_repo.get_user_genre_activity = AsyncMock(return_value=[]) + mb_repo.search_release_groups_by_tag = AsyncMock(return_value=[]) + lb_repo.get_user_loved_recordings = AsyncMock(return_value=[]) + lb_repo.get_user_fresh_releases = AsyncMock(return_value=[]) + + async def top_release_groups_side_effect( + username: str | None = None, + range_: str = "this_month", + count: int = 25, + offset: int = 0, + ) -> list[ListenBrainzReleaseGroup]: + if range_ == "all_time": + return [ + ListenBrainzReleaseGroup( + release_group_name="Already Heard", + artist_name="Deep Artist", + listen_count=120, + release_group_mbid="listened-rg-1", + artist_mbids=["deep-artist-1"], + ) + ] + if range_ == "this_month": + return [ + ListenBrainzReleaseGroup( + release_group_name="Current Fav", + artist_name="Deep Artist", + listen_count=50, + release_group_mbid="current-rg-1", + artist_mbids=["deep-artist-1"], + ) + ] + return [] + + lb_repo.get_user_top_release_groups = AsyncMock(side_effect=top_release_groups_side_effect) + + async def artist_release_groups_side_effect( + artist_mbid: str, + count: int = 10, + ) -> list[ListenBrainzReleaseGroup]: + if artist_mbid == "deep-artist-1": + return [ + ListenBrainzReleaseGroup( + release_group_name="Already Heard", + artist_name="Deep Artist", + listen_count=120, + release_group_mbid="listened-rg-1", + ), + ListenBrainzReleaseGroup( + release_group_name="Unheard Deep Cut", + artist_name="Deep Artist", + listen_count=5, + release_group_mbid="deep-cut-new", + ), + ] + return [] + + lb_repo.get_artist_top_release_groups = AsyncMock(side_effect=artist_release_groups_side_effect) + lb_repo.get_sitewide_top_release_groups = AsyncMock( + return_value=[ + ListenBrainzReleaseGroup( + release_group_name=f"Wildcard {i}", + artist_name=f"Wildcard Artist {i}", + listen_count=100 - i, + release_group_mbid=f"wild-rg-{i}", + artist_mbids=[f"wild-artist-{i}"], + ) + for i in range(20) + ] + ) + + queue = await service.build_queue(count=10, source="listenbrainz") + mbids = {item.release_group_mbid.lower() for item in queue.items} + + assert "listened-rg-1" not in mbids, "Deep cuts should exclude listened release groups" + assert "deep-cut-new" in mbids, "Unheard deep cuts should be included" + + +class TestDiscoverPerformanceHotpaths: + @pytest.mark.asyncio + async def test_missing_essentials_does_not_use_per_artist_sleep(self, monkeypatch): + service, lb_repo, _, _ = _make_service(lb_enabled=True, lfm_enabled=False) + + sleep_mock = AsyncMock() + monkeypatch.setattr("services.discover.homepage_service.asyncio.sleep", sleep_mock) + + library_albums = [ + MagicMock(artist_mbid="artist-1", musicbrainz_id="in-lib-1"), + MagicMock(artist_mbid="artist-1", musicbrainz_id="in-lib-2"), + MagicMock(artist_mbid="artist-1", musicbrainz_id="in-lib-3"), + ] + lb_repo.get_artist_top_release_groups = AsyncMock( + return_value=[ + MagicMock( + release_group_mbid="new-rg-1", + release_group_name="Missing Album", + artist_name="Artist 1", + listen_count=25, + ) + ] + ) + + section = await service._homepage._build_missing_essentials( + { + "library_artists": [MagicMock(musicbrainz_id="artist-1")], + "library_albums": library_albums, + }, + library_mbids=set(), + ) + + sleep_mock.assert_not_awaited() + lb_repo.get_artist_top_release_groups.assert_awaited_once_with("artist-1", count=10) + assert section is not None + assert section.items[0].mbid == "new-rg-1" + + @pytest.mark.asyncio + async def test_popular_in_genres_lastfm_handles_partial_failures(self): + service, _, lfm_repo, _ = _make_service(lb_enabled=False, lfm_enabled=True) + from types import SimpleNamespace + + top_artists = [ + SimpleNamespace(name="A", mbid="a-mbid"), + SimpleNamespace(name="B", mbid="b-mbid"), + SimpleNamespace(name="C", mbid="c-mbid"), + ] + + info_1 = SimpleNamespace( + tags=[SimpleNamespace(name="Rock"), SimpleNamespace(name="Indie")] + ) + info_3 = SimpleNamespace(tags=[SimpleNamespace(name="Shoegaze")]) + lfm_repo.get_artist_info = AsyncMock(side_effect=[info_1, Exception("boom"), info_3]) + + async def _tag_side_effect(genre_name: str, limit: int = 10): + if genre_name.lower() == "rock": + return [SimpleNamespace(mbid="artist-rock", name="Rock Artist", playcount=42)] + if genre_name.lower() == "indie": + raise Exception("tag fail") + return [SimpleNamespace(mbid="artist-shoe", name="Shoe Artist", playcount=30)] + + lfm_repo.get_tag_top_artists = AsyncMock(side_effect=_tag_side_effect) + + section = await service._homepage._build_popular_in_genres_lastfm( + {"lfm_user_top_artists_for_genres": top_artists}, + library_mbids=set(), + seen_artist_mbids=set(), + ) + + assert section is not None + assert {item.mbid for item in section.items} == {"artist-rock", "artist-shoe"} + assert section.source == "lastfm" diff --git a/backend/tests/services/test_discovery_precache_progress.py b/backend/tests/services/test_discovery_precache_progress.py new file mode 100644 index 0000000..5cf7840 --- /dev/null +++ b/backend/tests/services/test_discovery_precache_progress.py @@ -0,0 +1,129 @@ +"""Tests for artist discovery precache progress reporting and error handling.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.artist_discovery_service import ArtistDiscoveryService + + +def _make_service(*, lb_configured: bool = True, lastfm_enabled: bool = False): + lb_repo = MagicMock() + lb_repo.is_configured.return_value = lb_configured + + lastfm_repo = MagicMock() if lastfm_enabled else None + prefs = MagicMock() + prefs.is_lastfm_enabled.return_value = lastfm_enabled + + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + + library_db = AsyncMock() + library_db.get_all_artist_mbids = AsyncMock(return_value=set()) + + svc = ArtistDiscoveryService( + listenbrainz_repo=lb_repo, + musicbrainz_repo=MagicMock(), + library_db=library_db, + lidarr_repo=MagicMock(), + memory_cache=cache, + lastfm_repo=lastfm_repo, + preferences_service=prefs, + ) + return svc + + +@pytest.mark.asyncio +async def test_progress_updates_on_success(): + svc = _make_service() + status = AsyncMock() + status.update_progress = AsyncMock() + + with ( + patch.object(svc, "get_similar_artists", new_callable=AsyncMock, return_value=MagicMock()), + patch.object(svc, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), + patch.object(svc, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), + ): + await svc.precache_artist_discovery( + ["mbid-a", "mbid-b"], + delay=0, + status_service=status, + mbid_to_name={"mbid-a": "Artist A", "mbid-b": "Artist B"}, + ) + + assert status.update_progress.call_count == 2 + status.update_progress.assert_any_call(1, current_item="Artist A") + status.update_progress.assert_any_call(2, current_item="Artist B") + + +@pytest.mark.asyncio +async def test_progress_updates_even_on_failure(): + svc = _make_service() + status = AsyncMock() + status.update_progress = AsyncMock() + + with ( + patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=RuntimeError("boom")), + patch.object(svc, "get_top_songs", new_callable=AsyncMock, side_effect=RuntimeError("boom")), + patch.object(svc, "get_top_albums", new_callable=AsyncMock, side_effect=RuntimeError("boom")), + ): + await svc.precache_artist_discovery( + ["mbid-a", "mbid-b", "mbid-c"], + delay=0, + status_service=status, + mbid_to_name={"mbid-a": "A", "mbid-b": "B", "mbid-c": "C"}, + ) + + assert status.update_progress.call_count == 3 + status.update_progress.assert_any_call(1, current_item="A") + status.update_progress.assert_any_call(2, current_item="B") + status.update_progress.assert_any_call(3, current_item="C") + + +@pytest.mark.asyncio +async def test_progress_updates_on_mixed_success_and_failure(): + svc = _make_service() + status = AsyncMock() + status.update_progress = AsyncMock() + + call_count = 0 + + async def sometimes_fail(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count <= 3: + raise RuntimeError("transient") + return MagicMock() + + with ( + patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=sometimes_fail), + patch.object(svc, "get_top_songs", new_callable=AsyncMock, side_effect=sometimes_fail), + patch.object(svc, "get_top_albums", new_callable=AsyncMock, side_effect=sometimes_fail), + ): + await svc.precache_artist_discovery( + ["mbid-1", "mbid-2"], + delay=0, + status_service=status, + ) + + assert status.update_progress.call_count == 2 + + +@pytest.mark.asyncio +async def test_cached_artists_still_update_progress(): + svc = _make_service() + status = AsyncMock() + status.update_progress = AsyncMock() + + svc._cache.get = AsyncMock(return_value="cached-value") + + await svc.precache_artist_discovery( + ["mbid-a", "mbid-b"], + delay=0, + status_service=status, + mbid_to_name={"mbid-a": "A", "mbid-b": "B"}, + ) + + assert status.update_progress.call_count == 2 diff --git a/backend/tests/services/test_genre_batch_parallel.py b/backend/tests/services/test_genre_batch_parallel.py new file mode 100644 index 0000000..4471899 --- /dev/null +++ b/backend/tests/services/test_genre_batch_parallel.py @@ -0,0 +1,110 @@ +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from services.home.genre_service import GenreService + + +def _make_service() -> tuple[GenreService, AsyncMock]: + mb = AsyncMock() + mem_cache = AsyncMock() + mem_cache.get = AsyncMock(return_value=None) + mem_cache.set = AsyncMock() + svc = GenreService( + musicbrainz_repo=mb, + memory_cache=mem_cache, + ) + return svc, mb + + +def _make_artist(mbid: str) -> MagicMock: + a = MagicMock() + a.musicbrainz_id = mbid + return a + + +class TestGenreBatchParallel: + @pytest.mark.asyncio + async def test_genre_batch_fires_parallel(self): + """All genre lookups should start before any finishes (parallel execution).""" + svc, mb = _make_service() + start_times: list[float] = [] + end_times: list[float] = [] + loop = asyncio.get_event_loop() + + original_search = mb.search_artists_by_tag + + async def tracking_search(genre_name, limit=10): + start_times.append(loop.time()) + await asyncio.sleep(0.05) + end_times.append(loop.time()) + return [_make_artist(f"mbid-{genre_name}")] + + mb.search_artists_by_tag = tracking_search + + genres = ["rock", "pop", "jazz", "metal", "blues"] + results = await svc.get_genre_artists_batch(genres) + + assert len(results) == 5 + # All starts should be before the first end (proves parallelism) + assert max(start_times) < min(end_times) + + @pytest.mark.asyncio + async def test_genre_batch_deduplicates(self): + """When two genres resolve the same MBID, the second gets re-resolved.""" + svc, mb = _make_service() + shared_mbid = "shared-aaaa-bbbb-cccc-dddddddddddd" + alt_mbid = "alt-eeee-ffff-gggg-hhhhhhhhhhhh" + call_count = 0 + + async def search_returning_same(genre_name, limit=10): + nonlocal call_count + call_count += 1 + if call_count > 2 and genre_name == "pop": + return [_make_artist(alt_mbid)] + return [_make_artist(shared_mbid)] + + mb.search_artists_by_tag = search_returning_same + + genres = ["rock", "pop"] + results = await svc.get_genre_artists_batch(genres) + + # rock gets the shared mbid, pop should be re-resolved + assert results["rock"] == shared_mbid + assert results["pop"] == alt_mbid + + @pytest.mark.asyncio + async def test_genre_batch_empty_input(self): + """Empty genre list returns empty dict.""" + svc, _ = _make_service() + result = await svc.get_genre_artists_batch([]) + assert result == {} + + @pytest.mark.asyncio + async def test_genre_batch_handles_none_results(self): + """Genres with no matching artist return None.""" + svc, mb = _make_service() + + async def no_results(genre_name, limit=10): + return [] + + mb.search_artists_by_tag = no_results + + genres = ["nonexistent"] + results = await svc.get_genre_artists_batch(genres) + assert results == {"nonexistent": None} + + @pytest.mark.asyncio + async def test_genre_batch_caps_at_20(self): + """Only processes the first 20 genres.""" + svc, mb = _make_service() + + async def simple_search(genre_name, limit=10): + return [_make_artist(f"mbid-{genre_name}")] + + mb.search_artists_by_tag = simple_search + + genres = [f"genre-{i}" for i in range(25)] + results = await svc.get_genre_artists_batch(genres) + assert len(results) == 20 diff --git a/backend/tests/services/test_genre_cover_prewarm_service.py b/backend/tests/services/test_genre_cover_prewarm_service.py new file mode 100644 index 0000000..183ab8a --- /dev/null +++ b/backend/tests/services/test_genre_cover_prewarm_service.py @@ -0,0 +1,139 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from services.genre_cover_prewarm_service import ( + GenreCoverPrewarmService, + _PREWARM_INTER_ITEM_DELAY, +) + + +def _make_cover_repo() -> MagicMock: + repo = MagicMock() + repo.get_artist_image = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb")) + repo.get_release_group_cover = AsyncMock(return_value=(b"img", "image/jpeg", "audiodb")) + return repo + + +@pytest.mark.asyncio +async def test_schedule_prewarm_calls_cover_repo_for_artists_and_albums(): + repo = _make_cover_repo() + svc = GenreCoverPrewarmService(cover_repo=repo) + + svc.schedule_prewarm("rock", ["a1", "a2"], ["b1"]) + await asyncio.sleep(0.1) + task = svc._active_genres.get("rock") + if task: + await task + + assert repo.get_artist_image.await_count == 2 + assert repo.get_release_group_cover.await_count == 1 + + +@pytest.mark.asyncio +async def test_schedule_prewarm_deduplicates_same_genre(): + repo = _make_cover_repo() + + async def _slow_fetch(*args, **kwargs): + await asyncio.sleep(0.5) + return (b"img", "image/jpeg", "audiodb") + + repo.get_artist_image = AsyncMock(side_effect=_slow_fetch) + svc = GenreCoverPrewarmService(cover_repo=repo) + + svc.schedule_prewarm("rock", ["a1"], []) + svc.schedule_prewarm("rock", ["a2", "a3"], []) + await asyncio.sleep(0.05) + + assert len(svc._active_genres) == 1 + task = svc._active_genres["rock"] + await task + + assert repo.get_artist_image.await_count == 1 + + +@pytest.mark.asyncio +async def test_error_isolation_per_item(): + repo = _make_cover_repo() + call_count = 0 + + async def _artist_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("boom") + return (b"img", "image/jpeg", "audiodb") + + repo.get_artist_image = AsyncMock(side_effect=_artist_side_effect) + svc = GenreCoverPrewarmService(cover_repo=repo) + + svc.schedule_prewarm("jazz", ["fail", "ok"], []) + await asyncio.sleep(0.1) + task = svc._active_genres.get("jazz") + if task: + await task + + assert repo.get_artist_image.await_count == 2 + + +@pytest.mark.asyncio +async def test_done_callback_does_not_remove_new_task(): + repo = _make_cover_repo() + svc = GenreCoverPrewarmService(cover_repo=repo) + + blocker = asyncio.Event() + original_prewarm = svc._prewarm + + async def _slow_prewarm(*args, **kwargs): + await blocker.wait() + + svc._prewarm = _slow_prewarm + svc.schedule_prewarm("pop", ["a1"], []) + first_task = svc._active_genres["pop"] + + blocker.set() + await first_task + + svc._prewarm = original_prewarm + svc.schedule_prewarm("pop", ["a2"], []) + second_task = svc._active_genres.get("pop") + + assert second_task is not None + assert second_task is not first_task + await second_task + + +@pytest.mark.asyncio +async def test_shutdown_cancels_active_tasks(): + repo = _make_cover_repo() + repo.get_artist_image = AsyncMock(side_effect=lambda *a, **kw: asyncio.sleep(10)) + svc = GenreCoverPrewarmService(cover_repo=repo) + + svc.schedule_prewarm("metal", [f"m{i}" for i in range(5)], []) + await asyncio.sleep(0.1) + + assert len(svc._active_genres) == 1 + await svc.shutdown() + assert len(svc._active_genres) == 0 + + +@pytest.mark.asyncio +async def test_shutdown_is_idempotent_when_no_tasks(): + repo = _make_cover_repo() + svc = GenreCoverPrewarmService(cover_repo=repo) + await svc.shutdown() + assert len(svc._active_genres) == 0 + + +@pytest.mark.asyncio +async def test_cleanup_after_task_completes(): + repo = _make_cover_repo() + svc = GenreCoverPrewarmService(cover_repo=repo) + + svc.schedule_prewarm("blues", ["a1"], []) + task = svc._active_genres["blues"] + await task + await asyncio.sleep(0.05) + + assert "blues" not in svc._active_genres diff --git a/backend/tests/services/test_home_charts_service.py b/backend/tests/services/test_home_charts_service.py new file mode 100644 index 0000000..b58f475 --- /dev/null +++ b/backend/tests/services/test_home_charts_service.py @@ -0,0 +1,127 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, call + +from api.v1.schemas.settings import ( + LastFmConnectionSettings, + ListenBrainzConnectionSettings, + PrimaryMusicSourceSettings, +) +from services.home_charts_service import HomeChartsService + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = True, + lfm_username: str = "lfmuser", + primary_source: str = "lastfm", +) -> MagicMock: + prefs = MagicMock() + lb_settings = ListenBrainzConnectionSettings( + user_token="tok", username="lbuser", enabled=lb_enabled + ) + prefs.get_listenbrainz_connection.return_value = lb_settings + + lfm_settings = LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk", + username=lfm_username, + enabled=lfm_enabled, + ) + prefs.get_lastfm_connection.return_value = lfm_settings + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + return prefs + + +def _make_service( + lb_enabled: bool = True, + lfm_enabled: bool = True, + lfm_username: str = "lfmuser", + primary_source: str = "lastfm", +) -> tuple[HomeChartsService, AsyncMock, AsyncMock]: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[]) + lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + + lfm_repo = AsyncMock() + lfm_repo.get_global_top_artists = AsyncMock(return_value=[]) + lfm_repo.get_user_top_albums = AsyncMock(return_value=[]) + + lidarr_repo = AsyncMock() + lidarr_repo.get_library = AsyncMock(return_value=[]) + lidarr_repo.get_artists_from_library = AsyncMock(return_value=[]) + + mb_repo = AsyncMock() + prefs = _make_prefs( + lb_enabled=lb_enabled, + lfm_enabled=lfm_enabled, + lfm_username=lfm_username, + primary_source=primary_source, + ) + + service = HomeChartsService( + listenbrainz_repo=lb_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + lastfm_repo=lfm_repo, + preferences_service=prefs, + ) + return service, lb_repo, lfm_repo + + +class TestPopularAlbumsLastFmMissingUsername: + @pytest.mark.asyncio + async def test_returns_empty_when_username_missing(self): + """When Last.fm is enabled but username is empty, should return empty response.""" + service, _, lfm_repo = _make_service( + lfm_enabled=True, lfm_username="", primary_source="lastfm" + ) + result = await service.get_popular_albums(limit=10, source="lastfm") + lfm_repo.get_user_top_albums.assert_not_awaited() + assert result.all_time.featured is None + assert result.all_time.items == [] + assert result.all_time.total_count == 0 + + @pytest.mark.asyncio + async def test_returns_empty_when_lastfm_disabled(self): + """When Last.fm is disabled, should return empty response.""" + service, _, lfm_repo = _make_service( + lfm_enabled=False, lfm_username="user", primary_source="lastfm" + ) + result = await service._get_popular_albums_lastfm(limit=10) + lfm_repo.get_user_top_albums.assert_not_awaited() + assert result.all_time.total_count == 0 + + @pytest.mark.asyncio + async def test_calls_api_when_username_present(self): + """When Last.fm is enabled with a username, should call the API.""" + service, _, lfm_repo = _make_service( + lfm_enabled=True, lfm_username="validuser", primary_source="lastfm" + ) + await service.get_popular_albums(limit=10, source="lastfm") + assert lfm_repo.get_user_top_albums.await_count == 4 + lfm_repo.get_user_top_albums.assert_has_awaits( + [ + call("validuser", period="7day", limit=11), + call("validuser", period="1month", limit=11), + call("validuser", period="12month", limit=11), + call("validuser", period="overall", limit=11), + ], + any_order=True, + ) + + @pytest.mark.asyncio + async def test_range_endpoint_uses_source_specific_lastfm_period(self): + service, _, lfm_repo = _make_service( + lfm_enabled=True, lfm_username="validuser", primary_source="lastfm" + ) + await service.get_popular_albums_by_range( + range_key="this_year", + limit=5, + offset=0, + source="lastfm", + ) + lfm_repo.get_user_top_albums.assert_awaited_once_with( + "validuser", period="12month", limit=6 + ) diff --git a/backend/tests/services/test_home_genre_decoupling.py b/backend/tests/services/test_home_genre_decoupling.py new file mode 100644 index 0000000..4b00ca4 --- /dev/null +++ b/backend/tests/services/test_home_genre_decoupling.py @@ -0,0 +1,271 @@ +"""Tests for genre section decoupling from home page build.""" + +import asyncio +import json +import time + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from api.v1.schemas.home import HomeGenre, HomeResponse, HomeSection +from services.home.genre_service import GenreService, GENRE_SECTION_TTL_DEFAULT + + +def _make_artist(mbid: str) -> MagicMock: + a = MagicMock() + a.musicbrainz_id = mbid + return a + + +def _make_genre_service( + tmp_path=None, + genre_section_ttl: int = GENRE_SECTION_TTL_DEFAULT, +) -> tuple[GenreService, AsyncMock, AsyncMock]: + mb = AsyncMock() + mem_cache = AsyncMock() + mem_cache.get = AsyncMock(return_value=None) + mem_cache.set = AsyncMock() + audiodb = AsyncMock() + + prefs = MagicMock() + adv = MagicMock() + adv.genre_section_ttl = genre_section_ttl + prefs.get_advanced_settings.return_value = adv + + svc = GenreService( + musicbrainz_repo=mb, + memory_cache=mem_cache, + audiodb_image_service=audiodb, + cache_dir=tmp_path, + preferences_service=prefs, + ) + return svc, mb, mem_cache + + +class TestGenreSectionCacheHit: + @pytest.mark.asyncio + async def test_cache_hit_returns_data_no_mb_calls(self, tmp_path): + """Genre section cache hit should return data without any MB/AudioDB calls.""" + svc, mb, mem_cache = _make_genre_service(tmp_path) + genre_artists = {"rock": "mbid-1", "jazz": "mbid-2"} + genre_images = {"rock": "http://img/rock.jpg", "jazz": "http://img/jazz.jpg"} + + mem_cache.get = AsyncMock(return_value=(genre_artists, genre_images)) + + result = await svc.get_cached_genre_section("listenbrainz") + assert result is not None + assert result[0] == genre_artists + assert result[1] == genre_images + mb.search_artists_by_tag.assert_not_awaited() + + @pytest.mark.asyncio + async def test_cache_miss_returns_none(self, tmp_path): + """Genre section cache miss should return None.""" + svc, _, mem_cache = _make_genre_service(tmp_path) + mem_cache.get = AsyncMock(return_value=None) + + result = await svc.get_cached_genre_section("listenbrainz") + assert result is None + + +class TestGenreSectionDiskPersistence: + @pytest.mark.asyncio + async def test_save_and_read_from_disk(self, tmp_path): + """Genre section should be readable from disk after save.""" + svc, _, mem_cache = _make_genre_service(tmp_path) + genre_artists = {"rock": "mbid-1", "pop": "mbid-2"} + genre_images = {"rock": "http://img/rock.jpg"} + + await svc.save_genre_section("listenbrainz", genre_artists, genre_images) + + mem_cache.get = AsyncMock(return_value=None) + result = await svc.get_cached_genre_section("listenbrainz") + + assert result is not None + assert result[0] == genre_artists + assert result[1] == genre_images + + @pytest.mark.asyncio + async def test_disk_survives_memory_clear(self, tmp_path): + """Simulates a restart: memory cache is empty, disk data should be used.""" + svc, _, mem_cache = _make_genre_service(tmp_path) + genre_artists = {"metal": "mbid-3"} + genre_images = {"metal": "http://img/metal.jpg"} + + await svc.save_genre_section("lastfm", genre_artists, genre_images) + + mem_cache.get = AsyncMock(return_value=None) + svc2, _, mem_cache2 = _make_genre_service(tmp_path) + mem_cache2.get = AsyncMock(return_value=None) + result = await svc2.get_cached_genre_section("lastfm") + + assert result is not None + assert result[0] == genre_artists + + @pytest.mark.asyncio + async def test_expired_disk_returns_none(self, tmp_path): + """Expired genre section on disk should return None.""" + svc, _, mem_cache = _make_genre_service(tmp_path, genre_section_ttl=1) + genre_artists = {"rock": "mbid-1"} + genre_images = {} + + await svc.save_genre_section("listenbrainz", genre_artists, genre_images) + + file_path = tmp_path / "genre_sections" / "listenbrainz.json" + data = json.loads(file_path.read_text()) + data["built_at"] = time.time() - 100 + file_path.write_text(json.dumps(data)) + + mem_cache.get = AsyncMock(return_value=None) + result = await svc.get_cached_genre_section("listenbrainz") + assert result is None + + +class TestGenreSectionBuild: + @pytest.mark.asyncio + async def test_build_caches_result(self, tmp_path): + """build_and_cache_genre_section should build and save to disk.""" + svc, mb, mem_cache = _make_genre_service(tmp_path) + mb.search_artists_by_tag = AsyncMock( + side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")] + ) + + img_result = MagicMock() + img_result.is_negative = False + img_result.wide_thumb_url = "http://img/test.jpg" + img_result.banner_url = None + img_result.fanart_url = None + svc._audiodb_image_service.fetch_and_cache_artist_images = AsyncMock( + return_value=img_result + ) + + await svc.build_and_cache_genre_section("listenbrainz", ["rock", "jazz"]) + + mem_cache.get = AsyncMock(return_value=None) + result = await svc.get_cached_genre_section("listenbrainz") + assert result is not None + assert "rock" in result[0] + assert "jazz" in result[0] + + @pytest.mark.asyncio + async def test_build_skips_when_locked(self, tmp_path): + """Concurrent builds for same source should be skipped if lock is held.""" + svc, mb, _ = _make_genre_service(tmp_path) + mb.search_artists_by_tag = AsyncMock( + side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")] + ) + + svc._genre_build_locks["listenbrainz"] = asyncio.Lock() + async with svc._genre_build_locks["listenbrainz"]: + await svc.build_and_cache_genre_section("listenbrainz", ["rock"]) + + mb.search_artists_by_tag.assert_not_awaited() + + +class TestGenreSectionTTLSetting: + @pytest.mark.asyncio + async def test_ttl_from_preferences(self, tmp_path): + """Genre section TTL should be read from advanced settings.""" + svc, _, _ = _make_genre_service(tmp_path, genre_section_ttl=7200) + assert svc._get_genre_section_ttl() == 7200 + + @pytest.mark.asyncio + async def test_ttl_default_on_missing(self, tmp_path): + """Missing preference should fall back to default TTL.""" + svc, _, _ = _make_genre_service(tmp_path) + svc._preferences_service = None + assert svc._get_genre_section_ttl() == GENRE_SECTION_TTL_DEFAULT + + +class TestAdvancedSettingsGenreTTL: + def test_genre_section_ttl_default(self): + """AdvancedSettings should include genre_section_ttl with correct default.""" + from api.v1.schemas.advanced_settings import AdvancedSettings + settings = AdvancedSettings() + assert settings.genre_section_ttl == 21600 + + def test_genre_section_ttl_roundtrip(self): + """Frontend→backend roundtrip should preserve genre_section_ttl value.""" + from api.v1.schemas.advanced_settings import AdvancedSettings, AdvancedSettingsFrontend + backend = AdvancedSettings(genre_section_ttl=43200) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.genre_section_ttl == 12 + roundtripped = frontend.to_backend() + assert roundtripped.genre_section_ttl == 43200 + + def test_genre_section_ttl_validation_rejects_below_minimum(self): + """genre_section_ttl below 3600 should be rejected.""" + from api.v1.schemas.advanced_settings import AdvancedSettings + import msgspec + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(genre_section_ttl=100) + + +class TestPerSourceLockIndependence: + """Per-source locks allow parallel builds for different sources.""" + + @pytest.mark.asyncio + async def test_different_sources_build_independently(self, tmp_path): + """Building for LB should not block building for LFM.""" + svc, mb, audiodb = _make_genre_service(tmp_path) + mb.search_artists_by_tag = AsyncMock( + side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")] + ) + audiodb.get_artist_images_batch = AsyncMock(return_value={}) + + results = await asyncio.gather( + svc.build_and_cache_genre_section("listenbrainz", ["rock"]), + svc.build_and_cache_genre_section("lastfm", ["jazz"]), + ) + + assert mb.search_artists_by_tag.await_count == 2 + + @pytest.mark.asyncio + async def test_per_source_locks_created_on_demand(self, tmp_path): + """Lock for a source is created when first needed.""" + svc, mb, audiodb = _make_genre_service(tmp_path) + + assert "listenbrainz" not in svc._genre_build_locks + + mb.search_artists_by_tag = AsyncMock( + side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")] + ) + audiodb.get_artist_images_batch = AsyncMock(return_value={}) + await svc.build_and_cache_genre_section("listenbrainz", ["rock"]) + + assert "listenbrainz" in svc._genre_build_locks + + +class TestWarmerRetryOnNoop: + """Warmer uses short retry interval when no sources were warmed.""" + + @pytest.mark.asyncio + async def test_warmer_retries_quickly_on_no_data(self): + """When no cached home data exists, warmer sleeps 60s not full TTL.""" + from core.tasks import warm_genre_cache_periodically + + home_svc = AsyncMock() + home_svc.get_cached_home_data = AsyncMock(return_value=None) + home_svc._genre._get_genre_section_ttl = MagicMock(return_value=21600) + + sleep_values: list[int | float] = [] + call_count = 0 + + original_sleep = asyncio.sleep + + async def mock_sleep(seconds): + nonlocal call_count + sleep_values.append(seconds) + call_count += 1 + if call_count >= 2: + raise asyncio.CancelledError + return await original_sleep(0) + + with patch("core.tasks.asyncio.sleep", side_effect=mock_sleep): + try: + await warm_genre_cache_periodically(home_svc, interval=21600) + except asyncio.CancelledError: + pass + + assert len(sleep_values) >= 2 + assert sleep_values[1] == 60 diff --git a/backend/tests/services/test_home_service.py b/backend/tests/services/test_home_service.py new file mode 100644 index 0000000..916d8ee --- /dev/null +++ b/backend/tests/services/test_home_service.py @@ -0,0 +1,293 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from api.v1.schemas.library import LibraryAlbum +from repositories.protocols import ListenBrainzReleaseGroup +from services.home_service import HomeService + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + lb_settings = ListenBrainzConnectionSettings( + user_token="tok", username="lbuser", enabled=lb_enabled + ) + prefs.get_listenbrainz_connection.return_value = lb_settings + + lfm_settings = LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk", + username="lfmuser", + enabled=lfm_enabled, + ) + prefs.get_lastfm_connection.return_value = lfm_settings + prefs.is_lastfm_enabled.return_value = lfm_enabled + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source) + + jf_settings = MagicMock() + jf_settings.enabled = False + jf_settings.jellyfin_url = "" + jf_settings.api_key = "" + prefs.get_jellyfin_connection.return_value = jf_settings + + lidarr = MagicMock() + lidarr.lidarr_url = "" + lidarr.lidarr_api_key = "" + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = False + yt.api_key = "" + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = False + lf.music_path = "" + prefs.get_local_files_connection.return_value = lf + + return prefs + + +def _make_service( + lb_enabled: bool = True, + lfm_enabled: bool = True, + primary_source: str = "listenbrainz", +) -> tuple[HomeService, AsyncMock, AsyncMock, MagicMock]: + lb_repo = AsyncMock() + lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[]) + lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[]) + lb_repo.get_user_listens = AsyncMock(return_value=[]) + lb_repo.get_user_loved_recordings = AsyncMock(return_value=[]) + lb_repo.get_user_genre_activity = AsyncMock(return_value=None) + lb_repo.get_recommendation_playlists = AsyncMock(return_value=[]) + lb_repo.get_playlist_tracks = AsyncMock(return_value=None) + lb_repo.configure = MagicMock() + + lfm_repo = AsyncMock() + lfm_repo.get_global_top_artists = AsyncMock(return_value=[]) + lfm_repo.get_user_top_albums = AsyncMock(return_value=[]) + lfm_repo.get_user_recent_tracks = AsyncMock(return_value=[]) + lfm_repo.get_user_loved_tracks = AsyncMock(return_value=[]) + + jf_repo = AsyncMock() + lidarr_repo = AsyncMock() + lidarr_repo.get_library = AsyncMock(return_value=[]) + lidarr_repo.get_artists_from_library = AsyncMock(return_value=[]) + lidarr_repo.get_recently_imported = AsyncMock(return_value=[]) + mb_repo = AsyncMock() + + prefs = _make_prefs( + lb_enabled=lb_enabled, + lfm_enabled=lfm_enabled, + primary_source=primary_source, + ) + + service = HomeService( + listenbrainz_repo=lb_repo, + jellyfin_repo=jf_repo, + lidarr_repo=lidarr_repo, + musicbrainz_repo=mb_repo, + preferences_service=prefs, + lastfm_repo=lfm_repo, + ) + return service, lb_repo, lfm_repo, prefs + + +class TestHomeServiceResolveSource: + def test_explicit_source_overrides_global(self): + service, _, _, _ = _make_service(primary_source="lastfm") + assert service._resolve_source("listenbrainz") == "listenbrainz" + + def test_none_uses_global_setting(self): + service, _, _, _ = _make_service(primary_source="lastfm") + assert service._resolve_source(None) == "lastfm" + + +class TestHomeServiceSourceSelection: + @pytest.mark.asyncio + async def test_lb_trending_called_when_source_is_lb(self): + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + await service.get_home_data("listenbrainz") + lb_repo.get_sitewide_top_artists.assert_awaited_once() + lfm_repo.get_global_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lfm_trending_called_when_source_is_lastfm(self): + service, lb_repo, lfm_repo, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + await service.get_home_data("lastfm") + lfm_repo.get_global_top_artists.assert_awaited_once() + lb_repo.get_sitewide_top_artists.assert_not_awaited() + + @pytest.mark.asyncio + async def test_source_field_in_response(self): + service, _, _, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + response = await service.get_home_data("listenbrainz") + assert response.integration_status is not None + assert response.integration_status.lastfm is True + assert response.integration_status.listenbrainz is True + + @pytest.mark.asyncio + async def test_popular_album_in_library_uses_album_mbids_not_artist_mbids(self): + service, lb_repo, _, prefs = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + lidarr_conn = MagicMock() + lidarr_conn.lidarr_url = "http://lidarr.local" + lidarr_conn.lidarr_api_key = "apikey" + prefs.get_lidarr_connection.return_value = lidarr_conn + service._lidarr_repo.get_library.return_value = [ + LibraryAlbum( + artist="Artist", + album="Album", + monitored=True, + musicbrainz_id="rg-123", + artist_mbid="artist-123", + ) + ] + service._lidarr_repo.get_artists_from_library.return_value = [{"mbid": "artist-123"}] + lb_repo.get_sitewide_top_release_groups.return_value = [ + ListenBrainzReleaseGroup( + release_group_name="Album", + artist_name="Artist", + listen_count=99, + release_group_mbid="rg-123", + artist_mbids=["artist-other"], + ) + ] + + response = await service.get_home_data("listenbrainz") + + assert response.popular_albums is not None + assert len(response.popular_albums.items) == 1 + assert response.popular_albums.items[0].in_library is True + + @pytest.mark.asyncio + async def test_lastfm_source_skips_user_top_albums_when_username_missing(self): + service, _, lfm_repo, prefs = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + prefs.get_lastfm_connection.return_value = LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk", + username="", + enabled=True, + ) + + await service.get_home_data("lastfm") + + lfm_repo.get_global_top_artists.assert_awaited_once() + lfm_repo.get_user_top_albums.assert_not_awaited() + + @pytest.mark.asyncio + async def test_listenbrainz_source_includes_weekly_exploration(self): + service, lb_repo, _, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="listenbrainz" + ) + lb_repo.get_recommendation_playlists.return_value = [ + { + "playlist_id": "weekly-123", + "source_patch": "weekly-exploration", + "identifier": "https://listenbrainz.org/playlist/weekly-123", + } + ] + lb_repo.get_playlist_tracks.return_value = MagicMock( + title="Weekly Exploration for lbuser", + date="2026-03-30T00:00:00+00:00", + tracks=[ + MagicMock( + title="Song", + creator="Artist", + album="Album", + recording_mbid="recording-1", + artist_mbids=["artist-1"], + caa_release_mbid="release-1", + duration_ms=123000, + ) + ], + ) + service._mb_repo.get_release_group_id_from_release.return_value = "release-group-1" + + response = await service.get_home_data("listenbrainz") + + assert response.weekly_exploration is not None + assert response.weekly_exploration.source_url == "https://listenbrainz.org/playlist/weekly-123" + assert len(response.weekly_exploration.tracks) == 1 + assert response.weekly_exploration.tracks[0].release_group_mbid == "release-group-1" + + @pytest.mark.asyncio + async def test_lastfm_source_skips_weekly_exploration(self): + service, lb_repo, _, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + + response = await service.get_home_data("lastfm") + + assert response.weekly_exploration is None + lb_repo.get_recommendation_playlists.assert_not_awaited() + + +class TestHomeServiceCacheKeySourceAware: + def test_different_sources_produce_different_keys(self): + service, _, _, _ = _make_service() + key_lb = service._get_home_cache_key("listenbrainz") + key_lfm = service._get_home_cache_key("lastfm") + assert key_lb != key_lfm + + +class TestBuildServicePrompts: + def test_source_prompts_hidden_when_one_source_enabled(self): + service, _, _, _ = _make_service() + prompts = service._build_service_prompts( + lb_enabled=True, lidarr_configured=True, lfm_enabled=False + ) + services = [p.service for p in prompts] + assert "lastfm" not in services + assert "listenbrainz" not in services + + def test_source_prompts_hidden_when_lastfm_enabled(self): + service, _, _, _ = _make_service() + prompts = service._build_service_prompts( + lb_enabled=False, lidarr_configured=True, lfm_enabled=True + ) + services = [p.service for p in prompts] + assert "listenbrainz" not in services + assert "lastfm" not in services + + def test_no_prompts_when_all_enabled(self): + service, _, _, _ = _make_service() + prompts = service._build_service_prompts( + lb_enabled=True, lidarr_configured=True, lfm_enabled=True + ) + assert prompts == [] + + def test_all_prompts_when_nothing_enabled(self): + service, _, _, _ = _make_service() + prompts = service._build_service_prompts( + lb_enabled=False, lidarr_configured=False, lfm_enabled=False + ) + services = {p.service for p in prompts} + assert services == {"lidarr-connection", "listenbrainz", "lastfm"} + + def test_lb_prompt_mentions_lastfm(self): + service, _, _, _ = _make_service() + prompts = service._build_service_prompts( + lb_enabled=False, lidarr_configured=True, lfm_enabled=False + ) + lb_prompt = next(p for p in prompts if p.service == "listenbrainz") + assert "last.fm" in lb_prompt.description.lower() diff --git a/backend/tests/services/test_jellyfin_playback_service.py b/backend/tests/services/test_jellyfin_playback_service.py new file mode 100644 index 0000000..a836131 --- /dev/null +++ b/backend/tests/services/test_jellyfin_playback_service.py @@ -0,0 +1,109 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from core.exceptions import ExternalServiceError, PlaybackNotAllowedError +from infrastructure.constants import JELLYFIN_TICKS_PER_SECOND +from services.jellyfin_playback_service import ( + JellyfinPlaybackService, +) + + +def _make_repo() -> AsyncMock: + repo = AsyncMock() + repo.get_playback_info = AsyncMock( + return_value={"PlaySessionId": "sess-123"} + ) + repo.report_playback_start = AsyncMock() + repo.report_playback_progress = AsyncMock() + repo.report_playback_stopped = AsyncMock() + return repo + + +@pytest.fixture +def service(): + repo = _make_repo() + svc = JellyfinPlaybackService(jellyfin_repo=repo) + return svc, repo + + +@pytest.mark.asyncio +async def test_start_playback_returns_session_id(service): + svc, repo = service + result = await svc.start_playback("item-1") + assert result == "sess-123" + repo.report_playback_start.assert_called_once_with("item-1", "sess-123") + + +@pytest.mark.asyncio +async def test_start_playback_raises_on_error_code(service): + svc, repo = service + repo.get_playback_info.return_value = {"ErrorCode": "NotAllowed"} + + with pytest.raises(PlaybackNotAllowedError, match="NotAllowed"): + await svc.start_playback("item-1") + + +@pytest.mark.asyncio +async def test_start_playback_handles_null_session_id(service): + svc, repo = service + repo.get_playback_info.return_value = {"PlaySessionId": None} + + result = await svc.start_playback("item-1") + assert result == "" + + +@pytest.mark.asyncio +async def test_report_progress_sends_to_jellyfin(service): + svc, repo = service + await svc.report_progress("item-1", "sess-123", 5.0, False) + expected_ticks = int(5.0 * JELLYFIN_TICKS_PER_SECOND) + repo.report_playback_progress.assert_called_once_with( + "item-1", "sess-123", expected_ticks, False + ) + + +@pytest.mark.asyncio +async def test_report_progress_skips_empty_session(service): + svc, repo = service + await svc.report_progress("item-1", "", 5.0, False) + repo.report_playback_progress.assert_not_called() + + +@pytest.mark.asyncio +async def test_report_progress_handles_http_failure(service): + svc, repo = service + repo.report_playback_progress.side_effect = httpx.ConnectError("network error") + await svc.report_progress("item-1", "sess-123", 5.0, False) + + +@pytest.mark.asyncio +async def test_report_progress_handles_external_service_failure(service): + svc, repo = service + repo.report_playback_progress.side_effect = ExternalServiceError("server error") + await svc.report_progress("item-1", "sess-123", 5.0, False) + + +@pytest.mark.asyncio +async def test_stop_playback_sends_to_jellyfin(service): + svc, repo = service + await svc.stop_playback("item-1", "sess-123", 10.0) + expected_ticks = int(10.0 * JELLYFIN_TICKS_PER_SECOND) + repo.report_playback_stopped.assert_called_once_with( + "item-1", "sess-123", expected_ticks + ) + + +@pytest.mark.asyncio +async def test_stop_playback_skips_empty_session(service): + svc, repo = service + await svc.stop_playback("item-1", "", 10.0) + repo.report_playback_stopped.assert_not_called() + + +@pytest.mark.asyncio +async def test_stop_playback_handles_failure(service): + svc, repo = service + repo.report_playback_stopped.side_effect = httpx.ConnectError("timeout") + await svc.stop_playback("item-1", "sess-123", 10.0) diff --git a/backend/tests/services/test_lastfm_auth_service.py b/backend/tests/services/test_lastfm_auth_service.py new file mode 100644 index 0000000..7b31d07 --- /dev/null +++ b/backend/tests/services/test_lastfm_auth_service.py @@ -0,0 +1,97 @@ +import time + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from core.exceptions import ConfigurationError +from repositories.lastfm_models import LastFmSession, LastFmToken +from services.lastfm_auth_service import ( + LastFmAuthService, + MAX_PENDING_TOKENS, + TOKEN_TTL_SECONDS, +) + + +def _make_repo() -> AsyncMock: + repo = AsyncMock() + repo.get_token = AsyncMock(return_value=LastFmToken(token="test-token-abc")) + repo.get_session = AsyncMock( + return_value=LastFmSession(name="testuser", key="sk-123", subscriber=0) + ) + return repo + + +@pytest.fixture +def service(): + repo = _make_repo() + svc = LastFmAuthService(lastfm_repo=repo) + return svc, repo + + +@pytest.mark.asyncio +async def test_request_token_returns_token_and_auth_url(service): + svc, repo = service + token, auth_url = await svc.request_token("my-api-key") + assert token == "test-token-abc" + assert "my-api-key" in auth_url + assert "test-token-abc" in auth_url + repo.get_token.assert_called_once() + + +@pytest.mark.asyncio +async def test_request_token_stores_in_pending(service): + svc, _repo = service + token, _ = await svc.request_token("key") + assert token in svc._pending_tokens + + +@pytest.mark.asyncio +async def test_exchange_session_returns_username_and_key(service): + svc, _repo = service + token, _ = await svc.request_token("key") + username, session_key, _ = await svc.exchange_session(token) + assert username == "testuser" + assert session_key == "sk-123" + + +@pytest.mark.asyncio +async def test_exchange_session_removes_from_pending(service): + svc, _repo = service + token, _ = await svc.request_token("key") + await svc.exchange_session(token) + assert token not in svc._pending_tokens + + +@pytest.mark.asyncio +async def test_exchange_session_rejects_unknown_token(service): + svc, _repo = service + with pytest.raises(ConfigurationError, match="expired or not recognized"): + await svc.exchange_session("never-requested-token") + + +@pytest.mark.asyncio +async def test_expired_tokens_are_evicted(service): + svc, _repo = service + token, _ = await svc.request_token("key") + svc._pending_tokens[token].created_at = time.time() - TOKEN_TTL_SECONDS - 1 + + with pytest.raises(ConfigurationError, match="expired or not recognized"): + await svc.exchange_session(token) + + +@pytest.mark.asyncio +async def test_max_pending_tokens_evicts_oldest(service): + svc, repo = service + tokens = [] + for i in range(MAX_PENDING_TOKENS): + repo.get_token.return_value = LastFmToken(token=f"tok-{i}") + tok, _ = await svc.request_token("key") + tokens.append(tok) + + assert len(svc._pending_tokens) == MAX_PENDING_TOKENS + + repo.get_token.return_value = LastFmToken(token="tok-new") + await svc.request_token("key") + assert len(svc._pending_tokens) == MAX_PENDING_TOKENS + assert tokens[0] not in svc._pending_tokens + assert "tok-new" in svc._pending_tokens diff --git a/backend/tests/services/test_lastfm_repository.py b/backend/tests/services/test_lastfm_repository.py new file mode 100644 index 0000000..66fc17e --- /dev/null +++ b/backend/tests/services/test_lastfm_repository.py @@ -0,0 +1,741 @@ +import hashlib + +import pytest +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from core.exceptions import ConfigurationError, ExternalServiceError +from repositories.lastfm_repository import LastFmRepository, LASTFM_ERROR_MAP + + +def _make_cache() -> AsyncMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return cache + + +def _make_repo( + api_key: str = "key", + shared_secret: str = "secret", + session_key: str = "", + cache: AsyncMock | None = None, +) -> LastFmRepository: + http_client = AsyncMock(spec=httpx.AsyncClient) + return LastFmRepository( + http_client=http_client, + cache=cache or _make_cache(), + api_key=api_key, + shared_secret=shared_secret, + session_key=session_key, + ) + + +class TestBuildApiSig: + def test_basic_signature(self): + repo = _make_repo(shared_secret="mysecret") + params = {"method": "auth.getToken", "api_key": "mykey"} + sig = repo._build_api_sig(params) + expected_str = "api_keymykeymethodauth.getTokenmysecret" + expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest() + assert sig == expected + + def test_excludes_format_and_callback(self): + repo = _make_repo(shared_secret="sec") + params = { + "method": "test", + "api_key": "k", + "format": "json", + "callback": "cb", + } + sig = repo._build_api_sig(params) + expected_str = "api_keykmethodtestsec" + expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest() + assert sig == expected + + def test_params_sorted_alphabetically(self): + repo = _make_repo(shared_secret="s") + params = {"z_param": "z", "a_param": "a"} + sig = repo._build_api_sig(params) + expected_str = "a_paramaz_paramzs" + expected = hashlib.md5(expected_str.encode("utf-8")).hexdigest() + assert sig == expected + + +class TestHandleErrorResponse: + def test_no_error_returns_none(self): + repo = _make_repo() + repo._handle_error_response({"artist": "test"}) + + def test_invalid_api_key_raises_configuration_error(self): + repo = _make_repo() + with pytest.raises(ConfigurationError, match="(?i)invalid api key"): + repo._handle_error_response({"error": 10, "message": "bad key"}) + + def test_session_expired_raises_configuration_error(self): + repo = _make_repo() + with pytest.raises(ConfigurationError, match="Session key expired"): + repo._handle_error_response({"error": 9, "message": "expired"}) + + def test_service_offline_raises_external_error(self): + repo = _make_repo() + with pytest.raises(ExternalServiceError, match="temporarily offline"): + repo._handle_error_response({"error": 11, "message": "offline"}) + + def test_unknown_error_code_raises_external_error(self): + repo = _make_repo() + with pytest.raises(ExternalServiceError, match="Last.fm error \\(999\\)"): + repo._handle_error_response({"error": 999, "message": "weird"}) + + +class TestConfigureMethod: + def test_configure_updates_credentials(self): + repo = _make_repo(api_key="old", shared_secret="old") + repo.configure(api_key="new-key", shared_secret="new-secret", session_key="sk-1") + assert repo._api_key == "new-key" + assert repo._shared_secret == "new-secret" + assert repo._session_key == "sk-1" + + +class TestConstructorDefaults: + def test_default_empty_strings(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + repo = LastFmRepository(http_client=http_client, cache=_make_cache()) + assert repo._api_key == "" + assert repo._shared_secret == "" + assert repo._session_key == "" + + +class TestUpdateNowPlaying: + @pytest.mark.asyncio + async def test_posts_with_required_params(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + result = await repo.update_now_playing(artist="Artist", track="Track") + assert result is True + call_args = http_client.post.call_args + posted_data = call_args.kwargs.get("data", call_args.args[1] if len(call_args.args) > 1 else {}) + assert posted_data["method"] == "track.updateNowPlaying" + assert posted_data["artist"] == "Artist" + assert posted_data["track"] == "Track" + assert "api_sig" in posted_data + + @pytest.mark.asyncio + async def test_includes_optional_params(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + await repo.update_now_playing( + artist="A", track="T", album="Album", duration=300, mbid="mb-123" + ) + posted_data = http_client.post.call_args.kwargs.get( + "data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {} + ) + assert posted_data["album"] == "Album" + assert posted_data["duration"] == "300" + assert posted_data["mbid"] == "mb-123" + + @pytest.mark.asyncio + async def test_omits_empty_optional_params(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + await repo.update_now_playing(artist="A", track="T") + posted_data = http_client.post.call_args.kwargs.get( + "data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {} + ) + assert "album" not in posted_data + assert "duration" not in posted_data + assert "mbid" not in posted_data + + @pytest.mark.asyncio + async def test_signature_includes_session_key(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"nowplaying": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + await repo.update_now_playing(artist="A", track="T") + posted_data = http_client.post.call_args.kwargs.get( + "data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {} + ) + assert posted_data["sk"] == "sk-1" + + +class TestScrobble: + @pytest.mark.asyncio + async def test_posts_with_timestamp(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"scrobbles": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + result = await repo.scrobble(artist="Artist", track="Track", timestamp=1700000000) + assert result is True + posted_data = http_client.post.call_args.kwargs.get( + "data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {} + ) + assert posted_data["method"] == "track.scrobble" + assert posted_data["artist"] == "Artist" + assert posted_data["track"] == "Track" + assert posted_data["timestamp"] == "1700000000" + assert "api_sig" in posted_data + + @pytest.mark.asyncio + async def test_includes_album_and_duration(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock(status_code=200, json=lambda: {"scrobbles": {}}, text="") + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + await repo.scrobble( + artist="A", track="T", timestamp=1700000000, album="Alb", duration=200 + ) + posted_data = http_client.post.call_args.kwargs.get( + "data", http_client.post.call_args.args[1] if len(http_client.post.call_args.args) > 1 else {} + ) + assert posted_data["album"] == "Alb" + assert posted_data["duration"] == "200" + + @pytest.mark.asyncio + async def test_raises_on_api_error(self): + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.post = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: {"error": 9, "message": "session expired"}, + text="", + ) + ) + repo = LastFmRepository( + http_client=http_client, + cache=_make_cache(), + api_key="key", + shared_secret="secret", + session_key="sk-1", + ) + with pytest.raises(ConfigurationError, match="Session key expired"): + await repo.scrobble(artist="A", track="T", timestamp=1700000000) + + +class TestGetUserTopArtists: + @pytest.mark.asyncio + async def test_parses_response_and_caches(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "topartists": { + "artist": [ + {"name": "Radiohead", "mbid": "a74b1b7f", "playcount": "100"}, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_top_artists("user1", period="7day", limit=5) + assert len(result) == 1 + assert result[0].name == "Radiohead" + assert result[0].playcount == 100 + cache.set.assert_called_once() + + @pytest.mark.asyncio + async def test_returns_cached_data(self): + from repositories.lastfm_models import LastFmArtist + + cached_data = [LastFmArtist(name="Cached", playcount=50)] + cache = _make_cache() + cache.get = AsyncMock(return_value=cached_data) + repo = _make_repo(cache=cache) + result = await repo.get_user_top_artists("user1") + assert result[0].name == "Cached" + assert repo._client.get.call_count == 0 + + @pytest.mark.asyncio + async def test_invalid_period_defaults_to_overall(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: {"topartists": {"artist": []}}, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + await repo.get_user_top_artists("user1", period="invalid") + call_params = http_client.get.call_args.kwargs.get("params", {}) + assert call_params.get("period") == "overall" + + +class TestGetSimilarArtists: + @pytest.mark.asyncio + async def test_parses_by_name(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "similarartists": { + "artist": [ + {"name": "Muse", "mbid": "abc", "match": "0.9"}, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_similar_artists("Radiohead", limit=5) + assert len(result) == 1 + assert result[0].name == "Muse" + assert result[0].match == pytest.approx(0.9) + + @pytest.mark.asyncio + async def test_uses_mbid_when_provided(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: {"similarartists": {"artist": []}}, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + await repo.get_similar_artists("Radiohead", mbid="abc-123", limit=5) + call_params = http_client.get.call_args.kwargs.get("params", {}) + assert call_params.get("mbid") == "abc-123" + assert "artist" not in call_params + + +class TestGetUserWeeklyAlbumChart: + @pytest.mark.asyncio + async def test_parses_weekly_album_chart(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "weeklyalbumchart": { + "album": [ + { + "name": "OK Computer", + "artist": {"#text": "Radiohead"}, + "mbid": "rg-1", + "playcount": "12", + }, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_weekly_album_chart("user1") + assert len(result) == 1 + assert result[0].name == "OK Computer" + assert result[0].artist_name == "Radiohead" + assert result[0].playcount == 12 + + +class TestGetUserTopAlbums: + @pytest.mark.asyncio + async def test_parses_albums(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "topalbums": { + "album": [ + { + "name": "OK Computer", + "artist": {"name": "Radiohead"}, + "mbid": "a1", + "playcount": "55", + "listeners": "200", + }, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_top_albums("user1", period="3month", limit=5) + assert len(result) == 1 + assert result[0].name == "OK Computer" + assert result[0].artist_name == "Radiohead" + assert result[0].playcount == 55 + cache.set.assert_called_once() + + +class TestGetUserTopTracks: + @pytest.mark.asyncio + async def test_parses_tracks(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "toptracks": { + "track": [ + { + "name": "Karma Police", + "artist": {"name": "Radiohead"}, + "mbid": "t1", + "playcount": "30", + }, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_top_tracks("user1", period="1month", limit=10) + assert len(result) == 1 + assert result[0].name == "Karma Police" + assert result[0].artist_name == "Radiohead" + cache.set.assert_called_once() + + +class TestGetUserRecentTracks: + @pytest.mark.asyncio + async def test_parses_recent_tracks(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "recenttracks": { + "track": [ + { + "name": "Everything In Its Right Place", + "artist": {"#text": "Radiohead", "mbid": "a1"}, + "album": {"#text": "Kid A", "mbid": "al1"}, + "date": {"uts": "1700000000"}, + }, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_recent_tracks("user1", limit=10) + assert len(result) == 1 + assert result[0].track_name == "Everything In Its Right Place" + assert result[0].artist_name == "Radiohead" + assert result[0].album_name == "Kid A" + assert result[0].timestamp == 1700000000 + + @pytest.mark.asyncio + async def test_now_playing_flag(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "recenttracks": { + "track": [ + { + "name": "NOW", + "artist": {"#text": "Band"}, + "album": {"#text": "Album"}, + "@attr": {"nowplaying": "true"}, + }, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_recent_tracks("user1") + assert result[0].now_playing is True + + +class TestGetUserWeeklyArtistChart: + @pytest.mark.asyncio + async def test_parses_weekly_artist_chart(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "weeklyartistchart": { + "artist": [ + {"name": "Radiohead", "playcount": "25", "mbid": "a1"}, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_user_weekly_artist_chart("user1") + assert len(result) == 1 + assert result[0].name == "Radiohead" + assert result[0].playcount == 25 + + +class TestGetGlobalTopTracks: + @pytest.mark.asyncio + async def test_parses_global_tracks(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "toptracks": { + "track": [ + {"name": "Blinding Lights", "artist": {"name": "The Weeknd"}, "playcount": "9999"}, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_global_top_tracks(limit=10) + assert len(result) == 1 + assert result[0].name == "Blinding Lights" + assert result[0].artist_name == "The Weeknd" + + +class TestGetTagTopArtists: + @pytest.mark.asyncio + async def test_parses_tag_artists(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "topartists": { + "artist": [ + {"name": "Pink Floyd", "mbid": "pf1", "playcount": "500"}, + ] + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_tag_top_artists("progressive rock", limit=10) + assert len(result) == 1 + assert result[0].name == "Pink Floyd" + + +class TestGetAlbumInfo: + @pytest.mark.asyncio + async def test_parses_album_info_with_tracks(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "album": { + "name": "OK Computer", + "artist": "Radiohead", + "mbid": "al1", + "listeners": "3000", + "playcount": "50000", + "url": "https://last.fm/album", + "wiki": {"summary": "A classic album."}, + "tags": {"tag": [{"name": "rock", "url": "https://last.fm/tag/rock"}]}, + "tracks": { + "track": [ + {"name": "Airbag", "duration": "283", "@attr": {"rank": "1"}, "url": "https://last.fm/track"}, + {"name": "Paranoid Android", "duration": "390", "@attr": {"rank": "2"}, "url": ""}, + ] + }, + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_album_info("Radiohead", "OK Computer") + assert result is not None + assert result.name == "OK Computer" + assert result.artist_name == "Radiohead" + assert result.summary == "A classic album." + assert result.listeners == 3000 + assert len(result.tags) == 1 + assert result.tags[0].name == "rock" + assert len(result.tracks) == 2 + assert result.tracks[0].name == "Airbag" + assert result.tracks[0].duration == 283 + assert result.tracks[0].rank == 1 + assert result.tracks[1].name == "Paranoid Android" + + @pytest.mark.asyncio + async def test_not_found_returns_none(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: {"error": 6, "message": "Album not found"}, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_album_info("X", "Y") + assert result is None + + +class TestGetArtistInfoNotFound: + @pytest.mark.asyncio + async def test_not_found_returns_none(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: {"error": 6, "message": "Artist not found"}, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_artist_info("Nonexistent Artist") + assert result is None + + +class TestGetArtistInfoParsing: + @pytest.mark.asyncio + async def test_parses_full_artist_info(self): + cache = _make_cache() + http_client = AsyncMock(spec=httpx.AsyncClient) + http_client.get = AsyncMock( + return_value=MagicMock( + status_code=200, + json=lambda: { + "artist": { + "name": "Radiohead", + "mbid": "a1", + "stats": {"listeners": "5000", "playcount": "80000"}, + "url": "https://last.fm/artist", + "bio": {"summary": "English rock band."}, + "tags": {"tag": [{"name": "alternative", "url": "https://last.fm/tag/alt"}]}, + "similar": { + "artist": [ + {"name": "Muse", "mbid": "m1", "match": "0.85"}, + ] + }, + } + }, + text="", + ) + ) + repo = LastFmRepository(http_client=http_client, cache=cache, api_key="k") + result = await repo.get_artist_info("Radiohead") + assert result is not None + assert result.name == "Radiohead" + assert result.listeners == 5000 + assert result.playcount == 80000 + assert result.bio_summary == "English rock band." + assert len(result.tags) == 1 + assert result.tags[0].name == "alternative" + assert len(result.similar) == 1 + assert result.similar[0].name == "Muse" + assert result.similar[0].match == pytest.approx(0.85) + cache.set.assert_called_once() + + +class TestCacheBehavior: + @pytest.mark.asyncio + async def test_returns_cached_global_top_artists(self): + from repositories.lastfm_models import LastFmArtist + + cached_data = [LastFmArtist(name="Cached", playcount=10)] + cache = _make_cache() + cache.get = AsyncMock(return_value=cached_data) + repo = _make_repo(cache=cache) + result = await repo.get_global_top_artists(limit=5) + assert result[0].name == "Cached" + assert repo._client.get.call_count == 0 + + @pytest.mark.asyncio + async def test_returns_cached_tag_top_artists(self): + from repositories.lastfm_models import LastFmArtist + + cached_data = [LastFmArtist(name="TagCached", playcount=20)] + cache = _make_cache() + cache.get = AsyncMock(return_value=cached_data) + repo = _make_repo(cache=cache) + result = await repo.get_tag_top_artists("rock", limit=5) + assert result[0].name == "TagCached" + assert repo._client.get.call_count == 0 + + @pytest.mark.asyncio + async def test_returns_cached_album_info(self): + from repositories.lastfm_models import LastFmAlbumInfo + + cached_data = LastFmAlbumInfo(name="Cached Album", artist_name="A") + cache = _make_cache() + cache.get = AsyncMock(return_value=cached_data) + repo = _make_repo(cache=cache) + result = await repo.get_album_info("A", "Cached Album") + assert result.name == "Cached Album" + assert repo._client.get.call_count == 0 diff --git a/backend/tests/services/test_library_service.py b/backend/tests/services/test_library_service.py new file mode 100644 index 0000000..68ee5f5 --- /dev/null +++ b/backend/tests/services/test_library_service.py @@ -0,0 +1,49 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.library import LibraryGroupedAlbum, LibraryGroupedArtist +from core.exceptions import ExternalServiceError +from services.library_service import LibraryService + + +def _make_service() -> tuple[LibraryService, MagicMock]: + lidarr_repo = MagicMock() + library_db = MagicMock() + cover_repo = MagicMock() + preferences_service = MagicMock() + + service = LibraryService( + lidarr_repo=lidarr_repo, + library_db=library_db, + cover_repo=cover_repo, + preferences_service=preferences_service, + ) + return service, lidarr_repo + + +@pytest.mark.asyncio +async def test_get_library_grouped_returns_typed_data(): + service, lidarr_repo = _make_service() + expected = [ + LibraryGroupedArtist( + artist="Artist A", + albums=[LibraryGroupedAlbum(title="Album A", year=2024, monitored=True)], + ) + ] + lidarr_repo.get_library_grouped = AsyncMock(return_value=expected) + + grouped = await service.get_library_grouped() + + assert len(grouped) == 1 + assert grouped[0].artist == "Artist A" + assert grouped[0].albums[0].title == "Album A" + + +@pytest.mark.asyncio +async def test_get_library_grouped_wraps_errors(): + service, lidarr_repo = _make_service() + lidarr_repo.get_library_grouped = AsyncMock(side_effect=RuntimeError("unavailable")) + + with pytest.raises(ExternalServiceError): + await service.get_library_grouped() diff --git a/backend/tests/services/test_library_track_resolution.py b/backend/tests/services/test_library_track_resolution.py new file mode 100644 index 0000000..bcb76ed --- /dev/null +++ b/backend/tests/services/test_library_track_resolution.py @@ -0,0 +1,108 @@ +"""Tests for LibraryService._resolve_album_tracks and resolve_tracks_batch.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api.v1.schemas.library import TrackResolveRequest, TrackResolveResponse, ResolvedTrack +from services.library_service import LibraryService + + +def _item(mbid: str, disc: int = 1, track: int = 1): + """Shorthand to build a TrackResolveRequest.Item-like object.""" + from api.v1.schemas.library import TrackResolveItem + return TrackResolveItem(release_group_mbid=mbid, disc_number=disc, track_number=track) + + +def _make_service( + *, + local_service=None, + jf_service=None, + nd_service=None, + preferences=None, + cache=None, +): + lidarr_repo = MagicMock() + library_db = MagicMock() + memory_cache = cache or MagicMock() + disk_cache = MagicMock() + prefs = preferences or MagicMock() + audiodb_image_service = MagicMock() + cover_repo = MagicMock() + + service = LibraryService( + lidarr_repo=lidarr_repo, + library_db=library_db, + cover_repo=cover_repo, + preferences_service=prefs, + memory_cache=memory_cache, + disk_cache=disk_cache, + audiodb_image_service=audiodb_image_service, + local_files_service=local_service, + jellyfin_library_service=jf_service, + navidrome_library_service=nd_service, + ) + return service + + +@pytest.mark.asyncio +async def test_resolve_tracks_batch_empty_items(): + service = _make_service() + result = await service.resolve_tracks_batch([]) + assert isinstance(result, TrackResolveResponse) + assert result.items == [] + + +@pytest.mark.asyncio +async def test_resolve_tracks_batch_respects_max_items(): + service = _make_service() + items = [_item(f"mbid-{i}", track=i) for i in range(60)] + + cache = MagicMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + service._memory_cache = cache + + local_service = MagicMock() + local_match = MagicMock() + local_match.found = False + local_match.tracks = [] + local_service.match_album_by_mbid = AsyncMock(return_value=local_match) + service._local_files_service = local_service + + prefs = MagicMock() + prefs.get_navidrome_connection_raw = MagicMock(side_effect=Exception("unconfigured")) + prefs.get_jellyfin_connection = MagicMock(side_effect=Exception("unconfigured")) + service._preferences_service = prefs + + result = await service.resolve_tracks_batch(items) + assert len(result.items) <= 50 + + +@pytest.mark.asyncio +async def test_resolve_tracks_batch_missing_mbid_returns_base(): + service = _make_service() + items = [_item("", disc=1, track=1)] + + result = await service.resolve_tracks_batch(items) + assert len(result.items) == 1 + assert result.items[0].source is None + assert result.items[0].stream_url is None + + +@pytest.mark.asyncio +async def test_resolve_tracks_batch_uses_cache_hit(): + cache = MagicMock() + cached_map = {"1:1": ("local", "file-123", "flac", 240.0)} + cache.get = AsyncMock(return_value=cached_map) + cache.set = AsyncMock() + + service = _make_service(cache=cache) + items = [_item("mbid-abc", disc=1, track=1)] + + result = await service.resolve_tracks_batch(items) + assert len(result.items) == 1 + assert result.items[0].source == "local" + assert result.items[0].stream_url == "/api/v1/stream/local/file-123" + assert result.items[0].format == "flac" + assert result.items[0].duration == 240.0 diff --git a/backend/tests/services/test_listenbrainz_repository.py b/backend/tests/services/test_listenbrainz_repository.py new file mode 100644 index 0000000..7a67694 --- /dev/null +++ b/backend/tests/services/test_listenbrainz_repository.py @@ -0,0 +1,158 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from core.exceptions import ExternalServiceError +from repositories.listenbrainz_repository import ListenBrainzRepository + + +def _make_repo(username: str = "user", user_token: str = "tok-abc") -> tuple[ListenBrainzRepository, AsyncMock]: + http_client = AsyncMock(spec=httpx.AsyncClient) + cache = MagicMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + repo = ListenBrainzRepository( + http_client=http_client, + cache=cache, + username=username, + user_token=user_token, + ) + return repo, http_client + + +def _ok_response(json_data=None): + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = json_data or {"status": "ok"} + resp.text = "" + return resp + + +class TestSubmitNowPlaying: + @pytest.mark.asyncio + async def test_posts_playing_now_payload(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + result = await repo.submit_now_playing( + artist_name="Artist", track_name="Track" + ) + assert result is True + call_args = http_client.request.call_args + assert call_args.args[0] == "POST" + assert "/1/submit-listens" in call_args.args[1] + payload = call_args.kwargs["json"] + assert payload["listen_type"] == "playing_now" + assert len(payload["payload"]) == 1 + track_meta = payload["payload"][0]["track_metadata"] + assert track_meta["artist_name"] == "Artist" + assert track_meta["track_name"] == "Track" + + @pytest.mark.asyncio + async def test_includes_release_name(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_now_playing( + artist_name="A", track_name="T", release_name="Album" + ) + payload = http_client.request.call_args.kwargs["json"] + assert payload["payload"][0]["track_metadata"]["release_name"] == "Album" + + @pytest.mark.asyncio + async def test_includes_duration_ms(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_now_playing( + artist_name="A", track_name="T", duration_ms=200000 + ) + payload = http_client.request.call_args.kwargs["json"] + additional = payload["payload"][0]["track_metadata"]["additional_info"] + assert additional["duration_ms"] == 200000 + + @pytest.mark.asyncio + async def test_omits_optional_when_empty(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_now_playing(artist_name="A", track_name="T") + track_meta = http_client.request.call_args.kwargs["json"]["payload"][0]["track_metadata"] + assert "release_name" not in track_meta + assert "additional_info" not in track_meta + + @pytest.mark.asyncio + async def test_sends_auth_header(self): + repo, http_client = _make_repo(user_token="my-token") + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_now_playing(artist_name="A", track_name="T") + headers = http_client.request.call_args.kwargs["headers"] + assert headers["Authorization"] == "Token my-token" + + @pytest.mark.asyncio + async def test_raises_without_token(self): + repo, http_client = _make_repo(user_token="") + with pytest.raises(ExternalServiceError, match="token required"): + await repo.submit_now_playing(artist_name="A", track_name="T") + + +class TestSubmitSingleListen: + @pytest.mark.asyncio + async def test_posts_single_listen_payload(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + result = await repo.submit_single_listen( + artist_name="Artist", + track_name="Track", + listened_at=1700000000, + ) + assert result is True + payload = http_client.request.call_args.kwargs["json"] + assert payload["listen_type"] == "single" + listen = payload["payload"][0] + assert listen["listened_at"] == 1700000000 + assert listen["track_metadata"]["artist_name"] == "Artist" + assert listen["track_metadata"]["track_name"] == "Track" + + @pytest.mark.asyncio + async def test_includes_release_and_duration(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_single_listen( + artist_name="A", + track_name="T", + listened_at=1700000000, + release_name="Album", + duration_ms=180000, + ) + track_meta = http_client.request.call_args.kwargs["json"]["payload"][0]["track_metadata"] + assert track_meta["release_name"] == "Album" + assert track_meta["additional_info"]["duration_ms"] == 180000 + + @pytest.mark.asyncio + async def test_omits_optional_when_empty(self): + repo, http_client = _make_repo() + http_client.request = AsyncMock(return_value=_ok_response()) + await repo.submit_single_listen( + artist_name="A", track_name="T", listened_at=1700000000 + ) + track_meta = http_client.request.call_args.kwargs["json"]["payload"][0]["track_metadata"] + assert "release_name" not in track_meta + assert "additional_info" not in track_meta + + @pytest.mark.asyncio + async def test_raises_without_token(self): + repo, http_client = _make_repo(user_token="") + with pytest.raises(ExternalServiceError, match="token required"): + await repo.submit_single_listen( + artist_name="A", track_name="T", listened_at=1700000000 + ) + + @pytest.mark.asyncio + async def test_raises_on_http_error(self): + repo, http_client = _make_repo() + error_resp = MagicMock() + error_resp.status_code = 500 + error_resp.text = "Internal Server Error" + http_client.request = AsyncMock(return_value=error_resp) + with pytest.raises(ExternalServiceError): + await repo.submit_single_listen( + artist_name="A", track_name="T", listened_at=1700000000 + ) diff --git a/backend/tests/services/test_local_files_service.py b/backend/tests/services/test_local_files_service.py new file mode 100644 index 0000000..f341853 --- /dev/null +++ b/backend/tests/services/test_local_files_service.py @@ -0,0 +1,296 @@ +import asyncio +import pytest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +from core.exceptions import ExternalServiceError, ResourceNotFoundError +from services.local_files_service import LocalFilesService, AUDIO_EXTENSIONS + + +def _make_mock_cache() -> AsyncMock: + cache = AsyncMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return cache + + +def _make_preferences(music_path: str = "/music", lidarr_root: str = "/music") -> MagicMock: + prefs = MagicMock() + settings = MagicMock() + settings.music_path = music_path + settings.lidarr_root_path = lidarr_root + prefs.get_local_files_connection.return_value = settings + advanced = MagicMock() + advanced.cache_ttl_local_files_recently_added = 120 + advanced.cache_ttl_local_files_storage_stats = 300 + prefs.get_advanced_settings.return_value = advanced + return prefs + + +@pytest.fixture +def service(tmp_path): + music_dir = tmp_path / "music" + music_dir.mkdir() + lidarr = AsyncMock() + prefs = _make_preferences(str(music_dir), str(music_dir)) + cache = _make_mock_cache() + svc = LocalFilesService( + lidarr_repo=lidarr, + preferences_service=prefs, + cache=cache, + ) + return svc, lidarr, music_dir, cache + + +@pytest.mark.asyncio +async def test_stream_track_validates_audio_format(service): + svc, lidarr, music_dir, cache = service + bad_file = music_dir / "test.txt" + bad_file.write_text("not audio") + + lidarr.get_track_file = AsyncMock(return_value={"path": str(bad_file)}) + + with pytest.raises(ExternalServiceError, match="Unsupported audio format"): + await svc.stream_track(1) + + +@pytest.mark.asyncio +async def test_stream_track_serves_valid_file(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "song.flac" + audio_file.write_bytes(b"fLaC" + b"\x00" * 100) + + lidarr.get_track_file = AsyncMock(return_value={"path": str(audio_file)}) + + chunks_iter, headers, status = await svc.stream_track(1) + assert status == 200 + assert headers["Content-Type"] == "audio/flac" + + collected = b"" + async for chunk in chunks_iter: + collected += chunk + assert len(collected) == 104 + + +@pytest.mark.asyncio +async def test_stream_track_handles_range_request(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "song.mp3" + audio_file.write_bytes(b"\xff\xfb" + b"\x00" * 998) + + lidarr.get_track_file = AsyncMock(return_value={"path": str(audio_file)}) + + chunks_iter, headers, status = await svc.stream_track( + 1, range_header="bytes=0-99" + ) + assert status == 206 + assert "Content-Range" in headers + + collected = b"" + async for chunk in chunks_iter: + collected += chunk + assert len(collected) == 100 + + +@pytest.mark.asyncio +async def test_stream_track_raises_on_missing_file(service): + svc, lidarr, music_dir, cache = service + lidarr.get_track_file = AsyncMock( + return_value={"path": str(music_dir / "nonexistent.flac")} + ) + + with pytest.raises(ResourceNotFoundError, match="not found"): + await svc.stream_track(1) + + +@pytest.mark.asyncio +async def test_stream_track_raises_on_path_traversal(service): + svc, lidarr, music_dir, cache = service + traversal_path = str(music_dir / ".." / ".." / "etc" / "passwd") + lidarr.get_track_file = AsyncMock( + return_value={"path": traversal_path} + ) + + with pytest.raises(PermissionError, match="outside music directory"): + await svc.stream_track(1) + + +@pytest.mark.asyncio +async def test_get_storage_stats_uses_cache(service): + svc, lidarr, music_dir, cache = service + cached_data = { + "total_tracks": 42, + "total_albums": 5, + "total_artists": 3, + "total_size_bytes": 1000000, + "total_size_human": "976.6 KB", + "disk_free_bytes": 500000000, + "disk_free_human": "476.8 MB", + "format_breakdown": {}, + } + cache.get = AsyncMock(return_value=cached_data) + + stats = await svc.get_storage_stats() + assert stats.total_tracks == 42 + cache.set.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_storage_stats_caches_result(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "artist" / "album" / "track.flac" + audio_file.parent.mkdir(parents=True) + audio_file.write_bytes(b"\x00" * 50) + + stats = await svc.get_storage_stats() + assert stats.total_tracks == 1 + assert cache.set.called + + +@pytest.mark.asyncio +async def test_get_albums_caches_lidarr_response(service): + svc, lidarr, music_dir, cache = service + lidarr.get_all_albums = AsyncMock(return_value=[ + { + "id": 1, + "title": "Test Album", + "artist": {"artistName": "Test Artist"}, + "statistics": {"trackFileCount": 3}, + "foreignAlbumId": "mbid-123", + "releaseDate": "2024-01-01", + } + ]) + + result = await svc.get_albums(limit=10, offset=0) + assert result.total == 1 + + assert cache.set.called + call_args = cache.set.call_args + assert call_args[0][0] == "local_files_all_albums" + + +@pytest.mark.asyncio +async def test_get_recently_added_uses_cache(service): + svc, lidarr, music_dir, cache = service + cache.get = AsyncMock(return_value=[ + { + "lidarr_album_id": 10, + "musicbrainz_id": "mbid-10", + "name": "Cached Album", + "artist_name": "Cached Artist", + "track_count": 12, + "total_size_bytes": 123456, + "artist_mbid": None, + "year": 2024, + "primary_format": "flac", + "cover_url": None, + "date_added": "2026-02-17T00:00:00Z", + } + ]) + lidarr.get_recently_imported = AsyncMock(return_value=[]) + + result = await svc.get_recently_added(limit=20) + + assert len(result) == 1 + assert result[0].name == "Cached Album" + lidarr.get_recently_imported.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_get_recently_added_caches_result(service): + svc, lidarr, music_dir, cache = service + cache.get = AsyncMock(return_value=None) + lidarr.get_recently_imported = AsyncMock(return_value=[ + SimpleNamespace( + musicbrainz_id="mbid-123", + album="Album From Lidarr", + artist="Artist From Lidarr", + artist_mbid=None, + year=2023, + cover_url=None, + date_added=None, + ) + ]) + lidarr.get_all_albums = AsyncMock(return_value=[ + { + "id": 123, + "title": "Album From Lidarr", + "artist": {"artistName": "Artist From Lidarr"}, + "statistics": {"trackFileCount": 7, "sizeOnDisk": 987654}, + "foreignAlbumId": "mbid-123", + "releaseDate": "2023-01-01", + "added": "2026-02-17T00:00:00Z", + } + ]) + + result = await svc.get_recently_added(limit=20) + + assert len(result) == 1 + assert result[0].lidarr_album_id == 123 + cache.set.assert_called() + cache_key = cache.set.call_args[0][0] + assert cache_key == "local_files_recently_added:20" + + +@pytest.mark.asyncio +async def test_stream_track_handles_suffix_range(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "song.mp3" + audio_file.write_bytes(b"\xff\xfb" + b"\x00" * 998) + + lidarr.get_track_file = AsyncMock(return_value={"path": str(audio_file)}) + + chunks_iter, headers, status = await svc.stream_track( + 1, range_header="bytes=-200" + ) + assert status == 206 + assert "Content-Range" in headers + + collected = b"" + async for chunk in chunks_iter: + collected += chunk + assert len(collected) == 200 + + +@pytest.mark.asyncio +async def test_stream_track_fallback_on_malformed_range(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "song.mp3" + audio_file.write_bytes(b"\xff\xfb" + b"\x00" * 998) + + lidarr.get_track_file = AsyncMock(return_value={"path": str(audio_file)}) + + chunks_iter, headers, status = await svc.stream_track( + 1, range_header="bytes=abc-xyz" + ) + assert status == 200 + assert int(headers["Content-Length"]) == 1000 + + +@pytest.mark.asyncio +async def test_stream_track_rejects_invalid_range(service): + svc, lidarr, music_dir, cache = service + audio_file = music_dir / "song.mp3" + audio_file.write_bytes(b"\xff\xfb" + b"\x00" * 98) + + lidarr.get_track_file = AsyncMock(return_value={"path": str(audio_file)}) + + with pytest.raises(ExternalServiceError, match="Range not satisfiable"): + await svc.stream_track(1, range_header="bytes=5000-6000") + + +@pytest.mark.asyncio +async def test_remap_path_uses_component_matching(tmp_path): + music_dir = tmp_path / "music" + music_dir.mkdir() + lidarr = AsyncMock() + prefs = _make_preferences(str(music_dir), "/data/music") + cache = _make_mock_cache() + svc = LocalFilesService( + lidarr_repo=lidarr, + preferences_service=prefs, + cache=cache, + ) + result = svc._remap_path("/data/music2/artist/album/song.flac") + assert "/data/music2" in str(result) or "music2" in result.parts diff --git a/backend/tests/services/test_musicbrainz_tag_query.py b/backend/tests/services/test_musicbrainz_tag_query.py new file mode 100644 index 0000000..35aafe4 --- /dev/null +++ b/backend/tests/services/test_musicbrainz_tag_query.py @@ -0,0 +1,30 @@ +from repositories.musicbrainz_base import build_musicbrainz_tag_query + + +def test_build_musicbrainz_tag_query_quotes_multiword_tag() -> None: + query = build_musicbrainz_tag_query('Hip Hop') + + assert 'tag:"hip hop"^3' in query + assert 'tag:"hip-hop"^2' in query + assert 'tag:hip hop' not in query + + +def test_build_musicbrainz_tag_query_includes_standard_ampersand_aliases() -> None: + query = build_musicbrainz_tag_query('R&B') + + assert 'tag:"r&b"^3' in query + assert 'tag:"r and b"^2' in query + assert 'tag:"r b"^2' in query + + +def test_build_musicbrainz_tag_query_escapes_lucene_phrase_chars() -> None: + query = build_musicbrainz_tag_query('Drum "N" Bass') + + assert 'tag:"drum \\"n\\" bass"^3' in query + + +def test_build_musicbrainz_tag_query_deduplicates_variants() -> None: + query = build_musicbrainz_tag_query('hip-hop') + + assert query.count('tag:"hip-hop"') == 1 + assert query.count('tag:"hip hop"') == 1 diff --git a/backend/tests/services/test_navidrome_library_service.py b/backend/tests/services/test_navidrome_library_service.py new file mode 100644 index 0000000..12f458f --- /dev/null +++ b/backend/tests/services/test_navidrome_library_service.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from repositories.navidrome_models import ( + SubsonicAlbum, + SubsonicArtist, + SubsonicGenre, + SubsonicSearchResult, + SubsonicSong, +) +from services.navidrome_library_service import NavidromeLibraryService, _normalize, _clean_album_name + + +def _make_service() -> tuple[NavidromeLibraryService, MagicMock]: + repo = MagicMock() + repo.get_album_list = AsyncMock(return_value=[]) + repo.get_album = AsyncMock() + repo.get_artists = AsyncMock(return_value=[]) + repo.get_artist = AsyncMock() + repo.get_starred = AsyncMock(return_value=SubsonicSearchResult()) + repo.get_genres = AsyncMock(return_value=[]) + repo.search = AsyncMock(return_value=SubsonicSearchResult()) + prefs = MagicMock() + prefs.get_advanced_settings.return_value = MagicMock() + service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs) + return service, repo + + +def _album(id: str = "al1", name: str = "Album", artist: str = "Artist", + year: int = 2020, song_count: int = 10, mbid: str = "") -> SubsonicAlbum: + return SubsonicAlbum( + id=id, name=name, artist=artist, year=year, + songCount=song_count, musicBrainzId=mbid, + ) + + +def _artist(id: str = "ar1", name: str = "Artist", album_count: int = 3, + mbid: str = "") -> SubsonicArtist: + return SubsonicArtist( + id=id, name=name, albumCount=album_count, musicBrainzId=mbid, + ) + + +def _song(id: str = "s1", title: str = "Song", album: str = "Album", + artist: str = "Artist", track: int = 1, duration: int = 200, + suffix: str = "mp3", bit_rate: int = 320) -> SubsonicSong: + return SubsonicSong( + id=id, title=title, album=album, artist=artist, + track=track, duration=duration, suffix=suffix, bitRate=bit_rate, + ) + + +class TestGetAlbums: + @pytest.mark.asyncio + async def test_returns_mapped_summaries(self): + service, repo = _make_service() + repo.get_album_list = AsyncMock(return_value=[_album(id="a1", name="OK Computer")]) + result = await service.get_albums() + assert len(result) == 1 + assert result[0].navidrome_id == "a1" + assert result[0].name == "OK Computer" + + @pytest.mark.asyncio + async def test_empty_list(self): + service, repo = _make_service() + result = await service.get_albums() + assert result == [] + + +class TestGetAlbumDetail: + @pytest.mark.asyncio + async def test_maps_tracks(self): + service, repo = _make_service() + album = _album(id="a1", name="The Wall") + album = SubsonicAlbum( + id="a1", name="The Wall", artist="Pink Floyd", + year=1979, songCount=2, musicBrainzId="mb-a1", + song=[_song(id="s1", title="Comfortably Numb", track=1), + _song(id="s2", title="Another Brick", track=2)], + ) + repo.get_album = AsyncMock(return_value=album) + result = await service.get_album_detail("a1") + assert result is not None + assert result.navidrome_id == "a1" + assert result.track_count == 2 + assert result.tracks[0].title == "Comfortably Numb" + # Navidrome-native MBID is NOT exposed; only Lidarr-resolved MBIDs are canonical + assert result.musicbrainz_id is None + + @pytest.mark.asyncio + async def test_returns_none_on_exception(self): + service, repo = _make_service() + repo.get_album = AsyncMock(side_effect=RuntimeError("fail")) + result = await service.get_album_detail("a1") + assert result is None + + @pytest.mark.asyncio + async def test_fix_missing_track_numbers(self): + service, repo = _make_service() + album = SubsonicAlbum( + id="a1", name="Album", + song=[_song(id="s1", title="A", track=0), _song(id="s2", title="B", track=0)], + ) + repo.get_album = AsyncMock(return_value=album) + result = await service.get_album_detail("a1") + assert result is not None + assert result.tracks[0].track_number == 1 + assert result.tracks[1].track_number == 2 + + +class TestGetArtists: + @pytest.mark.asyncio + async def test_maps_artist_summaries(self): + service, repo = _make_service() + repo.get_artists = AsyncMock(return_value=[_artist(id="ar1", name="Radiohead", mbid="mb-ar1")]) + result = await service.get_artists() + assert len(result) == 1 + assert result[0].navidrome_id == "ar1" + assert result[0].name == "Radiohead" + assert result[0].musicbrainz_id == "mb-ar1" + + +class TestGetArtistDetail: + @pytest.mark.asyncio + async def test_returns_artist_and_albums(self): + service, repo = _make_service() + repo.get_artist = AsyncMock(return_value=_artist(id="ar1", name="Muse")) + search_result = SubsonicSearchResult( + album=[SubsonicAlbum(id="al1", name="Absolution", artistId="ar1")], + ) + repo.search = AsyncMock(return_value=search_result) + repo.get_album = AsyncMock(return_value=_album(id="al1", name="Absolution")) + result = await service.get_artist_detail("ar1") + assert result is not None + assert result["artist"].name == "Muse" + assert len(result["albums"]) == 1 + + @pytest.mark.asyncio + async def test_returns_none_on_exception(self): + service, repo = _make_service() + repo.get_artist = AsyncMock(side_effect=RuntimeError("fail")) + result = await service.get_artist_detail("ar1") + assert result is None + + +class TestSearch: + @pytest.mark.asyncio + async def test_maps_search_results(self): + service, repo = _make_service() + repo.search = AsyncMock(return_value=SubsonicSearchResult( + artist=[_artist(id="ar1", name="Beatles")], + album=[_album(id="al1", name="Abbey Road")], + song=[_song(id="s1", title="Come Together")], + )) + result = await service.search("beatles") + assert len(result.artists) == 1 + assert result.artists[0].name == "Beatles" + assert len(result.albums) == 1 + assert result.albums[0].name == "Abbey Road" + assert len(result.tracks) == 1 + assert result.tracks[0].title == "Come Together" + + +class TestGetRecent: + @pytest.mark.asyncio + async def test_delegates_to_repo(self): + service, repo = _make_service() + repo.get_album_list = AsyncMock(return_value=[_album(id="al1")]) + result = await service.get_recent(limit=5) + assert len(result) == 1 + repo.get_album_list.assert_awaited_once_with(type="recent", size=5, offset=0) + + +class TestGetFavorites: + @pytest.mark.asyncio + async def test_maps_starred(self): + service, repo = _make_service() + repo.get_starred = AsyncMock(return_value=SubsonicSearchResult( + artist=[_artist()], album=[_album()], song=[_song()], + )) + result = await service.get_favorites() + assert len(result.artists) == 1 + assert len(result.albums) == 1 + assert len(result.tracks) == 1 + + +class TestGetGenres: + @pytest.mark.asyncio + async def test_returns_genre_names(self): + service, repo = _make_service() + repo.get_genres = AsyncMock(return_value=[ + SubsonicGenre(name="Rock", songCount=100), + SubsonicGenre(name="Jazz"), + SubsonicGenre(name=""), + ]) + result = await service.get_genres() + assert result == ["Rock", "Jazz"] + + +class TestGetStats: + @pytest.mark.asyncio + async def test_aggregates_counts(self): + service, repo = _make_service() + repo.get_artists = AsyncMock(return_value=[_artist(), _artist(id="ar2")]) + repo.get_album_list = AsyncMock(return_value=[_album()]) + repo.get_genres = AsyncMock(return_value=[ + SubsonicGenre(name="Rock", songCount=50), + SubsonicGenre(name="Pop", songCount=30), + ]) + result = await service.get_stats() + assert result.total_artists == 2 + assert result.total_albums == 1 + assert result.total_tracks == 80 + + +class TestAlbumMatch: + @pytest.mark.asyncio + async def test_mbid_match(self): + service, repo = _make_service() + candidate = SubsonicAlbum(id="nd-1", name="Album", musicBrainzId="mb-target") + repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[candidate])) + repo.get_album = AsyncMock(return_value=SubsonicAlbum( + id="nd-1", name="Album", musicBrainzId="mb-target", + song=[_song(id="s1", title="Track 1")], + )) + result = await service.get_album_match("mb-target", "Album", "Artist") + assert result.found is True + assert result.navidrome_album_id == "nd-1" + assert len(result.tracks) == 1 + + @pytest.mark.asyncio + async def test_fuzzy_name_match(self): + service, repo = _make_service() + candidate = SubsonicAlbum(id="nd-2", name="OK Computer", artist="Radiohead") + repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[candidate])) + repo.get_album = AsyncMock(return_value=SubsonicAlbum( + id="nd-2", name="OK Computer", artist="Radiohead", + song=[_song()], + )) + result = await service.get_album_match("", "OK Computer", "Radiohead") + assert result.found is True + assert result.navidrome_album_id == "nd-2" + + @pytest.mark.asyncio + async def test_no_match(self): + service, repo = _make_service() + repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[])) + result = await service.get_album_match("mb-none", "Nonexistent", "Nobody") + assert result.found is False + assert result.navidrome_album_id is None + + +class TestNormalize: + def test_strips_accents(self): + assert _normalize("Café") == "cafe" + + def test_lowercases(self): + assert _normalize("HELLO") == "hello" + + def test_strips_non_alphanumeric(self): + assert _normalize("OK Computer!") == "okcomputer" + + +class TestLidarrAlbumMatching: + @pytest.mark.asyncio + async def test_exact_match(self): + service, _ = _make_service() + service._lidarr_album_index = { + f"{_normalize('Buzz')}:{_normalize('NIKI')}": ("mbid-buzz", "mbid-niki"), + } + result = await service._resolve_album_mbid("Buzz", "NIKI") + assert result == "mbid-buzz" + + @pytest.mark.asyncio + async def test_cleaned_name_match(self): + service, _ = _make_service() + service._lidarr_album_index = { + f"{_normalize('OK Computer')}:{_normalize('Radiohead')}": ("mbid-okc", "mbid-rh"), + } + result = await service._resolve_album_mbid("OK Computer (Remastered 2017)", "Radiohead") + assert result == "mbid-okc" + + @pytest.mark.asyncio + async def test_no_match_returns_none(self): + service, _ = _make_service() + service._lidarr_album_index = {} + result = await service._resolve_album_mbid("Nonexistent", "Nobody") + assert result is None + + @pytest.mark.asyncio + async def test_negative_cache_prevents_re_lookup(self): + service, _ = _make_service() + service._lidarr_album_index = {} + result1 = await service._resolve_album_mbid("Missing", "Artist") + assert result1 is None + # Second call should hit negative cache + service._lidarr_album_index = { + f"{_normalize('Missing')}:{_normalize('Artist')}": ("mbid-late", "mbid-a"), + } + result2 = await service._resolve_album_mbid("Missing", "Artist") + assert result2 is None # Still negative-cached + + @pytest.mark.asyncio + async def test_empty_name_returns_none(self): + service, _ = _make_service() + result = await service._resolve_album_mbid("", "Artist") + assert result is None + + +class TestLidarrArtistMatching: + @pytest.mark.asyncio + async def test_exact_match(self): + service, _ = _make_service() + service._lidarr_artist_index = { + _normalize("Radiohead"): "mbid-radiohead", + } + result = await service._resolve_artist_mbid("Radiohead") + assert result == "mbid-radiohead" + + @pytest.mark.asyncio + async def test_no_match_returns_none(self): + service, _ = _make_service() + service._lidarr_artist_index = {} + result = await service._resolve_artist_mbid("Unknown Artist") + assert result is None + + @pytest.mark.asyncio + async def test_empty_name_returns_none(self): + service, _ = _make_service() + result = await service._resolve_artist_mbid("") + assert result is None + + +def _make_service_with_cache() -> tuple[NavidromeLibraryService, MagicMock, MagicMock]: + repo = MagicMock() + repo.get_album_list = AsyncMock(return_value=[]) + repo.get_album = AsyncMock() + repo.get_artists = AsyncMock(return_value=[]) + repo.get_artist = AsyncMock() + repo.get_starred = AsyncMock(return_value=SubsonicSearchResult()) + repo.get_genres = AsyncMock(return_value=[]) + repo.search = AsyncMock(return_value=SubsonicSearchResult()) + prefs = MagicMock() + prefs.get_advanced_settings.return_value = MagicMock() + cache = MagicMock() + cache.get_all_albums_for_matching = AsyncMock(return_value=[]) + cache.load_navidrome_album_mbid_index = AsyncMock(return_value={}) + cache.load_navidrome_artist_mbid_index = AsyncMock(return_value={}) + cache.save_navidrome_album_mbid_index = AsyncMock() + cache.save_navidrome_artist_mbid_index = AsyncMock() + service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs, library_db=cache, mbid_store=cache) + return service, repo, cache + + +class TestWarmMbidCacheLifecycle: + @pytest.mark.asyncio + async def test_builds_lidarr_index_resolves_albums_and_persists(self): + service, repo, cache = _make_service_with_cache() + cache.get_all_albums_for_matching = AsyncMock(return_value=[ + ("OK Computer", "Radiohead", "mbid-okc", "mbid-rh"), + ]) + repo.get_album_list = AsyncMock(return_value=[ + _album(id="a1", name="OK Computer", artist="Radiohead"), + ]) + await service.warm_mbid_cache() + key = f"{_normalize('OK Computer')}:{_normalize('Radiohead')}" + assert service._album_mbid_cache[key] == "mbid-okc" + assert service._artist_mbid_cache[_normalize("Radiohead")] == "mbid-rh" + cache.save_navidrome_album_mbid_index.assert_awaited_once() + cache.save_navidrome_artist_mbid_index.assert_awaited_once() + + @pytest.mark.asyncio + async def test_negative_cache_overridden_when_lidarr_match_exists(self): + service, repo, cache = _make_service_with_cache() + # Seed a negative cache entry + key = f"{_normalize('Buzz')}:{_normalize('NIKI')}" + service._album_mbid_cache[key] = (None, 0.0) + # Lidarr now has a match + cache.get_all_albums_for_matching = AsyncMock(return_value=[ + ("Buzz", "NIKI", "mbid-buzz", "mbid-niki"), + ]) + repo.get_album_list = AsyncMock(return_value=[ + _album(id="a1", name="Buzz", artist="NIKI"), + ]) + await service.warm_mbid_cache() + assert service._album_mbid_cache[key] == "mbid-buzz" + + @pytest.mark.asyncio + async def test_persist_if_dirty_round_trip(self): + service, repo, cache = _make_service_with_cache() + service._lidarr_album_index = { + f"{_normalize('Album')}:{_normalize('Artist')}": ("mbid-a", "mbid-ar"), + } + service._lidarr_artist_index = {_normalize("Artist"): "mbid-ar"} + await service._resolve_album_mbid("Album", "Artist") + await service._resolve_artist_mbid("Artist") + assert service._dirty is True + await service.persist_if_dirty() + assert service._dirty is False + saved_albums = cache.save_navidrome_album_mbid_index.call_args[0][0] + saved_artists = cache.save_navidrome_artist_mbid_index.call_args[0][0] + assert saved_albums[f"{_normalize('Album')}:{_normalize('Artist')}"] == "mbid-a" + assert saved_artists[_normalize("Artist")] == "mbid-ar" + + @pytest.mark.asyncio + async def test_disk_cache_loaded_when_lidarr_unavailable(self): + service, repo, cache = _make_service_with_cache() + cache.get_all_albums_for_matching = AsyncMock(return_value=[]) + key = f"{_normalize('Album')}:{_normalize('Artist')}" + cache.load_navidrome_album_mbid_index = AsyncMock(return_value={key: "mbid-disk"}) + cache.load_navidrome_artist_mbid_index = AsyncMock(return_value={_normalize("Artist"): "mbid-ar-disk"}) + # Provide Navidrome albums so reconciliation keeps disk entries and reverse index is built + repo.get_album_list = AsyncMock(return_value=[_album(id="nd-1", name="Album", artist="Artist")]) + await service.warm_mbid_cache() + # Disk cache should be loaded even though Lidarr index is empty + assert service._album_mbid_cache[key] == "mbid-disk" + assert service._artist_mbid_cache[_normalize("Artist")] == "mbid-ar-disk" + # Reverse index should be built from disk cache (M2 fix) + assert service._mbid_to_navidrome_id.get("mbid-disk") == "nd-1" diff --git a/backend/tests/services/test_navidrome_playback_service.py b/backend/tests/services/test_navidrome_playback_service.py new file mode 100644 index 0000000..509099d --- /dev/null +++ b/backend/tests/services/test_navidrome_playback_service.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.navidrome_playback_service import NavidromePlaybackService + + +def _make_service(configured: bool = True) -> tuple[NavidromePlaybackService, MagicMock]: + repo = MagicMock() + repo.is_configured = MagicMock(return_value=configured) + repo.build_stream_url = MagicMock( + return_value="http://navidrome:4533/rest/stream?u=admin&t=tok&s=salt&v=1.16.1&c=musicseerr&f=json&id=song-1" + ) + repo.scrobble = AsyncMock(return_value=True) + service = NavidromePlaybackService(navidrome_repo=repo) + return service, repo + + +class TestGetStreamUrl: + def test_delegates_to_repo(self): + service, repo = _make_service() + url = service.get_stream_url("song-1") + repo.build_stream_url.assert_called_once_with("song-1") + assert "u=admin" in url + assert "id=song-1" in url + + def test_base_url_correct(self): + service, _ = _make_service() + url = service.get_stream_url("song-1") + assert url.startswith("http://navidrome:4533/rest/stream?") + + def test_raises_when_not_configured(self): + service, repo = _make_service(configured=False) + repo.build_stream_url.side_effect = ValueError("Navidrome is not configured") + with pytest.raises(ValueError, match="not configured"): + service.get_stream_url("song-1") + + +class TestScrobble: + @pytest.mark.asyncio + async def test_success(self): + service, repo = _make_service() + result = await service.scrobble("song-1") + assert result is True + repo.scrobble.assert_awaited_once() + call_args = repo.scrobble.call_args + assert call_args.args[0] == "song-1" + assert call_args.kwargs.get("time_ms") is not None + + @pytest.mark.asyncio + async def test_failure_returns_false(self): + service, repo = _make_service() + repo.scrobble = AsyncMock(side_effect=RuntimeError("network")) + result = await service.scrobble("song-1") + assert result is False diff --git a/backend/tests/services/test_navidrome_stream_proxy.py b/backend/tests/services/test_navidrome_stream_proxy.py new file mode 100644 index 0000000..6ecd290 --- /dev/null +++ b/backend/tests/services/test_navidrome_stream_proxy.py @@ -0,0 +1,94 @@ +"""Tests for NavidromePlaybackService.proxy_head / proxy_stream.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.navidrome_playback_service import NavidromePlaybackService +from core.exceptions import ExternalServiceError + + +def _make_service(): + repo = MagicMock() + service = NavidromePlaybackService(navidrome_repo=repo) + return service, repo + + +@pytest.mark.asyncio +async def test_proxy_head_returns_response(): + service, repo = _make_service() + + from repositories.navidrome_repository import StreamProxyResult + mock_result = StreamProxyResult( + status_code=200, + headers={"Content-Type": "audio/flac", "Content-Length": "12345"}, + media_type="audio/flac", + body_chunks=None, + ) + repo.proxy_head_stream = AsyncMock(return_value=mock_result) + + response = await service.proxy_head("song-1") + assert response.status_code == 200 + assert response.headers.get("Content-Type") == "audio/flac" + + +@pytest.mark.asyncio +async def test_proxy_head_raises_on_error(): + service, repo = _make_service() + repo.proxy_head_stream = AsyncMock(side_effect=ExternalServiceError("Failed to reach Navidrome")) + + with pytest.raises(ExternalServiceError): + await service.proxy_head("song-1") + + +@pytest.mark.asyncio +async def test_proxy_stream_returns_streaming_response(): + service, repo = _make_service() + + async def fake_chunks(): + yield b"chunk1" + yield b"chunk2" + + from repositories.navidrome_repository import StreamProxyResult + mock_result = StreamProxyResult( + status_code=200, + headers={"Content-Type": "audio/mpeg"}, + media_type="audio/mpeg", + body_chunks=fake_chunks(), + ) + repo.proxy_get_stream = AsyncMock(return_value=mock_result) + + response = await service.proxy_stream("song-1", None) + assert response.status_code == 200 + assert response.media_type == "audio/mpeg" + + +@pytest.mark.asyncio +async def test_proxy_stream_with_range_header(): + service, repo = _make_service() + + async def fake_chunks(): + yield b"partial" + + from repositories.navidrome_repository import StreamProxyResult + mock_result = StreamProxyResult( + status_code=206, + headers={"Content-Type": "audio/mpeg", "Content-Range": "bytes 0-999/5000"}, + media_type="audio/mpeg", + body_chunks=fake_chunks(), + ) + repo.proxy_get_stream = AsyncMock(return_value=mock_result) + + response = await service.proxy_stream("song-1", "bytes=0-999") + assert response.status_code == 206 + + +@pytest.mark.asyncio +async def test_proxy_stream_raises_416(): + service, repo = _make_service() + repo.proxy_get_stream = AsyncMock( + side_effect=ExternalServiceError("416 Range not satisfiable") + ) + + with pytest.raises(ExternalServiceError, match="416"): + await service.proxy_stream("song-1", "bytes=9999-") diff --git a/backend/tests/services/test_playlist_service.py b/backend/tests/services/test_playlist_service.py new file mode 100644 index 0000000..f978874 --- /dev/null +++ b/backend/tests/services/test_playlist_service.py @@ -0,0 +1,451 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from pathlib import Path +from types import SimpleNamespace + +from core.exceptions import InvalidPlaylistDataError, PlaylistNotFoundError, SourceResolutionError +from repositories.playlist_repository import PlaylistRecord, PlaylistTrackRecord +from services.playlist_service import PlaylistService + + +def _make_playlist(id="p-1", name="Test", cover_image_path=None) -> PlaylistRecord: + return PlaylistRecord( + id=id, name=name, cover_image_path=cover_image_path, + created_at="2025-01-01T00:00:00+00:00", + updated_at="2025-01-01T00:00:00+00:00", + ) + + +def _make_track(id="t-1", playlist_id="p-1", position=0) -> PlaylistTrackRecord: + return PlaylistTrackRecord( + id=id, playlist_id=playlist_id, position=position, + track_name="Track", artist_name="Artist", album_name="Album", + album_id=None, artist_id=None, track_source_id=None, cover_url=None, + source_type="local", available_sources=None, format=None, + track_number=None, disc_number=None, duration=None, + created_at="2025-01-01T00:00:00+00:00", + ) + + +def _make_service(tmp_path: Path) -> tuple[PlaylistService, MagicMock]: + repo = MagicMock() + repo.create_playlist = MagicMock(return_value=_make_playlist()) + repo.get_playlist = MagicMock(return_value=_make_playlist()) + repo.get_all_playlists = MagicMock(return_value=[]) + repo.update_playlist = MagicMock(return_value=_make_playlist()) + repo.delete_playlist = MagicMock(return_value=True) + repo.add_tracks = MagicMock(return_value=[_make_track()]) + repo.remove_track = MagicMock(return_value=True) + repo.reorder_track = MagicMock(return_value=2) + repo.update_track_source = MagicMock(return_value=_make_track()) + repo.get_tracks = MagicMock(return_value=[]) + service = PlaylistService(repo=repo, cache_dir=tmp_path) + return service, repo + + +class TestCreatePlaylist: + @pytest.mark.asyncio + async def test_valid_name(self, tmp_path): + service, repo = _make_service(tmp_path) + result = await service.create_playlist("My Playlist") + assert result.name == "Test" + repo.create_playlist.assert_called_once_with("My Playlist") + + @pytest.mark.asyncio + async def test_empty_name(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError): + await service.create_playlist("") + + @pytest.mark.asyncio + async def test_whitespace_name(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError): + await service.create_playlist(" ") + + @pytest.mark.asyncio + async def test_strips_whitespace(self, tmp_path): + service, repo = _make_service(tmp_path) + await service.create_playlist(" Hello ") + repo.create_playlist.assert_called_once_with("Hello") + + +class TestGetPlaylist: + @pytest.mark.asyncio + async def test_existing(self, tmp_path): + service, _ = _make_service(tmp_path) + result = await service.get_playlist("p-1") + assert result.id == "p-1" + + @pytest.mark.asyncio + async def test_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock(return_value=None) + with pytest.raises(PlaylistNotFoundError): + await service.get_playlist("nonexistent") + + +class TestUpdatePlaylist: + @pytest.mark.asyncio + async def test_valid_update(self, tmp_path): + service, repo = _make_service(tmp_path) + result = await service.update_playlist("p-1", name="New") + assert result is not None + repo.update_playlist.assert_called_once() + + @pytest.mark.asyncio + async def test_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.update_playlist = MagicMock(return_value=None) + with pytest.raises(PlaylistNotFoundError): + await service.update_playlist("nonexistent", name="X") + + @pytest.mark.asyncio + async def test_empty_name(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError): + await service.update_playlist("p-1", name="") + + +class TestDeletePlaylist: + @pytest.mark.asyncio + async def test_successful(self, tmp_path): + service, repo = _make_service(tmp_path) + await service.delete_playlist("p-1") + repo.delete_playlist.assert_called_once_with("p-1") + + @pytest.mark.asyncio + async def test_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.delete_playlist = MagicMock(return_value=False) + with pytest.raises(PlaylistNotFoundError): + await service.delete_playlist("nonexistent") + + +class TestAddTracks: + @pytest.mark.asyncio + async def test_valid(self, tmp_path): + service, repo = _make_service(tmp_path) + tracks = [{"track_name": "T", "artist_name": "A", "album_name": "AL", "source_type": "local"}] + result = await service.add_tracks("p-1", tracks) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_empty_list(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError): + await service.add_tracks("p-1", []) + + @pytest.mark.asyncio + async def test_playlist_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock(return_value=None) + with pytest.raises(PlaylistNotFoundError): + await service.add_tracks("nonexistent", [{"track_name": "T", "artist_name": "A", "album_name": "AL", "source_type": "local"}]) + + +class TestRemoveTrack: + @pytest.mark.asyncio + async def test_successful(self, tmp_path): + service, repo = _make_service(tmp_path) + await service.remove_track("p-1", "t-1") + repo.remove_track.assert_called_once_with("p-1", "t-1") + + @pytest.mark.asyncio + async def test_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.remove_track = MagicMock(return_value=False) + with pytest.raises(PlaylistNotFoundError): + await service.remove_track("p-1", "nonexistent") + + +class TestReorderTrack: + @pytest.mark.asyncio + async def test_valid(self, tmp_path): + service, repo = _make_service(tmp_path) + result = await service.reorder_track("p-1", "t-1", 2) + assert result == 2 + repo.reorder_track.assert_called_once_with("p-1", "t-1", 2) + + @pytest.mark.asyncio + async def test_negative_position(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError): + await service.reorder_track("p-1", "t-1", -1) + + @pytest.mark.asyncio + async def test_not_found(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.reorder_track = MagicMock(return_value=None) + with pytest.raises(PlaylistNotFoundError): + await service.reorder_track("p-1", "nonexistent", 0) + + +class TestUploadCover: + @pytest.mark.asyncio + async def test_invalid_mime(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock(return_value=_make_playlist(id="abcdef-1234")) + with pytest.raises(InvalidPlaylistDataError, match="Invalid image type"): + await service.upload_cover("abcdef-1234", b"data", "application/pdf") + + @pytest.mark.asyncio + async def test_too_large(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock(return_value=_make_playlist(id="abcdef-1234")) + data = b"x" * (2 * 1024 * 1024 + 1) + with pytest.raises(InvalidPlaylistDataError, match="too large"): + await service.upload_cover("abcdef-1234", data, "image/png") + + @pytest.mark.asyncio + async def test_path_traversal_id(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock(return_value=_make_playlist(id="../evil")) + with pytest.raises(InvalidPlaylistDataError, match="Invalid playlist ID"): + await service.upload_cover("../evil", b"data", "image/png") + + @pytest.mark.asyncio + async def test_valid_upload(self, tmp_path): + service, repo = _make_service(tmp_path) + playlist = _make_playlist(id="abcdef-1234") + repo.get_playlist = MagicMock(return_value=playlist) + + result = await service.upload_cover("abcdef-1234", b"PNG_DATA", "image/png") + assert result == "/api/v1/playlists/abcdef-1234/cover" + repo.update_playlist.assert_called() + + cover_dir = tmp_path / "covers" / "playlists" + assert (cover_dir / "abcdef-1234.png").exists() + + @pytest.mark.asyncio + async def test_replaces_old_cover(self, tmp_path): + service, repo = _make_service(tmp_path) + playlist = _make_playlist(id="abcdef-1234") + repo.get_playlist = MagicMock(return_value=playlist) + + await service.upload_cover("abcdef-1234", b"OLD_PNG", "image/png") + cover_dir = tmp_path / "covers" / "playlists" + assert (cover_dir / "abcdef-1234.png").exists() + + await service.upload_cover("abcdef-1234", b"NEW_JPEG", "image/jpeg") + assert not (cover_dir / "abcdef-1234.png").exists() + assert (cover_dir / "abcdef-1234.jpg").exists() + assert (cover_dir / "abcdef-1234.jpg").read_bytes() == b"NEW_JPEG" + + +class TestRemoveCover: + @pytest.mark.asyncio + async def test_removes_file_and_clears_path(self, tmp_path): + cover_dir = tmp_path / "covers" / "playlists" + cover_dir.mkdir(parents=True) + cover_file = cover_dir / "p-1.png" + cover_file.write_bytes(b"img") + + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock( + return_value=_make_playlist(cover_image_path=str(cover_file)), + ) + + await service.remove_cover("p-1") + assert not cover_file.exists() + repo.update_playlist.assert_called() + + @pytest.mark.asyncio + async def test_stale_cover_path_succeeds(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_playlist = MagicMock( + return_value=_make_playlist(cover_image_path="/nonexistent/stale.png"), + ) + await service.remove_cover("p-1") + repo.update_playlist.assert_called() + + +class TestSourceTypeValidation: + @pytest.mark.asyncio + async def test_invalid_source_type_in_add_tracks(self, tmp_path): + service, _ = _make_service(tmp_path) + tracks = [{"track_name": "T", "artist_name": "A", "album_name": "AL", "source_type": "invalid"}] + with pytest.raises(InvalidPlaylistDataError, match="Invalid source_type"): + await service.add_tracks("p-1", tracks) + + @pytest.mark.asyncio + async def test_valid_source_types_in_add_tracks(self, tmp_path): + service, repo = _make_service(tmp_path) + for st in ("local", "jellyfin", "youtube", ""): + tracks = [{"track_name": "T", "artist_name": "A", "album_name": "AL", "source_type": st}] + result = await service.add_tracks("p-1", tracks) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_invalid_source_type_in_update_track(self, tmp_path): + service, _ = _make_service(tmp_path) + with pytest.raises(InvalidPlaylistDataError, match="Invalid source_type"): + await service.update_track_source("p-1", "t-1", source_type="bogus") + + +class TestUpdatePlaylistWithDetail: + @pytest.mark.asyncio + async def test_returns_playlist_and_tracks(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_tracks = MagicMock(return_value=[_make_track()]) + playlist, tracks = await service.update_playlist_with_detail("p-1", name="New") + assert playlist is not None + assert len(tracks) == 1 + repo.update_playlist.assert_called_once() + + +class TestCheckTrackMembership: + @pytest.mark.asyncio + async def test_delegates_to_repo(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.check_track_membership = MagicMock(return_value={"p-1": [0]}) + result = await service.check_track_membership([ + ("Song", "Artist", "Album"), + ]) + assert result == {"p-1": [0]} + repo.check_track_membership.assert_called_once_with([ + ("Song", "Artist", "Album"), + ]) + + +def _jf_match(tracks): + return SimpleNamespace( + found=True, + tracks=[SimpleNamespace(title=t[0], track_number=t[1], jellyfin_id=t[2]) for t in tracks], + ) + + +def _local_match(tracks): + return SimpleNamespace( + found=True, + tracks=[SimpleNamespace(title=t[0], track_number=t[1], track_file_id=t[2]) for t in tracks], + ) + + +def _make_track_with_album(id="t-1", track_name="Track", track_number=1, album_id="mb-album-1", source_type="local"): + return PlaylistTrackRecord( + id=id, playlist_id="p-1", position=0, + track_name=track_name, artist_name="Artist", album_name="Album", + album_id=album_id, artist_id=None, track_source_id="old-src-id", + cover_url=None, source_type=source_type, available_sources=None, + format=None, track_number=track_number, disc_number=None, duration=None, + created_at="2025-01-01T00:00:00+00:00", + ) + + +class TestResolveTrackSources: + @pytest.mark.asyncio + async def test_resolves_jellyfin_and_local(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track_with_album(id="t-1", track_name="Song One", track_number=1) + repo.get_tracks = MagicMock(return_value=[track]) + + jf_svc = AsyncMock() + jf_svc.match_album_by_mbid.return_value = _jf_match([("Song One", 1, "jf-id-1")]) + local_svc = AsyncMock() + local_svc.match_album_by_mbid.return_value = _local_match([("Song One", 1, 42)]) + + result = await service.resolve_track_sources("p-1", jf_service=jf_svc, local_service=local_svc) + assert "t-1" in result + assert "jellyfin" in result["t-1"] + assert "local" in result["t-1"] + + @pytest.mark.asyncio + async def test_no_album_id_returns_current_source(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track(id="t-1") + track = PlaylistTrackRecord( + id="t-1", playlist_id="p-1", position=0, + track_name="Song", artist_name="Artist", album_name="Album", + album_id=None, artist_id=None, track_source_id=None, + cover_url=None, source_type="youtube", available_sources=None, + format=None, track_number=None, disc_number=None, duration=None, + created_at="2025-01-01T00:00:00+00:00", + ) + repo.get_tracks = MagicMock(return_value=[track]) + + result = await service.resolve_track_sources("p-1", jf_service=AsyncMock(), local_service=AsyncMock()) + assert result["t-1"] == ["youtube"] + + @pytest.mark.asyncio + async def test_empty_playlist_returns_empty(self, tmp_path): + service, repo = _make_service(tmp_path) + repo.get_tracks = MagicMock(return_value=[]) + result = await service.resolve_track_sources("p-1") + assert result == {} + + @pytest.mark.asyncio + async def test_service_error_skips_source(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track_with_album() + repo.get_tracks = MagicMock(return_value=[track]) + + jf_svc = AsyncMock() + jf_svc.match_album_by_mbid.side_effect = RuntimeError("connection failed") + local_svc = AsyncMock() + local_svc.match_album_by_mbid.return_value = SimpleNamespace(found=False) + + result = await service.resolve_track_sources("p-1", jf_service=jf_svc, local_service=local_svc) + assert "t-1" in result + assert "jellyfin" not in result["t-1"] + + +class TestResolveNewSourceId: + @pytest.mark.asyncio + async def test_switch_to_jellyfin(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track_with_album(track_name="Track Title", source_type="local") + repo.get_track = MagicMock(return_value=track) + repo.update_track_source = MagicMock(return_value=PlaylistTrackRecord( + id="t-1", playlist_id="p-1", position=0, + track_name="Track Title", artist_name="Artist", album_name="Album", + album_id="mb-album-1", artist_id=None, track_source_id="jf-id-1", + cover_url=None, source_type="jellyfin", available_sources=None, + format=None, track_number=1, disc_number=None, duration=None, + created_at="2025-01-01T00:00:00+00:00", + )) + + jf_svc = AsyncMock() + jf_svc.match_album_by_mbid.return_value = _jf_match([("Track Title", 1, "jf-id-1")]) + local_svc = AsyncMock() + + result = await service.update_track_source( + "p-1", "t-1", source_type="jellyfin", + jf_service=jf_svc, local_service=local_svc, + ) + assert result.track_source_id == "jf-id-1" + + @pytest.mark.asyncio + async def test_no_album_id_raises(self, tmp_path): + service, repo = _make_service(tmp_path) + track = PlaylistTrackRecord( + id="t-1", playlist_id="p-1", position=0, + track_name="Song", artist_name="Artist", album_name="Album", + album_id=None, artist_id=None, track_source_id=None, + cover_url=None, source_type="local", available_sources=None, + format=None, track_number=None, disc_number=None, duration=None, + created_at="2025-01-01T00:00:00+00:00", + ) + repo.get_track = MagicMock(return_value=track) + + with pytest.raises(SourceResolutionError, match="missing album_id"): + await service.update_track_source( + "p-1", "t-1", source_type="jellyfin", + jf_service=AsyncMock(), local_service=AsyncMock(), + ) + + @pytest.mark.asyncio + async def test_track_not_found_in_source_raises(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track_with_album(track_name="My Song", source_type="local") + repo.get_track = MagicMock(return_value=track) + + jf_svc = AsyncMock() + jf_svc.match_album_by_mbid.return_value = SimpleNamespace(found=False) + local_svc = AsyncMock() + + with pytest.raises(SourceResolutionError, match="not found in Jellyfin"): + await service.update_track_source( + "p-1", "t-1", source_type="jellyfin", + jf_service=jf_svc, local_service=local_svc, + ) diff --git a/backend/tests/services/test_playlist_source_resolution.py b/backend/tests/services/test_playlist_source_resolution.py new file mode 100644 index 0000000..dbaecd6 --- /dev/null +++ b/backend/tests/services/test_playlist_source_resolution.py @@ -0,0 +1,276 @@ +"""Tests for playlist source resolution fixes. + +Covers: +- _resolve_album_sources passes album_name/artist_name to Navidrome +- resolve_track_sources persists resolved sources to DB (superset guard) +- resolve_track_sources correctly discovers multi-source tracks +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from pathlib import Path +from types import SimpleNamespace + +from repositories.playlist_repository import PlaylistRecord, PlaylistTrackRecord +from services.playlist_service import PlaylistService + + +def _make_playlist(id="p-1") -> PlaylistRecord: + return PlaylistRecord( + id=id, name="Test", cover_image_path=None, + created_at="2025-01-01T00:00:00+00:00", + updated_at="2025-01-01T00:00:00+00:00", + ) + + +def _make_track( + id="t-1", album_id="mbid-abc", track_number=1, + track_name="Wall Street Shuffle", artist_name="10cc", album_name="Sheet Music", + source_type="navidrome", available_sources=None, +) -> PlaylistTrackRecord: + return PlaylistTrackRecord( + id=id, playlist_id="p-1", position=0, + track_name=track_name, artist_name=artist_name, album_name=album_name, + album_id=album_id, artist_id=None, track_source_id="nd-123", + cover_url=None, source_type=source_type, + available_sources=available_sources, + format="flac", track_number=track_number, disc_number=None, duration=240, + created_at="2025-01-01T00:00:00+00:00", + ) + + +def _make_service(tmp_path: Path) -> tuple[PlaylistService, MagicMock]: + repo = MagicMock() + repo.get_playlist = MagicMock(return_value=_make_playlist()) + repo.get_tracks = MagicMock(return_value=[]) + repo.batch_update_available_sources = MagicMock(return_value=0) + service = PlaylistService(repo=repo, cache_dir=tmp_path) + return service, repo + + +def _make_nd_service(found=True, tracks=None): + nd = AsyncMock() + if tracks is None: + tracks = [SimpleNamespace(track_number=1, title="Wall Street Shuffle", navidrome_id="nd-456")] + nd.get_album_match = AsyncMock(return_value=SimpleNamespace(found=found, tracks=tracks)) + return nd + + +def _make_local_service(found=True, tracks=None): + local = AsyncMock() + if tracks is None: + tracks = [SimpleNamespace(track_number=1, title="Wall Street Shuffle", track_file_id=789)] + local.match_album_by_mbid = AsyncMock(return_value=SimpleNamespace(found=found, tracks=tracks)) + return local + + +def _make_jf_service(found=False): + jf = AsyncMock() + jf.match_album_by_mbid = AsyncMock(return_value=SimpleNamespace(found=found, tracks=[])) + return jf + + +class TestResolveAlbumSourcesPassesMetadata: + """Verify _resolve_album_sources passes album_name/artist_name to Navidrome.""" + + @pytest.mark.asyncio + async def test_passes_album_name_and_artist_name_to_navidrome(self, tmp_path): + service, _ = _make_service(tmp_path) + nd = _make_nd_service() + + await service._resolve_album_sources( + "mbid-abc", None, None, nd, + album_name="Sheet Music", artist_name="10cc", + ) + + nd.get_album_match.assert_called_once_with( + album_id="mbid-abc", album_name="Sheet Music", artist_name="10cc", + ) + assert True + + @pytest.mark.asyncio + async def test_passes_empty_strings_when_not_provided(self, tmp_path): + service, _ = _make_service(tmp_path) + nd = _make_nd_service() + + await service._resolve_album_sources("mbid-abc", None, None, nd) + + nd.get_album_match.assert_called_once_with( + album_id="mbid-abc", album_name="", artist_name="", + ) + assert True + + +class TestResolveTrackSourcesDiscovery: + """Verify resolve_track_sources correctly discovers multi-source tracks.""" + + @pytest.mark.asyncio + async def test_discovers_local_and_navidrome_sources(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track(available_sources=["navidrome"]) + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + local = _make_local_service() + jf = _make_jf_service() + + result = await service.resolve_track_sources( + "p-1", jf_service=jf, local_service=local, nd_service=nd, + ) + + assert "t-1" in result + assert sorted(result["t-1"]) == ["local", "navidrome"] + + @pytest.mark.asyncio + async def test_extracts_album_metadata_from_tracks(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track() + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + + await service.resolve_track_sources("p-1", nd_service=nd) + + nd.get_album_match.assert_called_once_with( + album_id="mbid-abc", album_name="Sheet Music", artist_name="10cc", + ) + assert True + + @pytest.mark.asyncio + async def test_no_album_tracks_keep_single_source(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track(album_id=None, track_number=None) + repo.get_tracks = MagicMock(return_value=[track]) + + result = await service.resolve_track_sources("p-1") + + assert result["t-1"] == ["navidrome"] + + +class TestResolveTrackSourcesPersistence: + """Verify resolve_track_sources persists resolved sources (superset guard).""" + + @pytest.mark.asyncio + async def test_persists_when_resolved_is_superset(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track(available_sources=["navidrome"]) + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + local = _make_local_service() + + await service.resolve_track_sources("p-1", local_service=local, nd_service=nd) + + repo.batch_update_available_sources.assert_called_once() + call_args = repo.batch_update_available_sources.call_args + assert call_args[0][0] == "p-1" + updates = call_args[0][1] + assert "t-1" in updates + assert sorted(updates["t-1"]) == ["local", "navidrome"] + + @pytest.mark.asyncio + async def test_skips_persist_when_no_change(self, tmp_path): + service, repo = _make_service(tmp_path) + track = _make_track(available_sources=["local", "navidrome"]) + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + local = _make_local_service() + + await service.resolve_track_sources("p-1", local_service=local, nd_service=nd) + + repo.batch_update_available_sources.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_persist_when_resolved_is_subset(self, tmp_path): + """Superset guard: don't overwrite if resolution lost a source (e.g. service down).""" + service, repo = _make_service(tmp_path) + track = _make_track(available_sources=["jellyfin", "local", "navidrome"]) + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + local = _make_local_service() + + await service.resolve_track_sources("p-1", local_service=local, nd_service=nd) + + repo.batch_update_available_sources.assert_not_called() + + +class TestStringTrackNumberRegression: + """Regression tests: source resolution must work when track_number arrives as a string. + + Root cause: Lidarr API returns trackNumber as a string (e.g., "6"). If not coerced, + the source map gets string keys while playlist DB uses int keys, causing lookup misses. + """ + + @pytest.mark.asyncio + async def test_resolve_sources_with_string_track_numbers(self, tmp_path): + """resolve_track_sources discovers multi-source even when service returns string track_number.""" + service, repo = _make_service(tmp_path) + track = _make_track(track_number=1, available_sources=["navidrome"]) + repo.get_tracks = MagicMock(return_value=[track]) + + nd = _make_nd_service() + # Local service returns string track_number (simulating pre-fix Lidarr data) + local = _make_local_service(tracks=[ + SimpleNamespace(track_number="1", title="Wall Street Shuffle", track_file_id=789), + ]) + jf = _make_jf_service() + + result = await service.resolve_track_sources( + "p-1", jf_service=jf, local_service=local, nd_service=nd, + ) + + assert "t-1" in result + assert sorted(result["t-1"]) == ["local", "navidrome"] + + @pytest.mark.asyncio + async def test_update_track_source_with_string_track_numbers(self, tmp_path): + """update_track_source resolves local source_id even when track_number is a string.""" + service, repo = _make_service(tmp_path) + track = _make_track( + track_number=6, source_type="navidrome", + track_name="Speed Kills", available_sources=["local", "navidrome"], + ) + repo.get_track = MagicMock(return_value=track) + repo.update_track_source = MagicMock(return_value=track) + + local = _make_local_service(tracks=[ + SimpleNamespace(track_number="6", title="Speed Kills", track_file_id=2608), + ]) + nd = _make_nd_service(tracks=[ + SimpleNamespace(track_number=6, title="Speed Kills", navidrome_id="nd-456"), + ]) + + await service.update_track_source( + "p-1", "t-1", source_type="local", + jf_service=None, local_service=local, nd_service=nd, + ) + + repo.update_track_source.assert_called_once() + call_args = repo.update_track_source.call_args + # positional: (playlist_id, track_id, source_type, available_sources, track_source_id) + assert call_args[0][2] == "local" + assert call_args[0][4] == "2608" + + @pytest.mark.asyncio + async def test_cached_string_keys_are_normalized_on_read(self, tmp_path): + """Stale cached source maps with string keys are normalized to int keys.""" + service, repo = _make_service(tmp_path) + + from infrastructure.cache.memory_cache import InMemoryCache + cache = InMemoryCache() + service._cache = cache + stale_data = ( + {}, + {"6": ("Speed Kills", "2608"), "1": ("Johnny", "2601")}, + {6: ("Speed Kills", "nd-456"), 1: ("Johnny", "nd-401")}, + ) + await cache.set("source_resolution:mbid-abc", stale_data, ttl_seconds=3600) + + jf, local, nd = await service._resolve_album_sources( + "mbid-abc", None, None, None, + ) + + assert isinstance(next(iter(local)), int) + assert local[6] == ("Speed Kills", "2608") + assert local[1] == ("Johnny", "2601") diff --git a/backend/tests/services/test_preferences_generic_settings.py b/backend/tests/services/test_preferences_generic_settings.py new file mode 100644 index 0000000..78468dd --- /dev/null +++ b/backend/tests/services/test_preferences_generic_settings.py @@ -0,0 +1,47 @@ +import json +import tempfile +from pathlib import Path + +import pytest + +from services.preferences_service import PreferencesService +from core.config import Settings + + +@pytest.fixture +def prefs_service(tmp_path: Path) -> PreferencesService: + config_path = tmp_path / "config.json" + settings = Settings() + settings.config_file_path = config_path + return PreferencesService(settings) + + +class TestGenericSettings: + def test_get_setting_default_none(self, prefs_service: PreferencesService): + assert prefs_service.get_setting("nonexistent") is None + + def test_save_and_get_setting(self, prefs_service: PreferencesService): + prefs_service.save_setting("audiodb_sweep_cursor", "abc-123") + assert prefs_service.get_setting("audiodb_sweep_cursor") == "abc-123" + + def test_save_none_removes_setting(self, prefs_service: PreferencesService): + prefs_service.save_setting("audiodb_sweep_cursor", "abc-123") + prefs_service.save_setting("audiodb_sweep_cursor", None) + assert prefs_service.get_setting("audiodb_sweep_cursor") is None + + def test_settings_persist_across_instances(self, tmp_path: Path): + config_path = tmp_path / "config.json" + settings = Settings() + settings.config_file_path = config_path + + svc1 = PreferencesService(settings) + svc1.save_setting("cursor", "xyz") + + svc2 = PreferencesService(settings) + assert svc2.get_setting("cursor") == "xyz" + + def test_internal_namespace_isolated(self, prefs_service: PreferencesService): + prefs_service.save_setting("my_key", "my_val") + config = prefs_service._load_config() + assert "my_key" not in config + assert config["_internal"]["my_key"] == "my_val" diff --git a/backend/tests/services/test_preferences_lastfm.py b/backend/tests/services/test_preferences_lastfm.py new file mode 100644 index 0000000..23971ea --- /dev/null +++ b/backend/tests/services/test_preferences_lastfm.py @@ -0,0 +1,164 @@ +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +from api.v1.schemas.settings import ( + LastFmConnectionSettings, + LASTFM_SECRET_MASK, +) +from core.config import Settings +from services.preferences_service import PreferencesService + + +@pytest.fixture +def tmp_config(tmp_path: Path): + config_file = tmp_path / "config.json" + config_file.write_text("{}") + settings = MagicMock(spec=Settings) + settings.config_file_path = config_file + return settings, config_file + + +@pytest.fixture +def service(tmp_config): + settings, _ = tmp_config + return PreferencesService(settings=settings) + + +def test_get_lastfm_returns_defaults_when_missing(service): + result = service.get_lastfm_connection() + assert isinstance(result, LastFmConnectionSettings) + assert result.api_key == "" + assert result.shared_secret == "" + assert result.session_key == "" + assert result.username == "" + assert result.enabled is False + + +def test_save_and_load_lastfm_connection(service): + settings = LastFmConnectionSettings( + api_key="my-key", + shared_secret="my-secret", + session_key="", + username="", + enabled=True, + ) + service.save_lastfm_connection(settings) + loaded = service.get_lastfm_connection() + assert loaded.api_key == "my-key" + assert loaded.shared_secret == "my-secret" + assert loaded.enabled is True + + +def test_save_trims_whitespace(service): + settings = LastFmConnectionSettings( + api_key=" my-key ", + shared_secret=" my-secret ", + session_key="", + username=" user ", + enabled=True, + ) + service.save_lastfm_connection(settings) + loaded = service.get_lastfm_connection() + assert loaded.api_key == "my-key" + assert loaded.shared_secret == "my-secret" + assert loaded.username == "user" + + +def test_save_preserves_masked_secret(service): + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret="real-secret-value", + session_key="", + username="", + enabled=True, + ) + ) + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret=LASTFM_SECRET_MASK + "alue", + session_key="", + username="", + enabled=True, + ) + ) + loaded = service.get_lastfm_connection() + assert loaded.shared_secret == "real-secret-value" + + +def test_clearing_credentials_disables_and_clears_session(service): + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk-123", + username="user1", + enabled=True, + ) + ) + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="", + shared_secret="", + session_key="", + username="", + enabled=True, + ) + ) + loaded = service.get_lastfm_connection() + assert loaded.enabled is False + assert loaded.session_key == "" + assert loaded.username == "" + + +def test_clearing_api_key_only_disables(service): + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="sk-123", + username="user1", + enabled=True, + ) + ) + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="", + shared_secret="secret", + session_key=LASTFM_SECRET_MASK, + username="user1", + enabled=True, + ) + ) + loaded = service.get_lastfm_connection() + assert loaded.enabled is False + assert loaded.session_key == "" + assert loaded.username == "" + + +def test_is_lastfm_enabled_requires_all_fields(service): + assert service.is_lastfm_enabled() is False + + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="", + username="", + enabled=True, + ) + ) + assert service.is_lastfm_enabled() is True + + service.save_lastfm_connection( + LastFmConnectionSettings( + api_key="key", + shared_secret="secret", + session_key="", + username="", + enabled=False, + ) + ) + assert service.is_lastfm_enabled() is False diff --git a/backend/tests/services/test_request_service.py b/backend/tests/services/test_request_service.py new file mode 100644 index 0000000..8c5632f --- /dev/null +++ b/backend/tests/services/test_request_service.py @@ -0,0 +1,67 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from core.exceptions import ExternalServiceError +from services.request_service import RequestService + + +def _make_service(queue_add_result: dict | None = None) -> tuple[RequestService, MagicMock, MagicMock]: + lidarr_repo = MagicMock() + request_queue = MagicMock() + request_history = MagicMock() + + request_queue.add = AsyncMock(return_value=queue_add_result or {"message": "queued", "payload": {}}) + request_queue.get_status = MagicMock(return_value={"queue_size": 0, "processing": False}) + request_history.async_record_request = AsyncMock() + + service = RequestService(lidarr_repo, request_queue, request_history) + return service, request_queue, request_history + + +@pytest.mark.asyncio +async def test_request_album_records_history_and_returns_response(): + service, request_queue, request_history = _make_service( + { + "message": "Album queued", + "payload": { + "id": 42, + "title": "Album X", + "foreignAlbumId": "rg-123", + "artist": {"artistName": "Artist X", "foreignArtistId": "artist-123"}, + }, + } + ) + + response = await service.request_album("rg-123", artist="Fallback Artist", album="Fallback Album", year=2024) + + assert response.success is True + assert response.message == "Album queued" + assert isinstance(response.lidarr_response, dict) + assert response.lidarr_response["id"] == 42 + + request_queue.add.assert_awaited_once_with("rg-123") + request_history.async_record_request.assert_awaited_once() + kwargs = request_history.async_record_request.await_args.kwargs + assert kwargs["artist_name"] == "Artist X" + assert kwargs["album_title"] == "Album X" + assert kwargs["artist_mbid"] == "artist-123" + + +@pytest.mark.asyncio +async def test_request_album_wraps_errors(): + service, request_queue, _ = _make_service() + request_queue.add.side_effect = RuntimeError("boom") + + with pytest.raises(ExternalServiceError): + await service.request_album("rg-123") + + +def test_get_queue_status_returns_schema(): + service, request_queue, _ = _make_service() + request_queue.get_status.return_value = {"queue_size": 3, "processing": True} + + status = service.get_queue_status() + + assert status.queue_size == 3 + assert status.processing is True diff --git a/backend/tests/services/test_scrobble_service.py b/backend/tests/services/test_scrobble_service.py new file mode 100644 index 0000000..cd90977 --- /dev/null +++ b/backend/tests/services/test_scrobble_service.py @@ -0,0 +1,220 @@ +import time + +import pytest +from unittest.mock import AsyncMock, MagicMock, PropertyMock + +from api.v1.schemas.scrobble import NowPlayingRequest, ScrobbleRequest +from api.v1.schemas.settings import ScrobbleSettings, LastFmConnectionSettings, ListenBrainzConnectionSettings +from services.scrobble_service import ScrobbleService + + +def _make_lastfm_settings(enabled: bool = True, has_creds: bool = True) -> LastFmConnectionSettings: + return LastFmConnectionSettings( + api_key="key" if has_creds else "", + shared_secret="secret" if has_creds else "", + session_key="sk-123" if has_creds else "", + username="user", + enabled=enabled, + ) + + +def _make_lb_settings(enabled: bool = True, has_token: bool = True) -> ListenBrainzConnectionSettings: + return ListenBrainzConnectionSettings( + user_token="tok-abc" if has_token else "", + enabled=enabled, + ) + + +def _make_service( + lastfm_enabled: bool = True, + lb_enabled: bool = True, + scrobble_lastfm: bool = True, + scrobble_lb: bool = True, +) -> tuple[ScrobbleService, AsyncMock, AsyncMock, MagicMock]: + lastfm_repo = AsyncMock() + lb_repo = AsyncMock() + prefs = MagicMock() + prefs.get_scrobble_settings.return_value = ScrobbleSettings( + scrobble_to_lastfm=scrobble_lastfm, + scrobble_to_listenbrainz=scrobble_lb, + ) + prefs.get_lastfm_connection.return_value = _make_lastfm_settings(enabled=lastfm_enabled) + prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled) + service = ScrobbleService(lastfm_repo, lb_repo, prefs) + return service, lastfm_repo, lb_repo, prefs + + +def _now_playing_req(**overrides) -> NowPlayingRequest: + defaults = dict(track_name="Song", artist_name="Artist", album_name="Album", duration_ms=200_000) + defaults.update(overrides) + return NowPlayingRequest(**defaults) + + +def _scrobble_req(**overrides) -> ScrobbleRequest: + defaults = dict( + track_name="Song", + artist_name="Artist", + album_name="Album", + timestamp=int(time.time()) - 60, + duration_ms=200_000, + ) + defaults.update(overrides) + return ScrobbleRequest(**defaults) + + +class TestReportNowPlaying: + @pytest.mark.asyncio + async def test_dispatches_to_both_services(self): + service, lastfm, lb, _ = _make_service() + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is True + assert "lastfm" in result.services + assert "listenbrainz" in result.services + lastfm.update_now_playing.assert_awaited_once() + lb.submit_now_playing.assert_awaited_once() + + @pytest.mark.asyncio + async def test_dispatches_only_to_lastfm(self): + service, lastfm, lb, _ = _make_service(scrobble_lb=False) + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is True + assert "lastfm" in result.services + assert "listenbrainz" not in result.services + lb.submit_now_playing.assert_not_awaited() + + @pytest.mark.asyncio + async def test_dispatches_only_to_listenbrainz(self): + service, lastfm, lb, _ = _make_service(scrobble_lastfm=False) + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is True + assert "listenbrainz" in result.services + assert "lastfm" not in result.services + lastfm.update_now_playing.assert_not_awaited() + + @pytest.mark.asyncio + async def test_no_services_enabled(self): + service, lastfm, lb, _ = _make_service(scrobble_lastfm=False, scrobble_lb=False) + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is False + assert result.services == {} + + @pytest.mark.asyncio + async def test_lastfm_failure_isolated(self): + service, lastfm, lb, _ = _make_service() + lastfm.update_now_playing.side_effect = RuntimeError("API down") + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is True + assert result.services["lastfm"].success is False + assert "API down" in (result.services["lastfm"].error or "") + assert result.services["listenbrainz"].success is True + + @pytest.mark.asyncio + async def test_all_services_fail(self): + service, lastfm, lb, _ = _make_service() + lastfm.update_now_playing.side_effect = RuntimeError("fail1") + lb.submit_now_playing.side_effect = RuntimeError("fail2") + result = await service.report_now_playing(_now_playing_req()) + assert result.accepted is False + assert result.services["lastfm"].success is False + assert result.services["listenbrainz"].success is False + + +class TestSubmitScrobble: + @pytest.mark.asyncio + async def test_dispatches_to_both_services(self): + service, lastfm, lb, _ = _make_service() + result = await service.submit_scrobble(_scrobble_req()) + assert result.accepted is True + lastfm.scrobble.assert_awaited_once() + lb.submit_single_listen.assert_awaited_once() + + @pytest.mark.asyncio + async def test_skips_short_track(self): + service, lastfm, lb, _ = _make_service() + result = await service.submit_scrobble(_scrobble_req(duration_ms=15_000)) + assert result.accepted is False + assert result.services == {} + lastfm.scrobble.assert_not_awaited() + lb.submit_single_listen.assert_not_awaited() + + @pytest.mark.asyncio + async def test_zero_duration_not_skipped(self): + service, lastfm, lb, _ = _make_service() + result = await service.submit_scrobble(_scrobble_req(duration_ms=0)) + assert result.accepted is True + lastfm.scrobble.assert_awaited_once() + + @pytest.mark.asyncio + async def test_dedup_blocks_second_submit(self): + service, lastfm, lb, _ = _make_service() + ts = int(time.time()) - 60 + req = _scrobble_req(timestamp=ts) + result1 = await service.submit_scrobble(req) + assert result1.accepted is True + + req2 = _scrobble_req(timestamp=ts) + result2 = await service.submit_scrobble(req2) + assert result2.accepted is True + assert result2.services == {} + assert lastfm.scrobble.await_count == 1 + + @pytest.mark.asyncio + async def test_dedup_different_timestamp_allowed(self): + service, lastfm, lb, _ = _make_service() + ts = int(time.time()) - 600 + await service.submit_scrobble(_scrobble_req(timestamp=ts)) + await service.submit_scrobble(_scrobble_req(timestamp=ts + 300)) + assert lastfm.scrobble.await_count == 2 + + @pytest.mark.asyncio + async def test_failure_isolation(self): + service, lastfm, lb, _ = _make_service() + lastfm.scrobble.side_effect = RuntimeError("network") + result = await service.submit_scrobble(_scrobble_req()) + assert result.accepted is True + assert result.services["lastfm"].success is False + assert result.services["listenbrainz"].success is True + + @pytest.mark.asyncio + async def test_failed_scrobble_not_deduped(self): + service, lastfm, lb, _ = _make_service(scrobble_lb=False) + lastfm.scrobble.side_effect = RuntimeError("fail") + ts = int(time.time()) - 60 + result1 = await service.submit_scrobble(_scrobble_req(timestamp=ts)) + assert result1.accepted is False + + lastfm.scrobble.side_effect = None + result2 = await service.submit_scrobble(_scrobble_req(timestamp=ts)) + assert result2.accepted is True + + @pytest.mark.asyncio + async def test_disabled_lastfm_no_creds(self): + service, lastfm, lb, _ = _make_service(lastfm_enabled=False) + result = await service.submit_scrobble(_scrobble_req()) + assert "lastfm" not in result.services + lastfm.scrobble.assert_not_awaited() + + +class TestTimestampValidation: + def test_future_timestamp_rejected(self): + with pytest.raises(ValueError, match="future"): + _scrobble_req(timestamp=int(time.time()) + 3600) + + def test_old_timestamp_rejected(self): + with pytest.raises(ValueError, match="14 days"): + _scrobble_req(timestamp=int(time.time()) - 15 * 86400) + + def test_valid_timestamp_accepted(self): + req = _scrobble_req(timestamp=int(time.time()) - 60) + assert req.timestamp > 0 + + +class TestDedupEviction: + @pytest.mark.asyncio + async def test_evicts_when_exceeding_max(self): + service, _, _, _ = _make_service() + base_ts = int(time.time()) - 86400 + for i in range(205): + req = _scrobble_req(artist_name=f"artist-{i}", timestamp=base_ts + i) + await service.submit_scrobble(req) + assert len(service._dedup_cache) <= 200 diff --git a/backend/tests/services/test_search_audiodb_overlay.py b/backend/tests/services/test_search_audiodb_overlay.py new file mode 100644 index 0000000..c614b4b --- /dev/null +++ b/backend/tests/services/test_search_audiodb_overlay.py @@ -0,0 +1,262 @@ +"""Integration tests for SearchService AudioDB cache overlay. + +Covers the search/list cache-only overlay identified in Phase 3 peer review: +- Artist search results populated with cached AudioDB thumb/fanart/banner +- Album search results populated with cached AudioDB album_thumb_url +- Cache miss leaves fields as None +- Exception safety: overlay errors do not break search +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.search import SearchResult, SearchResponse +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages +from services.search_service import SearchService + + +TEST_ARTIST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + +ARTIST_IMAGES = AudioDBArtistImages( + thumb_url="https://cdn.example.com/artist_thumb.jpg", + fanart_url="https://cdn.example.com/fanart1.jpg", + banner_url="https://cdn.example.com/banner.jpg", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +ALBUM_IMAGES = AudioDBAlbumImages( + album_thumb_url="https://cdn.example.com/album_thumb.jpg", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +NEGATIVE_ARTIST = AudioDBArtistImages.negative(lookup_source="mbid") +NEGATIVE_ALBUM = AudioDBAlbumImages.negative(lookup_source="mbid") + + +def _artist_result(**overrides) -> SearchResult: + defaults = dict(type="artist", title="Coldplay", musicbrainz_id=TEST_ARTIST_MBID, score=100) + defaults.update(overrides) + return SearchResult(**defaults) + + +def _album_result(**overrides) -> SearchResult: + defaults = dict(type="album", title="Parachutes", musicbrainz_id=TEST_ALBUM_MBID, artist="Coldplay", score=90) + defaults.update(overrides) + return SearchResult(**defaults) + + +def _search_service(audiodb=None) -> SearchService: + mb_repo = MagicMock() + lidarr_repo = MagicMock() + lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) + lidarr_repo.get_queue = AsyncMock(return_value=[]) + coverart_repo = MagicMock() + prefs = MagicMock() + prefs.get_preferences.return_value = MagicMock(secondary_types=[]) + return SearchService(mb_repo, lidarr_repo, coverart_repo, prefs, audiodb) + + +class TestSearchAudioDBOverlayArtist: + """Artist search results should receive cached AudioDB images.""" + + @pytest.mark.asyncio + async def test_artist_gets_cached_thumb_fanart_banner(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url == "https://cdn.example.com/artist_thumb.jpg" + assert results[0].fanart_url == "https://cdn.example.com/fanart1.jpg" + assert results[0].banner_url == "https://cdn.example.com/banner.jpg" + audiodb.get_cached_artist_images.assert_awaited_once_with(TEST_ARTIST_MBID) + + @pytest.mark.asyncio + async def test_artist_cache_miss_leaves_none(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=None) + svc = _search_service(audiodb) + + results = [_artist_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[0].fanart_url is None + assert results[0].banner_url is None + + @pytest.mark.asyncio + async def test_artist_negative_cache_leaves_none(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=NEGATIVE_ARTIST) + svc = _search_service(audiodb) + + results = [_artist_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[0].fanart_url is None + assert results[0].banner_url is None + + @pytest.mark.asyncio + async def test_artist_existing_fields_not_overwritten(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result(thumb_url="https://existing.com/thumb.jpg")] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url == "https://existing.com/thumb.jpg" + assert results[0].fanart_url == "https://cdn.example.com/fanart1.jpg" + + +class TestSearchAudioDBOverlayAlbum: + """Album search results should receive cached AudioDB album_thumb_url.""" + + @pytest.mark.asyncio + async def test_album_gets_cached_thumb(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _search_service(audiodb) + + results = [_album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + audiodb.get_cached_album_images.assert_awaited_once_with(TEST_ALBUM_MBID) + + @pytest.mark.asyncio + async def test_album_cache_miss_leaves_none(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=None) + svc = _search_service(audiodb) + + results = [_album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].album_thumb_url is None + + @pytest.mark.asyncio + async def test_album_negative_cache_leaves_none(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=NEGATIVE_ALBUM) + svc = _search_service(audiodb) + + results = [_album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].album_thumb_url is None + + @pytest.mark.asyncio + async def test_album_existing_thumb_not_overwritten(self): + audiodb = MagicMock() + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _search_service(audiodb) + + results = [_album_result(album_thumb_url="https://existing.com/album.jpg")] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].album_thumb_url == "https://existing.com/album.jpg" + + +class TestSearchAudioDBOverlayMixed: + """Mixed artist+album results and edge cases.""" + + @pytest.mark.asyncio + async def test_mixed_results_overlay(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result(), _album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url == "https://cdn.example.com/artist_thumb.jpg" + assert results[1].album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + + @pytest.mark.asyncio + async def test_empty_results_no_error(self): + audiodb = MagicMock() + svc = _search_service(audiodb) + + results: list[SearchResult] = [] + await svc._apply_audiodb_search_overlay(results) + + assert len(results) == 0 + + @pytest.mark.asyncio + async def test_no_audiodb_service_is_noop(self): + svc = _search_service(audiodb=None) + + results = [_artist_result(), _album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[1].album_thumb_url is None + + @pytest.mark.asyncio + async def test_exception_in_one_item_does_not_break_others(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(side_effect=RuntimeError("db error")) + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result(), _album_result()] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[1].album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + + @pytest.mark.asyncio + async def test_empty_mbid_skipped(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _search_service(audiodb) + + results = [_artist_result(musicbrainz_id="")] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + audiodb.get_cached_artist_images.assert_not_awaited() + + +class TestSearchMethodIntegration: + """Verify the overlay is wired into the search() method.""" + + @pytest.mark.asyncio + async def test_search_calls_overlay(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + audiodb.get_cached_album_images = AsyncMock(return_value=ALBUM_IMAGES) + svc = _search_service(audiodb) + + artist = _artist_result() + album = _album_result() + svc._mb_repo.search_grouped = AsyncMock(return_value={"artists": [artist], "albums": [album]}) + + result = await svc.search("coldplay") + + assert result.artists[0].thumb_url == "https://cdn.example.com/artist_thumb.jpg" + assert result.albums[0].album_thumb_url == "https://cdn.example.com/album_thumb.jpg" + + @pytest.mark.asyncio + async def test_search_bucket_calls_overlay(self): + audiodb = MagicMock() + audiodb.get_cached_artist_images = AsyncMock(return_value=ARTIST_IMAGES) + svc = _search_service(audiodb) + + artist = _artist_result() + svc._mb_repo.search_artists = AsyncMock(return_value=[artist]) + + result, _top = await svc.search_bucket("artists", "coldplay") + + assert result[0].thumb_url == "https://cdn.example.com/artist_thumb.jpg" diff --git a/backend/tests/services/test_search_enrichment_service.py b/backend/tests/services/test_search_enrichment_service.py new file mode 100644 index 0000000..39bfd32 --- /dev/null +++ b/backend/tests/services/test_search_enrichment_service.py @@ -0,0 +1,265 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from api.v1.schemas.search import ( + ArtistEnrichmentRequest, + AlbumEnrichmentRequest, + EnrichmentBatchRequest, +) +from api.v1.schemas.settings import ( + ListenBrainzConnectionSettings, + LastFmConnectionSettings, + PrimaryMusicSourceSettings, +) +from services.search_enrichment_service import SearchEnrichmentService + + +def _make_prefs( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> MagicMock: + prefs = MagicMock() + lb_settings = ListenBrainzConnectionSettings( + user_token="tok", username="lbuser", enabled=lb_enabled + ) + prefs.get_listenbrainz_connection.return_value = lb_settings + + lfm_settings = LastFmConnectionSettings( + api_key="key" if lfm_enabled else "", + shared_secret="secret", + session_key="sk", + username="lfmuser", + enabled=lfm_enabled, + ) + prefs.get_lastfm_connection.return_value = lfm_settings + prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings( + source=primary_source + ) + return prefs + + +def _make_service( + lb_enabled: bool = True, + lfm_enabled: bool = False, + primary_source: str = "listenbrainz", +) -> tuple[SearchEnrichmentService, AsyncMock, AsyncMock, AsyncMock]: + mb_repo = AsyncMock() + mb_repo.get_artist_release_groups = AsyncMock(return_value=([], 5)) + + lb_repo = AsyncMock() + lb_repo.get_artist_top_release_groups = AsyncMock(return_value=[]) + lb_repo.get_release_group_popularity_batch = AsyncMock(return_value={}) + + lfm_repo = AsyncMock() + lfm_repo.get_artist_info = AsyncMock(return_value=None) + lfm_repo.get_album_info = AsyncMock(return_value=None) + + prefs = _make_prefs( + lb_enabled=lb_enabled, lfm_enabled=lfm_enabled, primary_source=primary_source + ) + + service = SearchEnrichmentService( + mb_repo=mb_repo, + lb_repo=lb_repo, + preferences_service=prefs, + lastfm_repo=lfm_repo, + ) + return service, mb_repo, lb_repo, lfm_repo + + +class TestSourceSelection: + def test_source_listenbrainz_when_lb_enabled(self): + service, _, _, _ = _make_service(lb_enabled=True, lfm_enabled=False) + assert service._get_enrichment_source() == "listenbrainz" + + def test_source_lastfm_when_lfm_preferred(self): + service, _, _, _ = _make_service( + lb_enabled=True, lfm_enabled=True, primary_source="lastfm" + ) + assert service._get_enrichment_source() == "lastfm" + + def test_source_none_when_nothing_enabled(self): + service, _, _, _ = _make_service(lb_enabled=False, lfm_enabled=False) + assert service._get_enrichment_source() == "none" + + def test_fallback_lb_when_lastfm_preferred_but_disabled(self): + service, _, _, _ = _make_service( + lb_enabled=True, lfm_enabled=False, primary_source="lastfm" + ) + assert service._get_enrichment_source() == "listenbrainz" + + def test_fallback_lastfm_when_lb_preferred_but_disabled(self): + service, _, _, _ = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="listenbrainz" + ) + assert service._get_enrichment_source() == "lastfm" + + +class TestEnrichBatch: + @pytest.mark.asyncio + async def test_listenbrainz_enrichment_path(self): + service, mb_repo, lb_repo, lfm_repo = _make_service( + lb_enabled=True, lfm_enabled=False + ) + lb_repo.get_release_group_popularity_batch.return_value = {"album-1": 1000} + + request = EnrichmentBatchRequest( + artists=[ArtistEnrichmentRequest(musicbrainz_id="art-1", name="Muse")], + albums=[ + AlbumEnrichmentRequest( + musicbrainz_id="album-1", artist_name="Muse", album_name="Absolution" + ) + ], + ) + result = await service.enrich_batch(request) + + assert result.source == "listenbrainz" + assert len(result.artists) == 1 + assert result.artists[0].release_group_count == 5 + assert len(result.albums) == 1 + assert result.albums[0].listen_count == 1000 + lb_repo.get_release_group_popularity_batch.assert_awaited_once() + lfm_repo.get_album_info.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lastfm_enrichment_path(self): + service, mb_repo, lb_repo, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + artist_info = MagicMock() + artist_info.listeners = 500000 + lfm_repo.get_artist_info.return_value = artist_info + + album_info = MagicMock() + album_info.playcount = 1200000 + lfm_repo.get_album_info.return_value = album_info + + request = EnrichmentBatchRequest( + artists=[ArtistEnrichmentRequest(musicbrainz_id="art-1", name="Muse")], + albums=[ + AlbumEnrichmentRequest( + musicbrainz_id="album-1", artist_name="Muse", album_name="Absolution" + ) + ], + ) + result = await service.enrich_batch(request) + + assert result.source == "lastfm" + assert result.artists[0].listen_count == 500000 + assert result.albums[0].listen_count == 1200000 + lb_repo.get_release_group_popularity_batch.assert_not_awaited() + lfm_repo.get_artist_info.assert_awaited_once() + lfm_repo.get_album_info.assert_awaited_once() + + @pytest.mark.asyncio + async def test_lastfm_zero_artist_listeners_preserved(self): + service, _, _, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + artist_info = MagicMock() + artist_info.listeners = 0 + lfm_repo.get_artist_info.return_value = artist_info + + request = EnrichmentBatchRequest( + artists=[ArtistEnrichmentRequest(musicbrainz_id="art-1", name="Obscure")], + albums=[], + ) + result = await service.enrich_batch(request) + + assert result.artists[0].listen_count == 0 + + @pytest.mark.asyncio + async def test_lastfm_zero_album_playcount_preserved(self): + service, _, _, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + album_info = MagicMock() + album_info.playcount = 0 + lfm_repo.get_album_info.return_value = album_info + + request = EnrichmentBatchRequest( + artists=[], + albums=[ + AlbumEnrichmentRequest( + musicbrainz_id="album-1", artist_name="Obscure", album_name="Debut" + ) + ], + ) + result = await service.enrich_batch(request) + + assert result.albums[0].listen_count == 0 + + @pytest.mark.asyncio + async def test_none_source_returns_release_counts_only(self): + service, mb_repo, lb_repo, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=False + ) + + request = EnrichmentBatchRequest( + artists=[ArtistEnrichmentRequest(musicbrainz_id="art-1", name="Muse")], + albums=[ + AlbumEnrichmentRequest( + musicbrainz_id="album-1", artist_name="Muse", album_name="Absolution" + ) + ], + ) + result = await service.enrich_batch(request) + + assert result.source == "none" + assert result.artists[0].release_group_count == 5 + assert result.artists[0].listen_count is None + assert result.albums[0].listen_count is None + + @pytest.mark.asyncio + async def test_lastfm_artist_enrichment_without_name_skips_lfm(self): + service, _, lb_repo, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + request = EnrichmentBatchRequest( + artists=[ArtistEnrichmentRequest(musicbrainz_id="art-1", name="")], + albums=[], + ) + result = await service.enrich_batch(request) + + assert result.source == "lastfm" + assert result.artists[0].listen_count is None + lfm_repo.get_artist_info.assert_not_awaited() + + @pytest.mark.asyncio + async def test_lastfm_album_without_names_skips_lfm(self): + service, _, _, lfm_repo = _make_service( + lb_enabled=False, lfm_enabled=True, primary_source="lastfm" + ) + + request = EnrichmentBatchRequest( + artists=[], + albums=[ + AlbumEnrichmentRequest( + musicbrainz_id="album-1", artist_name="", album_name="" + ) + ], + ) + result = await service.enrich_batch(request) + + assert result.albums[0].listen_count is None + lfm_repo.get_album_info.assert_not_awaited() + + +class TestLegacyEnrich: + @pytest.mark.asyncio + async def test_legacy_enrich_still_works(self): + service, _, lb_repo, _ = _make_service(lb_enabled=True, lfm_enabled=False) + lb_repo.get_release_group_popularity_batch.return_value = {"album-1": 42} + + result = await service.enrich( + artist_mbids=["art-1"], + album_mbids=["album-1"], + ) + + assert result.source == "listenbrainz" + assert len(result.artists) == 1 + assert len(result.albums) == 1 + assert result.albums[0].listen_count == 42 diff --git a/backend/tests/services/test_search_service.py b/backend/tests/services/test_search_service.py new file mode 100644 index 0000000..7b68724 --- /dev/null +++ b/backend/tests/services/test_search_service.py @@ -0,0 +1,324 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock +import asyncio + +from api.v1.schemas.search import SearchResult, SuggestResponse +from services.search_service import SearchService + + +def _make_search_result( + type: str, + title: str, + score: int = 0, + musicbrainz_id: str = "", + artist: str | None = None, + year: int | None = None, + disambiguation: str | None = None, +) -> SearchResult: + return SearchResult( + type=type, + title=title, + musicbrainz_id=musicbrainz_id or f"mbid-{title.lower().replace(' ', '-')}", + score=score, + artist=artist, + year=year, + disambiguation=disambiguation, + in_library=False, + requested=False, + ) + + +def _make_preferences(secondary_types: list[str] | None = None) -> MagicMock: + prefs = MagicMock() + prefs.secondary_types = secondary_types or [] + return prefs + + +def _make_service( + grouped: dict[str, list[SearchResult]] | None = None, + library_mbids: set[str] | None = None, + queue_items: list | None = None, + mb_error: Exception | None = None, + lidarr_library_error: Exception | None = None, + lidarr_queue_error: Exception | None = None, +) -> SearchService: + mb_repo = MagicMock() + if mb_error: + mb_repo.search_grouped = AsyncMock(side_effect=mb_error) + else: + mb_repo.search_grouped = AsyncMock(return_value=grouped or {"artists": [], "albums": []}) + + lidarr_repo = MagicMock() + if lidarr_library_error: + lidarr_repo.get_library_mbids = AsyncMock(side_effect=lidarr_library_error) + else: + lidarr_repo.get_library_mbids = AsyncMock(return_value=library_mbids or set()) + + if lidarr_queue_error: + lidarr_repo.get_queue = AsyncMock(side_effect=lidarr_queue_error) + else: + lidarr_repo.get_queue = AsyncMock(return_value=queue_items or []) + + coverart_repo = MagicMock() + preferences_service = MagicMock() + preferences_service.get_preferences.return_value = _make_preferences() + + return SearchService( + mb_repo=mb_repo, + lidarr_repo=lidarr_repo, + coverart_repo=coverart_repo, + preferences_service=preferences_service, + ) + + +@pytest.mark.asyncio +async def test_suggest_returns_suggest_response(): + artists = [_make_search_result("artist", "Muse", score=90)] + albums = [_make_search_result("album", "Origin of Symmetry", score=85, artist="Muse")] + svc = _make_service(grouped={"artists": artists, "albums": albums}) + + result = await svc.suggest(query="muse", limit=5) + + assert isinstance(result, SuggestResponse) + assert len(result.results) == 2 + assert result.results[0].title == "Muse" + assert result.results[1].title == "Origin of Symmetry" + + +@pytest.mark.asyncio +async def test_suggest_score_interleaving(): + artists = [ + _make_search_result("artist", "Artist A", score=90), + _make_search_result("artist", "Artist B", score=80), + ] + albums = [ + _make_search_result("album", "Album X", score=95, artist="X"), + _make_search_result("album", "Album Y", score=85, artist="Y"), + ] + svc = _make_service(grouped={"artists": artists, "albums": albums}) + + result = await svc.suggest(query="test", limit=5) + + assert len(result.results) == 4 + assert result.results[0].title == "Album X" + assert result.results[0].score == 95 + assert result.results[1].title == "Artist A" + assert result.results[1].score == 90 + assert result.results[2].title == "Album Y" + assert result.results[2].score == 85 + assert result.results[3].title == "Artist B" + assert result.results[3].score == 80 + + +@pytest.mark.asyncio +async def test_suggest_equal_score_artist_before_album(): + artists = [_make_search_result("artist", "Bee", score=80)] + albums = [_make_search_result("album", "Ant", score=80, artist="Someone")] + svc = _make_service(grouped={"artists": artists, "albums": albums}) + + result = await svc.suggest(query="test", limit=5) + + assert len(result.results) == 2 + assert result.results[0].type == "artist" + assert result.results[0].title == "Bee" + assert result.results[1].type == "album" + assert result.results[1].title == "Ant" + + +@pytest.mark.asyncio +async def test_suggest_alphabetical_tiebreak_within_same_type(): + artists = [ + _make_search_result("artist", "Zebra", score=80), + _make_search_result("artist", "Alpha", score=80), + ] + svc = _make_service(grouped={"artists": artists, "albums": []}) + + result = await svc.suggest(query="test", limit=5) + + assert len(result.results) == 2 + assert result.results[0].title == "Alpha" + assert result.results[1].title == "Zebra" + + +@pytest.mark.asyncio +async def test_suggest_truncates_to_limit(): + artists = [ + _make_search_result("artist", f"Artist {i}", score=100 - i) + for i in range(3) + ] + albums = [ + _make_search_result("album", f"Album {i}", score=99 - i, artist="X") + for i in range(3) + ] + svc = _make_service(grouped={"artists": artists, "albums": albums}) + + result = await svc.suggest(query="test", limit=4) + + assert len(result.results) == 4 + + +@pytest.mark.asyncio +async def test_suggest_lidarr_failure_returns_default_flags(): + artists = [_make_search_result("artist", "Muse", score=90)] + albums = [ + _make_search_result("album", "Absolution", score=85, artist="Muse", + musicbrainz_id="album-1"), + ] + svc = _make_service( + grouped={"artists": artists, "albums": albums}, + lidarr_library_error=Exception("Lidarr unavailable"), + lidarr_queue_error=Exception("Lidarr unavailable"), + ) + + result = await svc.suggest(query="muse", limit=5) + + assert len(result.results) == 2 + for r in result.results: + assert r.in_library is False + assert r.requested is False + + +@pytest.mark.asyncio +async def test_suggest_musicbrainz_failure_returns_empty(): + svc = _make_service(mb_error=Exception("MusicBrainz down")) + + result = await svc.suggest(query="muse", limit=5) + + assert isinstance(result, SuggestResponse) + assert len(result.results) == 0 + + +@pytest.mark.asyncio +async def test_suggest_query_normalization(): + artists = [_make_search_result("artist", "Muse", score=90)] + svc = _make_service(grouped={"artists": artists, "albums": []}) + + result = await svc.suggest(query=" muse ", limit=5) + + assert len(result.results) == 1 + assert result.results[0].title == "Muse" + svc._mb_repo.search_grouped.assert_called_once() + call_args = svc._mb_repo.search_grouped.call_args + assert call_args[0][0] == "muse" + + +@pytest.mark.asyncio +async def test_suggest_in_library_flag(): + albums = [ + _make_search_result("album", "Absolution", score=85, artist="Muse", + musicbrainz_id="album-lib-1"), + ] + svc = _make_service( + grouped={"artists": [], "albums": albums}, + library_mbids={"album-lib-1"}, + ) + + result = await svc.suggest(query="absolution", limit=5) + + assert len(result.results) == 1 + assert result.results[0].in_library is True + assert result.results[0].requested is False + + +@pytest.mark.asyncio +async def test_suggest_requested_flag(): + albums = [ + _make_search_result("album", "Absolution", score=85, artist="Muse", + musicbrainz_id="album-q-1"), + ] + queue_item = MagicMock() + queue_item.musicbrainz_id = "album-q-1" + svc = _make_service( + grouped={"artists": [], "albums": albums}, + queue_items=[queue_item], + ) + + result = await svc.suggest(query="absolution", limit=5) + + assert len(result.results) == 1 + assert result.results[0].in_library is False + assert result.results[0].requested is True + + +@pytest.mark.asyncio +async def test_suggest_whitespace_only_query_returns_empty(): + """Whitespace-padded query that becomes too short after strip returns empty.""" + svc = _make_service(grouped={"artists": [], "albums": []}) + + result = await svc.suggest(query=" a ", limit=5) + + assert isinstance(result, SuggestResponse) + assert len(result.results) == 0 + svc._mb_repo.search_grouped.assert_not_called() + + +@pytest.mark.asyncio +async def test_suggest_single_char_after_strip_returns_empty(): + """Single-char query after stripping returns empty without calling MusicBrainz.""" + svc = _make_service(grouped={"artists": [], "albums": []}) + + result = await svc.suggest(query="x", limit=5) + + assert isinstance(result, SuggestResponse) + assert len(result.results) == 0 + svc._mb_repo.search_grouped.assert_not_called() + + +@pytest.mark.asyncio +async def test_suggest_case_insensitive_alphabetical_tiebreak(): + """Alphabetical tiebreak is case-insensitive: 'alpha' before 'Bravo'.""" + artists = [ + _make_search_result("artist", "Bravo", score=80), + _make_search_result("artist", "alpha", score=80), + ] + svc = _make_service(grouped={"artists": artists, "albums": []}) + + result = await svc.suggest(query="test", limit=5) + + assert len(result.results) == 2 + assert result.results[0].title == "alpha" + assert result.results[1].title == "Bravo" + + +@pytest.mark.asyncio +async def test_suggest_deduplication_single_mb_call(): + """Concurrent suggest calls with same normalized query produce only one MusicBrainz call.""" + artists = [_make_search_result("artist", "Muse", score=90)] + + call_event = asyncio.Event() + call_count = 0 + + async def slow_search_grouped(*args, **kwargs): + nonlocal call_count + call_count += 1 + await call_event.wait() + return {"artists": artists, "albums": []} + + mb_repo = MagicMock() + mb_repo.search_grouped = slow_search_grouped + + lidarr_repo = MagicMock() + lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) + lidarr_repo.get_queue = AsyncMock(return_value=[]) + + coverart_repo = MagicMock() + preferences_service = MagicMock() + preferences_service.get_preferences.return_value = _make_preferences() + + svc = SearchService( + mb_repo=mb_repo, + lidarr_repo=lidarr_repo, + coverart_repo=coverart_repo, + preferences_service=preferences_service, + ) + + task1 = asyncio.create_task(svc.suggest(query="muse", limit=5)) + task2 = asyncio.create_task(svc.suggest(query="muse", limit=5)) + await asyncio.sleep(0.05) + call_event.set() + + r1, r2 = await asyncio.gather(task1, task2) + + assert call_count == 1 + assert len(r1.results) == 1 + assert len(r2.results) == 1 diff --git a/backend/tests/services/test_search_top_result.py b/backend/tests/services/test_search_top_result.py new file mode 100644 index 0000000..937309c --- /dev/null +++ b/backend/tests/services/test_search_top_result.py @@ -0,0 +1,108 @@ +"""Tests for SearchService._detect_top_result and _tokens_match.""" + +import pytest + +from models.search import SearchResult +from services.search_service import SearchService, TOP_RESULT_SCORE_THRESHOLD + + +def _make_result(title: str, score: int = 100, type: str = "artist") -> SearchResult: + return SearchResult( + type=type, + title=title, + musicbrainz_id="00000000-0000-0000-0000-000000000001", + score=score, + ) + + +class TestTokensMatch: + """Tests for SearchService._tokens_match (prefix-aware token matching).""" + + def test_exact_match(self) -> None: + assert SearchService._tokens_match({"taylor", "swift"}, {"taylor", "swift"}) is True + + def test_query_subset_of_title(self) -> None: + assert SearchService._tokens_match({"taylor"}, {"taylor", "swift"}) is True + + def test_title_subset_of_query(self) -> None: + assert SearchService._tokens_match({"taylor", "swift"}, {"swift"}) is True + + def test_prefix_match_query_partial(self) -> None: + assert SearchService._tokens_match({"taylor", "swif"}, {"taylor", "swift"}) is True + + def test_prefix_match_single_partial(self) -> None: + assert SearchService._tokens_match({"tay"}, {"taylor", "swift"}) is True + + def test_single_char_prefix_rejected(self) -> None: + assert SearchService._tokens_match({"t"}, {"taylor", "swift"}) is False + + def test_prefix_match_title_shorter(self) -> None: + assert SearchService._tokens_match({"taylor", "swift"}, {"tay"}) is True + + def test_no_match(self) -> None: + assert SearchService._tokens_match({"radiohead"}, {"taylor", "swift"}) is False + + def test_partial_overlap_no_prefix(self) -> None: + assert SearchService._tokens_match({"taylor", "jones"}, {"taylor", "swift"}) is False + + def test_empty_sets(self) -> None: + # all() on empty iterable is vacuously True; _detect_top_result guards empty tokens + assert SearchService._tokens_match(set(), {"taylor"}) is True + assert SearchService._tokens_match({"taylor"}, set()) is True + assert SearchService._tokens_match(set(), set()) is True + + +class TestDetectTopResult: + """Tests for SearchService._detect_top_result.""" + + def test_returns_none_for_empty_results(self) -> None: + assert SearchService._detect_top_result([], "taylor swift") is None + + def test_returns_none_below_threshold(self) -> None: + result = _make_result("Taylor Swift", score=TOP_RESULT_SCORE_THRESHOLD - 1) + assert SearchService._detect_top_result([result], "taylor swift") is None + + def test_returns_result_at_threshold(self) -> None: + result = _make_result("Taylor Swift", score=TOP_RESULT_SCORE_THRESHOLD) + top = SearchService._detect_top_result([result], "taylor swift") + assert top is result + + def test_exact_query_match(self) -> None: + result = _make_result("Taylor Swift", score=100) + top = SearchService._detect_top_result([result], "Taylor Swift") + assert top is result + + def test_partial_query_prefix_match(self) -> None: + result = _make_result("Taylor Swift", score=100) + top = SearchService._detect_top_result([result], "Taylor Swif") + assert top is result + + def test_single_token_prefix(self) -> None: + result = _make_result("Taylor Swift", score=100) + top = SearchService._detect_top_result([result], "Tay") + assert top is result + + def test_query_superset_of_title(self) -> None: + result = _make_result("Swift", score=95) + top = SearchService._detect_top_result([result], "Taylor Swift") + assert top is result + + def test_no_token_overlap(self) -> None: + result = _make_result("Radiohead", score=95) + top = SearchService._detect_top_result([result], "Taylor Swift") + assert top is None + + def test_only_checks_first_result(self) -> None: + low = _make_result("Radiohead", score=80) + high = _make_result("Taylor Swift", score=100) + assert SearchService._detect_top_result([low, high], "Taylor Swift") is None + + def test_diacritics_normalized(self) -> None: + result = _make_result("Beyoncé", score=100) + top = SearchService._detect_top_result([result], "beyonce") + assert top is result + + def test_album_type(self) -> None: + result = _make_result("Midnights", score=95, type="album") + top = SearchService._detect_top_result([result], "Midnights") + assert top is result diff --git a/backend/tests/services/test_settings_cache_invalidation.py b/backend/tests/services/test_settings_cache_invalidation.py new file mode 100644 index 0000000..5476f4d --- /dev/null +++ b/backend/tests/services/test_settings_cache_invalidation.py @@ -0,0 +1,170 @@ +"""Integration tests — SettingsService invalidation methods clear the right cache keys.""" + +import pytest + +from infrastructure.cache.cache_keys import ( + ALBUM_INFO_PREFIX, + ARTIST_INFO_PREFIX, + DISCOVER_RESPONSE_PREFIX, + GENRE_ARTIST_PREFIX, + HOME_RESPONSE_PREFIX, + JELLYFIN_PREFIX, + LB_PREFIX, + LFM_PREFIX, + LOCAL_FILES_PREFIX, + SOURCE_RESOLUTION_PREFIX, + musicbrainz_prefixes, +) +from infrastructure.cache.memory_cache import InMemoryCache +from services.settings_service import SettingsService + + +async def _build_service() -> tuple[SettingsService, InMemoryCache]: + cache = InMemoryCache(max_entries=500) + service = SettingsService(preferences_service=None, cache=cache) + return service, cache + + +async def _populate(cache: InMemoryCache, keys: list[str]) -> None: + for key in keys: + await cache.set(key, "v", ttl_seconds=300) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_clear_musicbrainz_cache(): + service, cache = await _build_service() + + mb_keys = [f"{p}dummy" for p in musicbrainz_prefixes()] + extra_keys = [f"{ARTIST_INFO_PREFIX}art1", f"{ALBUM_INFO_PREFIX}alb1"] + unrelated = ["unrelated:key"] + await _populate(cache, mb_keys + extra_keys + unrelated) + + cleared = await service.clear_caches_for_preference_change() + + assert cleared == len(mb_keys) + len(extra_keys) + for key in mb_keys + extra_keys: + assert await cache.get(key) is None + assert await cache.get("unrelated:key") == "v" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_clear_home_cache(): + service, cache = await _build_service() + + home_keys = [ + f"{HOME_RESPONSE_PREFIX}page1", + f"{DISCOVER_RESPONSE_PREFIX}rock", + f"{GENRE_ARTIST_PREFIX}pop", + f"{JELLYFIN_PREFIX}lib", + f"{LB_PREFIX}stats", + f"{LFM_PREFIX}chart", + ] + unrelated = ["unrelated:key"] + await _populate(cache, home_keys + unrelated) + + cleared = await service.clear_home_cache() + + assert cleared == len(home_keys) + for key in home_keys: + assert await cache.get(key) is None + assert await cache.get("unrelated:key") == "v" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_clear_source_resolution_cache(): + """SOURCE_RESOLUTION_PREFIX = 'source_resolution' (no trailing colon) + must match both 'source_resolution:x' and 'source_resolution_tracks:y'.""" + service, cache = await _build_service() + + sr_keys = [ + f"{SOURCE_RESOLUTION_PREFIX}:track1", + f"{SOURCE_RESOLUTION_PREFIX}_tracks:track2", + ] + unrelated = ["unrelated:key"] + await _populate(cache, sr_keys + unrelated) + + cleared = await service.clear_source_resolution_cache() + + assert cleared == len(sr_keys) + for key in sr_keys: + assert await cache.get(key) is None + assert await cache.get("unrelated:key") == "v" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_clear_local_files_cache(): + service, cache = await _build_service() + + lf_keys = [f"{LOCAL_FILES_PREFIX}scan1", f"{LOCAL_FILES_PREFIX}scan2"] + unrelated = ["unrelated:key"] + await _populate(cache, lf_keys + unrelated) + + cleared = await service.clear_local_files_cache() + + assert cleared == len(lf_keys) + for key in lf_keys: + assert await cache.get(key) is None + assert await cache.get("unrelated:key") == "v" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_unrelated_keys_survive(): + """Clearing one domain must not affect keys from other domains.""" + service, cache = await _build_service() + + survivor_keys = [ + f"{LOCAL_FILES_PREFIX}scan", + f"{SOURCE_RESOLUTION_PREFIX}:t1", + f"{JELLYFIN_PREFIX}lib", + ] + target_keys = [f"{p}x" for p in musicbrainz_prefixes()] + await _populate(cache, survivor_keys + target_keys) + + await service.clear_caches_for_preference_change() + + for key in survivor_keys: + assert await cache.get(key) == "v", f"Key {key!r} was incorrectly cleared" + + +@pytest.mark.asyncio(loop_scope="function") +async def test_update_home_settings_route_clears_cache(): + """The update_home_settings route must call clear_home_cache.""" + from unittest.mock import AsyncMock, MagicMock + from api.v1.routes.settings import update_home_settings + + mock_prefs = MagicMock() + mock_settings_svc = AsyncMock() + mock_settings_svc.clear_home_cache = AsyncMock(return_value=5) + mock_settings_obj = MagicMock() + + result = await update_home_settings( + settings=mock_settings_obj, + preferences_service=mock_prefs, + settings_service=mock_settings_svc, + ) + + mock_prefs.save_home_settings.assert_called_once_with(mock_settings_obj) + mock_settings_svc.clear_home_cache.assert_awaited_once() + assert result is mock_settings_obj + + +@pytest.mark.asyncio(loop_scope="function") +async def test_youtube_settings_change_clears_home_cache(): + """on_youtube_settings_changed should reset singleton AND clear home caches.""" + from unittest.mock import patch, MagicMock + + service, cache = await _build_service() + + home_keys = [ + f"{HOME_RESPONSE_PREFIX}page1", + f"{DISCOVER_RESPONSE_PREFIX}rock", + ] + await _populate(cache, home_keys) + + mock_repo_fn = MagicMock() + with patch("core.dependencies.get_youtube_repo", mock_repo_fn): + await service.on_youtube_settings_changed() + + mock_repo_fn.cache_clear.assert_called_once() + for key in home_keys: + assert await cache.get(key) is None diff --git a/backend/tests/services/test_settings_verify_methods.py b/backend/tests/services/test_settings_verify_methods.py new file mode 100644 index 0000000..fcedeff --- /dev/null +++ b/backend/tests/services/test_settings_verify_methods.py @@ -0,0 +1,180 @@ +"""Tests for SettingsService.verify_navidrome / verify_youtube / verify_lastfm.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services.settings_service import ( + SettingsService, + NavidromeVerifyResult, + YouTubeVerifyResult, + LastFmVerifyResult, +) + + +def _make_service(*, preferences=None): + prefs = preferences or MagicMock() + cache = MagicMock() + cache.clear_by_prefix = AsyncMock(return_value=0) + service = SettingsService( + preferences_service=prefs, + cache=cache, + ) + return service + + +@pytest.mark.asyncio +async def test_verify_navidrome_success(): + prefs = MagicMock() + raw = MagicMock() + raw.password = "real-password" + prefs.get_navidrome_connection_raw = MagicMock(return_value=raw) + + service = _make_service(preferences=prefs) + + from api.v1.schemas.settings import NavidromeConnectionSettings + settings = NavidromeConnectionSettings( + enabled=True, + navidrome_url="http://navidrome.local", + username="admin", + password="••••••••", + ) + + mock_repo_instance = MagicMock() + mock_repo_instance.ping = AsyncMock(return_value=True) + + with patch("infrastructure.validators.validate_service_url"), \ + patch("services.settings_service.get_settings", return_value=MagicMock()), \ + patch("services.settings_service.get_http_client", return_value=MagicMock()), \ + patch("repositories.navidrome_repository.NavidromeRepository") as MockRepo: + MockRepo.return_value = mock_repo_instance + MockRepo.reset_circuit_breaker = MagicMock() + + result = await service.verify_navidrome(settings) + + assert isinstance(result, NavidromeVerifyResult) + assert result.valid is True + assert "success" in result.message.lower() + + +@pytest.mark.asyncio +async def test_verify_navidrome_ping_fail(): + prefs = MagicMock() + raw = MagicMock() + raw.password = "real-password" + prefs.get_navidrome_connection_raw = MagicMock(return_value=raw) + + service = _make_service(preferences=prefs) + + from api.v1.schemas.settings import NavidromeConnectionSettings + settings = NavidromeConnectionSettings( + enabled=True, + navidrome_url="http://navidrome.local", + username="admin", + password="real-password", + ) + + mock_repo_instance = MagicMock() + mock_repo_instance.ping = AsyncMock(return_value=False) + + with patch("infrastructure.validators.validate_service_url"), \ + patch("services.settings_service.get_settings", return_value=MagicMock()), \ + patch("services.settings_service.get_http_client", return_value=MagicMock()), \ + patch("repositories.navidrome_repository.NavidromeRepository") as MockRepo: + MockRepo.return_value = mock_repo_instance + MockRepo.reset_circuit_breaker = MagicMock() + + result = await service.verify_navidrome(settings) + + assert result.valid is False + + +@pytest.mark.asyncio +async def test_verify_youtube_success(): + service = _make_service() + + from api.v1.schemas.settings import YouTubeConnectionSettings + settings = YouTubeConnectionSettings( + enabled=True, + api_key="test-key", + daily_quota_limit=100, + ) + + mock_repo_instance = MagicMock() + mock_repo_instance.verify_api_key = AsyncMock(return_value=(True, "Valid")) + + with patch("services.settings_service.get_settings", return_value=MagicMock()), \ + patch("services.settings_service.get_http_client", return_value=MagicMock()), \ + patch("repositories.youtube.YouTubeRepository") as MockRepo: + MockRepo.return_value = mock_repo_instance + + result = await service.verify_youtube(settings) + + assert isinstance(result, YouTubeVerifyResult) + assert result.valid is True + + +@pytest.mark.asyncio +async def test_verify_lastfm_api_key_invalid(): + prefs = MagicMock() + current = MagicMock() + current.shared_secret = "real-secret" + current.session_key = "" + prefs.get_lastfm_connection = MagicMock(return_value=current) + + service = _make_service(preferences=prefs) + + from api.v1.schemas.settings import LastFmConnectionSettings + settings = LastFmConnectionSettings( + enabled=True, + api_key="bad-key", + shared_secret="real-secret", + session_key="", + ) + + mock_repo_instance = MagicMock() + mock_repo_instance.validate_api_key = AsyncMock(return_value=(False, "Invalid API key")) + + with patch("services.settings_service.get_settings", return_value=MagicMock()), \ + patch("services.settings_service.get_http_client", return_value=MagicMock()), \ + patch("repositories.lastfm_repository.LastFmRepository") as MockRepo: + MockRepo.return_value = mock_repo_instance + + result = await service.verify_lastfm(settings) + + assert isinstance(result, LastFmVerifyResult) + assert result.valid is False + assert "invalid" in result.message.lower() + + +@pytest.mark.asyncio +async def test_verify_lastfm_with_session_key(): + prefs = MagicMock() + current = MagicMock() + current.shared_secret = "real-secret" + current.session_key = "real-session-key" + prefs.get_lastfm_connection = MagicMock(return_value=current) + + service = _make_service(preferences=prefs) + + from api.v1.schemas.settings import LastFmConnectionSettings, LASTFM_SECRET_MASK + settings = LastFmConnectionSettings( + enabled=True, + api_key="good-key", + shared_secret=LASTFM_SECRET_MASK, + session_key=LASTFM_SECRET_MASK, + ) + + mock_repo_instance = MagicMock() + mock_repo_instance.validate_api_key = AsyncMock(return_value=(True, "OK")) + mock_repo_instance.validate_session = AsyncMock(return_value=(True, "Session valid")) + + with patch("services.settings_service.get_settings", return_value=MagicMock()), \ + patch("services.settings_service.get_http_client", return_value=MagicMock()), \ + patch("repositories.lastfm_repository.LastFmRepository") as MockRepo: + MockRepo.return_value = mock_repo_instance + + result = await service.verify_lastfm(settings) + + assert result.valid is True + assert "session" in result.message.lower() diff --git a/backend/tests/services/test_strip_html_tags.py b/backend/tests/services/test_strip_html_tags.py new file mode 100644 index 0000000..b8d3e56 --- /dev/null +++ b/backend/tests/services/test_strip_html_tags.py @@ -0,0 +1,79 @@ +import pytest + +from infrastructure.validators import clean_lastfm_bio, strip_html_tags + + +def test_strip_html_tags_removes_bold(): + assert strip_html_tags("bold text") == "bold text" + + +def test_strip_html_tags_removes_links(): + result = strip_html_tags('Visit example site') + assert result == "Visit example site" + + +def test_strip_html_tags_converts_br_to_newline(): + assert strip_html_tags("line one
line two") == "line one\nline two" + assert strip_html_tags("line one
line two") == "line one\nline two" + + +def test_strip_html_tags_converts_p_end_to_double_newline(): + result = strip_html_tags("

First paragraph

Second paragraph

") + assert "First paragraph" in result + assert "Second paragraph" in result + assert "\n\n" in result + + +def test_strip_html_tags_handles_empty_string(): + assert strip_html_tags("") == "" + + +def test_strip_html_tags_handles_none(): + assert strip_html_tags(None) == "" + + +def test_strip_html_tags_handles_plain_text(): + assert strip_html_tags("just plain text") == "just plain text" + + +def test_strip_html_tags_handles_html_entities(): + assert strip_html_tags("rock & roll") == "rock & roll" + + +def test_strip_html_tags_complex_html(): + html = ( + '

Radiohead are an English rock band from ' + 'Abingdon, Oxfordshire.

' + ) + result = strip_html_tags(html) + assert "<" not in result + assert ">" not in result + assert "Radiohead are an English rock band from Abingdon, Oxfordshire." in result + + + + +def test_clean_lastfm_bio_strips_read_more_suffix(): + html = ( + "Walter Carl Becker was an American musician. " + 'Read more on Last.fm' + ) + result = clean_lastfm_bio(html) + assert "Read more on Last.fm" not in result + assert result == "Walter Carl Becker was an American musician." + + +def test_clean_lastfm_bio_strips_plain_text_suffix(): + text = "Some artist bio. Read more on Last.fm" + result = clean_lastfm_bio(text) + assert result == "Some artist bio." + + +def test_clean_lastfm_bio_no_suffix(): + text = "A clean bio with no Last.fm link." + assert clean_lastfm_bio(text) == text + + +def test_clean_lastfm_bio_empty(): + assert clean_lastfm_bio("") == "" + assert clean_lastfm_bio(None) == "" diff --git a/backend/tests/services/test_weekly_exploration.py b/backend/tests/services/test_weekly_exploration.py new file mode 100644 index 0000000..2d15d0d --- /dev/null +++ b/backend/tests/services/test_weekly_exploration.py @@ -0,0 +1,266 @@ +"""Tests for ListenBrainz weekly exploration (recommendation playlists).""" +import pytest +from unittest.mock import AsyncMock, MagicMock + +import httpx + +from repositories.listenbrainz_repository import ListenBrainzRepository +from repositories.listenbrainz_models import ( + parse_recommendation_track, + ListenBrainzRecommendationTrack, +) + + +def _make_repo(username: str = "testuser", user_token: str = "tok-abc"): + http_client = AsyncMock(spec=httpx.AsyncClient) + cache = MagicMock() + cache.get = AsyncMock(return_value=None) + cache.set = AsyncMock() + return ListenBrainzRepository(http_client, cache, username, user_token), http_client + + +def _ok_response(json_data=None): + resp = MagicMock() + resp.status_code = 200 + resp.content = b"" + resp.json.return_value = json_data or {} + resp.text = "" + import msgspec + if json_data is not None: + resp.content = msgspec.json.encode(json_data) + return resp + + +SAMPLE_PLAYLISTS_RESPONSE = { + "count": 0, + "offset": 0, + "playlist_count": 2, + "playlists": [ + { + "playlist": { + "identifier": "https://listenbrainz.org/playlist/abc-123", + "title": "Weekly Exploration for testuser, week of 2026-03-23 Mon", + "date": "2026-03-23T00:25:21.141222+00:00", + "creator": "listenbrainz", + "track": [], + "extension": { + "https://musicbrainz.org/doc/jspf#playlist": { + "additional_metadata": { + "algorithm_metadata": { + "source_patch": "weekly-exploration" + } + }, + "created_for": "testuser", + "creator": "listenbrainz", + } + }, + } + }, + { + "playlist": { + "identifier": "https://listenbrainz.org/playlist/def-456", + "title": "Weekly Exploration for testuser, week of 2026-03-16 Mon", + "date": "2026-03-16T00:24:27.677965+00:00", + "creator": "listenbrainz", + "track": [], + "extension": { + "https://musicbrainz.org/doc/jspf#playlist": { + "additional_metadata": { + "algorithm_metadata": { + "source_patch": "weekly-exploration" + } + }, + } + }, + } + }, + ], +} + + +SAMPLE_PLAYLIST_DETAIL = { + "playlist": { + "identifier": "https://listenbrainz.org/playlist/abc-123", + "title": "Weekly Exploration for testuser, week of 2026-03-23 Mon", + "date": "2026-03-23T00:25:21.141222+00:00", + "creator": "listenbrainz", + "extension": { + "https://musicbrainz.org/doc/jspf#playlist": { + "additional_metadata": { + "algorithm_metadata": {"source_patch": "weekly-exploration"} + }, + } + }, + "track": [ + { + "title": "Eye in the Sky", + "creator": "The Alan Parsons Project", + "album": "Eye in the Sky", + "duration": 276173, + "identifier": [ + "https://musicbrainz.org/recording/e9209cce-7b98-4d7e-ba62-779374272de6" + ], + "extension": { + "https://musicbrainz.org/doc/jspf#track": { + "additional_metadata": { + "artists": [ + { + "artist_credit_name": "The Alan Parsons Project", + "artist_mbid": "f98711e5-06f7-43ed-8239-da0f61a9c460", + "join_phrase": "", + } + ], + "caa_id": 14860133678, + "caa_release_mbid": "b43d2acd-4490-4517-b049-56e780cd0e69", + }, + } + }, + }, + { + "title": "Wet", + "creator": "Dazey and the Scouts", + "album": "Maggot", + "duration": 170000, + "identifier": [ + "https://musicbrainz.org/recording/a30c907d-0c8b-4841-902e-012ca67d08c2" + ], + "extension": { + "https://musicbrainz.org/doc/jspf#track": { + "additional_metadata": { + "artists": [ + { + "artist_credit_name": "Dazey and the Scouts", + "artist_mbid": "a7fd3cbf-15cc-4a8a-b59f-587b5784feb2", + "join_phrase": "", + } + ], + "caa_id": 19535225900, + "caa_release_mbid": "aaf9fb14-d3af-43d7-b6f3-882c520aa6f6", + }, + } + }, + }, + ], + } +} + + +class TestParseRecommendationTrack: + def test_parses_valid_track(self): + raw = SAMPLE_PLAYLIST_DETAIL["playlist"]["track"][0] + track = parse_recommendation_track(raw) + assert track is not None + assert track.title == "Eye in the Sky" + assert track.creator == "The Alan Parsons Project" + assert track.album == "Eye in the Sky" + assert track.recording_mbid == "e9209cce-7b98-4d7e-ba62-779374272de6" + assert track.artist_mbids == ["f98711e5-06f7-43ed-8239-da0f61a9c460"] + assert track.duration_ms == 276173 + assert track.caa_id == 14860133678 + assert track.caa_release_mbid == "b43d2acd-4490-4517-b049-56e780cd0e69" + + def test_returns_none_for_missing_title(self): + track = parse_recommendation_track({"creator": "Artist"}) + assert track is None + + def test_returns_none_for_missing_creator(self): + track = parse_recommendation_track({"title": "Song"}) + assert track is None + + def test_handles_missing_extension(self): + track = parse_recommendation_track({ + "title": "Song", + "creator": "Artist", + "album": "Album", + }) + assert track is not None + assert track.artist_mbids is None + assert track.caa_id is None + assert track.duration_ms is None + + def test_handles_non_numeric_duration(self): + track = parse_recommendation_track({ + "title": "Song", + "creator": "Artist", + "album": "Album", + "duration": "invalid", + }) + assert track is not None + assert track.duration_ms is None + + +class TestGetRecommendationPlaylists: + @pytest.mark.asyncio + async def test_returns_playlists(self): + repo, http = _make_repo() + http.request = AsyncMock(return_value=_ok_response(SAMPLE_PLAYLISTS_RESPONSE)) + result = await repo.get_recommendation_playlists() + assert len(result) == 2 + assert result[0]["playlist_id"] == "abc-123" + assert result[0]["source_patch"] == "weekly-exploration" + assert result[1]["playlist_id"] == "def-456" + + @pytest.mark.asyncio + async def test_returns_empty_for_no_username(self): + repo, _ = _make_repo(username="") + result = await repo.get_recommendation_playlists() + assert result == [] + + @pytest.mark.asyncio + async def test_returns_empty_for_404(self): + repo, http = _make_repo() + resp = MagicMock() + resp.status_code = 404 + resp.text = "Not Found" + http.request = AsyncMock(return_value=resp) + result = await repo.get_recommendation_playlists() + assert result == [] + + @pytest.mark.asyncio + async def test_caches_result(self): + repo, http = _make_repo() + http.request = AsyncMock(return_value=_ok_response(SAMPLE_PLAYLISTS_RESPONSE)) + await repo.get_recommendation_playlists() + repo._cache.set.assert_called_once() + call_args = repo._cache.set.call_args + assert call_args[0][0] == "lb_rec_playlists:testuser" + assert call_args[1]["ttl_seconds"] == 21600 + + +class TestGetPlaylistTracks: + @pytest.mark.asyncio + async def test_returns_playlist_with_tracks(self): + repo, http = _make_repo() + http.request = AsyncMock(return_value=_ok_response(SAMPLE_PLAYLIST_DETAIL)) + result = await repo.get_playlist_tracks("abc-123") + assert result is not None + assert len(result.tracks) == 2 + assert result.tracks[0].title == "Eye in the Sky" + assert result.tracks[1].title == "Wet" + assert result.source_patch == "weekly-exploration" + + @pytest.mark.asyncio + async def test_returns_none_for_empty_id(self): + repo, _ = _make_repo() + result = await repo.get_playlist_tracks("") + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_404(self): + repo, http = _make_repo() + resp = MagicMock() + resp.status_code = 404 + resp.text = "Not Found" + http.request = AsyncMock(return_value=resp) + result = await repo.get_playlist_tracks("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_caches_playlist(self): + repo, http = _make_repo() + http.request = AsyncMock(return_value=_ok_response(SAMPLE_PLAYLIST_DETAIL)) + await repo.get_playlist_tracks("abc-123") + repo._cache.set.assert_called_once() + call_args = repo._cache.set.call_args + assert call_args[0][0] == "lb_rec_playlist:abc-123" + assert call_args[1]["ttl_seconds"] == 21600 diff --git a/backend/tests/test_advanced_settings_roundtrip.py b/backend/tests/test_advanced_settings_roundtrip.py new file mode 100644 index 0000000..5d16edb --- /dev/null +++ b/backend/tests/test_advanced_settings_roundtrip.py @@ -0,0 +1,187 @@ +"""Round-trip tests for AdvancedSettings ↔ AdvancedSettingsFrontend conversion.""" + +import msgspec +import pytest + +from api.v1.schemas.advanced_settings import AdvancedSettings, AdvancedSettingsFrontend + + +class TestDirectRemoteImagesRoundTrip: + def test_default_value_is_true(self) -> None: + settings = AdvancedSettings() + assert settings.direct_remote_images_enabled is True + + def test_frontend_default_is_true(self) -> None: + frontend = AdvancedSettingsFrontend() + assert frontend.direct_remote_images_enabled is True + + def test_roundtrip_preserves_true(self) -> None: + backend = AdvancedSettings(direct_remote_images_enabled=True) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.direct_remote_images_enabled is True + restored = frontend.to_backend() + assert restored.direct_remote_images_enabled is True + + def test_roundtrip_preserves_false(self) -> None: + backend = AdvancedSettings(direct_remote_images_enabled=False) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.direct_remote_images_enabled is False + restored = frontend.to_backend() + assert restored.direct_remote_images_enabled is False + + +class TestAudioDBNameSearchFallbackRoundTrip: + def test_default_value_is_false(self) -> None: + settings = AdvancedSettings() + assert settings.audiodb_name_search_fallback is False + + def test_frontend_default_is_false(self) -> None: + frontend = AdvancedSettingsFrontend() + assert frontend.audiodb_name_search_fallback is False + + def test_roundtrip_preserves_true(self) -> None: + backend = AdvancedSettings(audiodb_name_search_fallback=True) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_name_search_fallback is True + restored = frontend.to_backend() + assert restored.audiodb_name_search_fallback is True + + def test_roundtrip_preserves_false(self) -> None: + backend = AdvancedSettings(audiodb_name_search_fallback=False) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_name_search_fallback is False + restored = frontend.to_backend() + assert restored.audiodb_name_search_fallback is False + + +class TestCacheTtlAudiodbFoundRoundTrip: + def test_default_value(self) -> None: + settings = AdvancedSettings() + frontend = AdvancedSettingsFrontend.from_backend(settings) + assert frontend.cache_ttl_audiodb_found == 168 + + def test_roundtrip_preserves(self) -> None: + backend = AdvancedSettings(cache_ttl_audiodb_found=604800) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_audiodb_found == 168 + restored = frontend.to_backend() + assert restored.cache_ttl_audiodb_found == 604800 + + def test_custom_value_roundtrip(self) -> None: + backend = AdvancedSettings(cache_ttl_audiodb_found=36000) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_audiodb_found == 10 + restored = frontend.to_backend() + assert restored.cache_ttl_audiodb_found == 36000 + + +class TestCacheTtlAudiodbNotFoundRoundTrip: + def test_default_value(self) -> None: + settings = AdvancedSettings() + frontend = AdvancedSettingsFrontend.from_backend(settings) + assert frontend.cache_ttl_audiodb_not_found == 24 + + def test_roundtrip_preserves(self) -> None: + backend = AdvancedSettings(cache_ttl_audiodb_not_found=86400) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_audiodb_not_found == 24 + restored = frontend.to_backend() + assert restored.cache_ttl_audiodb_not_found == 86400 + + def test_custom_value_roundtrip(self) -> None: + backend = AdvancedSettings(cache_ttl_audiodb_not_found=7200) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_audiodb_not_found == 2 + restored = frontend.to_backend() + assert restored.cache_ttl_audiodb_not_found == 7200 + + +class TestCacheTtlAudiodbLibraryRoundTrip: + def test_default_value(self) -> None: + settings = AdvancedSettings() + frontend = AdvancedSettingsFrontend.from_backend(settings) + assert frontend.cache_ttl_audiodb_library == 336 + + def test_roundtrip_preserves(self) -> None: + backend = AdvancedSettings(cache_ttl_audiodb_library=1209600) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_audiodb_library == 336 + restored = frontend.to_backend() + assert restored.cache_ttl_audiodb_library == 1209600 + + +class TestCacheTtlRecentlyViewedBytesRoundTrip: + def test_default_value(self) -> None: + settings = AdvancedSettings() + frontend = AdvancedSettingsFrontend.from_backend(settings) + assert frontend.cache_ttl_recently_viewed_bytes == 48 + + def test_roundtrip_preserves(self) -> None: + backend = AdvancedSettings(cache_ttl_recently_viewed_bytes=172800) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_recently_viewed_bytes == 48 + restored = frontend.to_backend() + assert restored.cache_ttl_recently_viewed_bytes == 172800 + + +class TestAudiodbValidationClamping: + def test_cache_ttl_found_below_min(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_found=100) + + def test_cache_ttl_found_above_max(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_found=99999999) + + def test_cache_ttl_not_found_below_min(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_not_found=100) + + def test_cache_ttl_not_found_above_max(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_not_found=999999) + + def test_cache_ttl_library_below_min(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_library=100) + + def test_cache_ttl_library_above_max(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_audiodb_library=99999999) + + def test_cache_ttl_recently_viewed_bytes_below_min(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_recently_viewed_bytes=100) + + def test_cache_ttl_recently_viewed_bytes_above_max(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_recently_viewed_bytes=999999) + + def test_api_key_empty_coerced(self) -> None: + settings = AdvancedSettings(audiodb_api_key="") + assert settings.audiodb_api_key == "123" + + def test_api_key_whitespace_coerced(self) -> None: + settings = AdvancedSettings(audiodb_api_key=" ") + assert settings.audiodb_api_key == "123" + + +class TestAudiodbEnabledRoundTrip: + def test_default_is_true(self) -> None: + settings = AdvancedSettings() + frontend = AdvancedSettingsFrontend.from_backend(settings) + assert frontend.audiodb_enabled is True + + def test_roundtrip_false(self) -> None: + backend = AdvancedSettings(audiodb_enabled=False) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_enabled is False + restored = frontend.to_backend() + assert restored.audiodb_enabled is False + + def test_roundtrip_true(self) -> None: + backend = AdvancedSettings(audiodb_enabled=True) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_enabled is True + restored = frontend.to_backend() + assert restored.audiodb_enabled is True diff --git a/backend/tests/test_audiodb_killswitch.py b/backend/tests/test_audiodb_killswitch.py new file mode 100644 index 0000000..194f31c --- /dev/null +++ b/backend/tests/test_audiodb_killswitch.py @@ -0,0 +1,395 @@ +"""Regression tests: audiodb_enabled=False suppresses ALL AudioDB behavior. + +Tests are split into two groups: +- Settings-based: AudioDBImageService exists but audiodb_enabled=False causes + its methods to short-circuit and return None despite data in cache. +- Null-guard (supplementary): audiodb_service=None — tests DI wiring defence. +""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.v1.schemas.album import AlbumInfo +from api.v1.schemas.artist import ArtistInfo +from api.v1.schemas.search import SearchResult +from repositories.audiodb_models import AudioDBArtistImages, AudioDBAlbumImages +from repositories.audiodb_repository import AudioDBRepository +from repositories.coverart_album import AlbumCoverFetcher +from services.audiodb_image_service import AudioDBImageService +from services.album_service import AlbumService +from services.artist_service import ArtistService +from services.search_service import SearchService + +TEST_ARTIST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + +ARTIST_IMAGES = AudioDBArtistImages( + thumb_url="https://cdn.example.com/thumb.jpg", + fanart_url="https://cdn.example.com/fanart1.jpg", + fanart_url_2="https://cdn.example.com/fanart2.jpg", + fanart_url_3="https://cdn.example.com/fanart3.jpg", + fanart_url_4="https://cdn.example.com/fanart4.jpg", + wide_thumb_url="https://cdn.example.com/wide.jpg", + banner_url="https://cdn.example.com/banner.jpg", + logo_url="https://cdn.example.com/logo.png", + clearart_url="https://cdn.example.com/clearart.png", + cutout_url="https://cdn.example.com/cutout.png", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + +ALBUM_IMAGES = AudioDBAlbumImages( + album_thumb_url="https://cdn.example.com/album_thumb.jpg", + album_back_url="https://cdn.example.com/album_back.jpg", + album_cdart_url="https://cdn.example.com/album_cdart.png", + album_spine_url="https://cdn.example.com/album_spine.jpg", + album_3d_case_url="https://cdn.example.com/3d_case.png", + album_3d_flat_url="https://cdn.example.com/3d_flat.png", + album_3d_face_url="https://cdn.example.com/3d_face.png", + album_3d_thumb_url="https://cdn.example.com/3d_thumb.png", + lookup_source="mbid", + is_negative=False, + cached_at=1000.0, +) + + +ARTIST_IMAGES_RAW = { + "thumb_url": ARTIST_IMAGES.thumb_url, + "fanart_url": ARTIST_IMAGES.fanart_url, + "fanart_url_2": ARTIST_IMAGES.fanart_url_2, + "fanart_url_3": ARTIST_IMAGES.fanart_url_3, + "fanart_url_4": ARTIST_IMAGES.fanart_url_4, + "wide_thumb_url": ARTIST_IMAGES.wide_thumb_url, + "banner_url": ARTIST_IMAGES.banner_url, + "logo_url": ARTIST_IMAGES.logo_url, + "clearart_url": ARTIST_IMAGES.clearart_url, + "cutout_url": ARTIST_IMAGES.cutout_url, + "lookup_source": "mbid", + "is_negative": False, + "cached_at": 1000.0, +} + +ALBUM_IMAGES_RAW = { + "album_thumb_url": ALBUM_IMAGES.album_thumb_url, + "album_back_url": ALBUM_IMAGES.album_back_url, + "album_cdart_url": ALBUM_IMAGES.album_cdart_url, + "album_spine_url": ALBUM_IMAGES.album_spine_url, + "album_3d_case_url": ALBUM_IMAGES.album_3d_case_url, + "album_3d_flat_url": ALBUM_IMAGES.album_3d_flat_url, + "album_3d_face_url": ALBUM_IMAGES.album_3d_face_url, + "album_3d_thumb_url": ALBUM_IMAGES.album_3d_thumb_url, + "lookup_source": "mbid", + "is_negative": False, + "cached_at": 1000.0, +} + + + +def _make_artist_info(**overrides) -> ArtistInfo: + defaults = dict(name="Coldplay", musicbrainz_id=TEST_ARTIST_MBID) + defaults.update(overrides) + return ArtistInfo(**defaults) + + +def _make_album_info(**overrides) -> AlbumInfo: + defaults = dict( + title="Parachutes", + musicbrainz_id=TEST_ALBUM_MBID, + artist_name="Coldplay", + artist_id=TEST_ARTIST_MBID, + ) + defaults.update(overrides) + return AlbumInfo(**defaults) + + +def _disabled_settings() -> MagicMock: + """Return a mock preferences_service whose settings have audiodb_enabled=False.""" + prefs = MagicMock() + settings = MagicMock() + settings.audiodb_enabled = False + prefs.get_advanced_settings.return_value = settings + return prefs + + +def _disabled_image_service() -> AudioDBImageService: + """Real AudioDBImageService with audiodb_enabled=False and data in disk cache.""" + disk_cache = MagicMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=ARTIST_IMAGES_RAW) + disk_cache.get_audiodb_album = AsyncMock(return_value=ALBUM_IMAGES_RAW) + return AudioDBImageService( + audiodb_repo=MagicMock(), + disk_cache=disk_cache, + preferences_service=_disabled_settings(), + memory_cache=None, + ) + + +def _make_artist_service(audiodb_service=None) -> ArtistService: + return ArtistService( + mb_repo=MagicMock(), + lidarr_repo=MagicMock(), + wikidata_repo=MagicMock(), + preferences_service=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +def _make_album_service(audiodb_service=None) -> AlbumService: + return AlbumService( + lidarr_repo=MagicMock(), + mb_repo=MagicMock(), + library_db=MagicMock(), + memory_cache=MagicMock(), + disk_cache=MagicMock(), + preferences_service=MagicMock(), + audiodb_image_service=audiodb_service, + ) + + +def _make_search_service(audiodb_service=None) -> SearchService: + mb_repo = MagicMock() + lidarr_repo = MagicMock() + lidarr_repo.get_library_mbids = AsyncMock(return_value=set()) + lidarr_repo.get_queue = AsyncMock(return_value=[]) + coverart_repo = MagicMock() + prefs = MagicMock() + prefs.get_preferences.return_value = MagicMock(secondary_types=[]) + return SearchService(mb_repo, lidarr_repo, coverart_repo, prefs, audiodb_service) + + +def _make_repo(enabled: bool = True) -> AudioDBRepository: + client = AsyncMock() + prefs = MagicMock() + settings = MagicMock() + settings.audiodb_enabled = enabled + settings.audiodb_api_key = "test_key" + prefs.get_advanced_settings.return_value = settings + return AudioDBRepository( + http_client=client, + preferences_service=prefs, + api_key="test_key", + premium=False, + ) + + +# Settings-based kill-switch tests (audiodb_enabled=False in preferences) + +class TestSettingsKillSwitchArtist: + """8.10.a — Real AudioDBImageService with audiodb_enabled=False.""" + + @pytest.mark.asyncio + async def test_image_service_returns_none_despite_cached_data(self): + img_svc = _disabled_image_service() + result = await img_svc.get_cached_artist_images(TEST_ARTIST_MBID) + assert result is None + img_svc._disk_cache.get_audiodb_artist.assert_not_awaited() + + @pytest.mark.asyncio + async def test_fetch_returns_none_despite_cached_data(self): + img_svc = _disabled_image_service() + result = await img_svc.fetch_and_cache_artist_images(TEST_ARTIST_MBID, "Coldplay") + assert result is None + + @pytest.mark.asyncio + async def test_artist_detail_all_audiodb_fields_none(self): + img_svc = _disabled_image_service() + svc = _make_artist_service(audiodb_service=img_svc) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_ARTIST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + assert result.fanart_url_2 is None + assert result.fanart_url_3 is None + assert result.fanart_url_4 is None + assert result.wide_thumb_url is None + assert result.logo_url is None + assert result.clearart_url is None + assert result.cutout_url is None + + +class TestSettingsKillSwitchAlbum: + """8.10.b — Real AudioDBImageService with audiodb_enabled=False.""" + + @pytest.mark.asyncio + async def test_image_service_returns_none_despite_cached_data(self): + img_svc = _disabled_image_service() + result = await img_svc.get_cached_album_images(TEST_ALBUM_MBID) + assert result is None + img_svc._disk_cache.get_audiodb_album.assert_not_awaited() + + @pytest.mark.asyncio + async def test_album_detail_all_audiodb_fields_none(self): + img_svc = _disabled_image_service() + svc = _make_album_service(audiodb_service=img_svc) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_ALBUM_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url is None + assert result.album_back_url is None + assert result.album_cdart_url is None + assert result.album_spine_url is None + assert result.album_3d_case_url is None + assert result.album_3d_flat_url is None + assert result.album_3d_face_url is None + assert result.album_3d_thumb_url is None + + +class TestSettingsKillSwitchSearch: + """8.10.d — Real AudioDBImageService with audiodb_enabled=False.""" + + @pytest.mark.asyncio + async def test_search_overlay_no_audiodb_urls(self): + img_svc = _disabled_image_service() + svc = _make_search_service(audiodb_service=img_svc) + + results = [ + SearchResult(type="artist", title="Coldplay", musicbrainz_id=TEST_ARTIST_MBID, score=100), + SearchResult(type="album", title="Parachutes", musicbrainz_id=TEST_ALBUM_MBID, artist="Coldplay", score=90), + ] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[0].fanart_url is None + assert results[0].banner_url is None + assert results[1].album_thumb_url is None + + +# Supplementary null-guard tests (audiodb_service=None — DI wiring defence) + +class TestNullGuardArtistDetail: + + @pytest.mark.asyncio + async def test_null_service_all_audiodb_fields_none(self): + svc = _make_artist_service(audiodb_service=None) + artist = _make_artist_info() + + result = await svc._apply_audiodb_artist_images( + artist, TEST_ARTIST_MBID, "Coldplay", allow_fetch=True, + ) + + assert result.thumb_url is None + assert result.fanart_url_2 is None + assert result.fanart_url_3 is None + assert result.fanart_url_4 is None + assert result.wide_thumb_url is None + assert result.logo_url is None + assert result.clearart_url is None + assert result.cutout_url is None + + +class TestNullGuardAlbumDetail: + + @pytest.mark.asyncio + async def test_null_service_all_audiodb_fields_none(self): + svc = _make_album_service(audiodb_service=None) + album = _make_album_info() + + result = await svc._apply_audiodb_album_images( + album, TEST_ALBUM_MBID, "Coldplay", "Parachutes", allow_fetch=True, + ) + + assert result.album_thumb_url is None + assert result.album_back_url is None + assert result.album_cdart_url is None + assert result.album_spine_url is None + assert result.album_3d_case_url is None + assert result.album_3d_flat_url is None + assert result.album_3d_face_url is None + assert result.album_3d_thumb_url is None + + +class TestNullGuardSearchOverlay: + + @pytest.mark.asyncio + async def test_null_service_no_audiodb_urls(self): + svc = _make_search_service(audiodb_service=None) + + results = [ + SearchResult(type="artist", title="Coldplay", musicbrainz_id=TEST_ARTIST_MBID, score=100), + SearchResult(type="album", title="Parachutes", musicbrainz_id=TEST_ALBUM_MBID, artist="Coldplay", score=90), + ] + await svc._apply_audiodb_search_overlay(results) + + assert results[0].thumb_url is None + assert results[0].fanart_url is None + assert results[0].banner_url is None + assert results[1].album_thumb_url is None + + +# Repository and cover provider tests (unchanged — already test correct path) + +class TestRepositoryDisabled: + + @pytest.mark.asyncio + async def test_repository_disabled_returns_none_no_http(self): + repo = _make_repo(enabled=False) + + result = await repo.get_artist_by_mbid(TEST_ARTIST_MBID) + + assert result is None + repo._client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_repository_album_disabled_returns_none_no_http(self): + repo = _make_repo(enabled=False) + + result = await repo.get_album_by_mbid(TEST_ALBUM_MBID) + + assert result is None + repo._client.get.assert_not_called() + + +class TestCoverProviderDisabled: + + @pytest.mark.asyncio + async def test_cover_provider_disabled_via_settings_skips_audiodb(self): + """8.10.c — audiodb_enabled=False: AudioDB cache not queried, fallback + providers called normally.""" + http_get = AsyncMock() + write_cache = AsyncMock() + img_svc = _disabled_image_service() + + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=img_svc, + ) + + result = await fetcher._fetch_from_audiodb( + TEST_ALBUM_MBID, Path("/tmp/fake_cover.jpg"), + ) + + assert result is None + img_svc._disk_cache.get_audiodb_album.assert_not_awaited() + http_get.assert_not_called() + write_cache.assert_not_called() + + @pytest.mark.asyncio + async def test_cover_provider_null_guard_skips_audiodb(self): + """Supplementary: audiodb_service=None — DI wiring defence.""" + http_get = AsyncMock() + write_cache = AsyncMock() + fetcher = AlbumCoverFetcher( + http_get_fn=http_get, + write_cache_fn=write_cache, + audiodb_service=None, + ) + + result = await fetcher._fetch_from_audiodb( + TEST_ALBUM_MBID, Path("/tmp/fake_cover.jpg"), + ) + + assert result is None + http_get.assert_not_called() + write_cache.assert_not_called() diff --git a/backend/tests/test_audiodb_schema_contracts.py b/backend/tests/test_audiodb_schema_contracts.py new file mode 100644 index 0000000..daa5efc --- /dev/null +++ b/backend/tests/test_audiodb_schema_contracts.py @@ -0,0 +1,174 @@ +import msgspec +import pytest +from api.v1.schemas.artist import ArtistInfo +from api.v1.schemas.album import AlbumInfo, AlbumBasicInfo +from api.v1.schemas.search import SearchResult +from api.v1.schemas.cache import CacheStats + + +AUDIODB_CDN = "https://www.theaudiodb.com/images/media" + + +def test_artist_info_audiodb_fields_default_to_none(): + artist = ArtistInfo(name="Test", musicbrainz_id="abc-123") + for field in ( + "thumb_url", + "fanart_url_2", + "fanart_url_3", + "fanart_url_4", + "wide_thumb_url", + "logo_url", + "clearart_url", + "cutout_url", + "fanart_url", + "banner_url", + ): + assert getattr(artist, field) is None, f"{field} should default to None" + + +def test_artist_info_audiodb_fields_serialized(): + fields = { + "thumb_url": f"{AUDIODB_CDN}/thumb.jpg", + "fanart_url_2": f"{AUDIODB_CDN}/fanart2.jpg", + "fanart_url_3": f"{AUDIODB_CDN}/fanart3.jpg", + "fanart_url_4": f"{AUDIODB_CDN}/fanart4.jpg", + "wide_thumb_url": f"{AUDIODB_CDN}/wide.jpg", + "logo_url": f"{AUDIODB_CDN}/logo.png", + "clearart_url": f"{AUDIODB_CDN}/clearart.png", + "cutout_url": f"{AUDIODB_CDN}/cutout.png", + } + artist = ArtistInfo(name="Test", musicbrainz_id="abc-123", **fields) + data = msgspec.json.decode(msgspec.json.encode(artist)) + for key, value in fields.items(): + assert key in data, f"{key} missing from serialized output" + assert data[key] == value + + +def test_artist_info_existing_fields_unchanged(): + artist = ArtistInfo( + name="Artist", + musicbrainz_id="mb-id", + image="http://lidarr/img.jpg", + fanart_url="http://lidarr/fanart.jpg", + ) + assert artist.name == "Artist" + assert artist.musicbrainz_id == "mb-id" + assert artist.image == "http://lidarr/img.jpg" + assert artist.fanart_url == "http://lidarr/fanart.jpg" + assert artist.in_library is False + assert artist.albums == [] + + +def test_album_info_audiodb_fields_default_to_none(): + album = AlbumInfo( + title="Test", musicbrainz_id="abc", artist_name="Artist", artist_id="xyz" + ) + for field in ( + "album_thumb_url", + "album_back_url", + "album_cdart_url", + "album_spine_url", + "album_3d_case_url", + "album_3d_flat_url", + "album_3d_face_url", + "album_3d_thumb_url", + "cover_url", + ): + assert getattr(album, field) is None, f"{field} should default to None" + + +def test_album_info_audiodb_fields_serialized(): + fields = { + "album_thumb_url": f"{AUDIODB_CDN}/album_thumb.jpg", + "album_back_url": f"{AUDIODB_CDN}/album_back.jpg", + "album_cdart_url": f"{AUDIODB_CDN}/cdart.png", + "album_spine_url": f"{AUDIODB_CDN}/spine.jpg", + "album_3d_case_url": f"{AUDIODB_CDN}/3dcase.png", + "album_3d_flat_url": f"{AUDIODB_CDN}/3dflat.png", + "album_3d_face_url": f"{AUDIODB_CDN}/3dface.png", + "album_3d_thumb_url": f"{AUDIODB_CDN}/3dthumb.png", + } + album = AlbumInfo( + title="Test", musicbrainz_id="abc", artist_name="Artist", artist_id="xyz", + **fields, + ) + data = msgspec.json.decode(msgspec.json.encode(album)) + for key, value in fields.items(): + assert key in data, f"{key} missing from serialized output" + assert data[key] == value + + +def test_album_basic_info_includes_thumb(): + basic = AlbumBasicInfo( + title="Test", musicbrainz_id="abc", artist_name="Artist", artist_id="xyz" + ) + assert basic.album_thumb_url is None + + basic_with_thumb = AlbumBasicInfo( + title="Test", musicbrainz_id="abc", artist_name="Artist", artist_id="xyz", + album_thumb_url="https://cdn/thumb.jpg", + ) + data = msgspec.json.decode(msgspec.json.encode(basic_with_thumb)) + assert "album_thumb_url" in data + assert data["album_thumb_url"] == "https://cdn/thumb.jpg" + + +def test_search_result_audiodb_overlay_fields(): + result = SearchResult(type="artist", title="Test", musicbrainz_id="abc") + assert result.thumb_url is None + assert result.fanart_url is None + assert result.banner_url is None + assert result.album_thumb_url is None + + overlay = { + "thumb_url": f"{AUDIODB_CDN}/thumb.jpg", + "fanart_url": f"{AUDIODB_CDN}/fanart.jpg", + "banner_url": f"{AUDIODB_CDN}/banner.jpg", + "album_thumb_url": f"{AUDIODB_CDN}/album_thumb.jpg", + } + result_with = SearchResult( + type="artist", title="Test", musicbrainz_id="abc", **overlay + ) + data = msgspec.json.decode(msgspec.json.encode(result_with)) + for key, value in overlay.items(): + assert key in data, f"{key} missing from serialized output" + assert data[key] == value + + +def test_cache_stats_audiodb_fields_default_to_zero(): + stats = CacheStats( + memory_entries=0, memory_size_bytes=0, memory_size_mb=0.0, + disk_metadata_count=0, disk_metadata_albums=0, disk_metadata_artists=0, + disk_cover_count=0, disk_cover_size_bytes=0, disk_cover_size_mb=0.0, + library_db_artist_count=0, library_db_album_count=0, + library_db_size_bytes=0, library_db_size_mb=0.0, + total_size_bytes=0, total_size_mb=0.0, + ) + assert stats.disk_audiodb_artist_count == 0 + assert stats.disk_audiodb_album_count == 0 + + +def test_cache_stats_audiodb_fields_serialized(): + stats = CacheStats( + memory_entries=10, memory_size_bytes=2048, memory_size_mb=0.002, + disk_metadata_count=50, disk_metadata_albums=30, disk_metadata_artists=20, + disk_cover_count=15, disk_cover_size_bytes=1048576, disk_cover_size_mb=1.0, + library_db_artist_count=5, library_db_album_count=8, + library_db_size_bytes=4096, library_db_size_mb=0.004, + total_size_bytes=1054720, total_size_mb=1.006, + disk_audiodb_artist_count=42, + disk_audiodb_album_count=99, + ) + data = msgspec.json.decode(msgspec.json.encode(stats)) + assert data["disk_audiodb_artist_count"] == 42 + assert data["disk_audiodb_album_count"] == 99 + assert data["memory_entries"] == 10 + assert data["memory_size_bytes"] == 2048 + assert data["disk_metadata_count"] == 50 + assert data["disk_metadata_albums"] == 30 + assert data["disk_metadata_artists"] == 20 + assert data["disk_cover_count"] == 15 + assert data["disk_cover_size_bytes"] == 1048576 + assert data["library_db_artist_count"] == 5 + assert data["library_db_album_count"] == 8 + assert data["total_size_bytes"] == 1054720 diff --git a/backend/tests/test_audiodb_settings.py b/backend/tests/test_audiodb_settings.py new file mode 100644 index 0000000..63dd7a2 --- /dev/null +++ b/backend/tests/test_audiodb_settings.py @@ -0,0 +1,135 @@ +"""Tests for Phase 7: AudioDB settings — API key masking, round-trips, validation.""" + +import pytest +import msgspec + +from api.v1.schemas.advanced_settings import ( + AdvancedSettings, + AdvancedSettingsFrontend, + _mask_api_key, + _is_masked_api_key, +) + + +class TestMaskApiKey: + def test_long_key_shows_last_three(self) -> None: + assert _mask_api_key("myapikey123") == "***…123" + + def test_four_char_key(self) -> None: + assert _mask_api_key("1234") == "***…234" + + def test_three_char_key_fully_masked(self) -> None: + assert _mask_api_key("123") == "***" + + def test_two_char_key_fully_masked(self) -> None: + assert _mask_api_key("ab") == "***" + + def test_one_char_key_fully_masked(self) -> None: + assert _mask_api_key("x") == "***" + + def test_empty_key_fully_masked(self) -> None: + assert _mask_api_key("") == "***" + + +class TestIsMaskedApiKey: + def test_masked_with_suffix(self) -> None: + assert _is_masked_api_key("***…123") is True + + def test_masked_short(self) -> None: + assert _is_masked_api_key("***") is True + + def test_plaintext_key(self) -> None: + assert _is_masked_api_key("mynewkey") is False + + def test_empty_string(self) -> None: + assert _is_masked_api_key("") is False + + def test_partial_asterisks(self) -> None: + assert _is_masked_api_key("** not masked") is False + + def test_triple_asterisks_with_extra(self) -> None: + assert _is_masked_api_key("***extra") is True + + +class TestApiKeyRoundTrip: + def test_from_backend_masks_long_key(self) -> None: + backend = AdvancedSettings(audiodb_api_key="secretkey") + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_api_key == "***…key" + + def test_from_backend_masks_default_key(self) -> None: + backend = AdvancedSettings(audiodb_api_key="123") + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.audiodb_api_key == "***" + + def test_to_backend_passes_masked_key_through(self) -> None: + frontend = AdvancedSettingsFrontend(audiodb_api_key="***…key") + backend = frontend.to_backend() + assert backend.audiodb_api_key == "***…key" + + def test_to_backend_passes_new_plaintext_key(self) -> None: + frontend = AdvancedSettingsFrontend(audiodb_api_key="newkey456") + backend = frontend.to_backend() + assert backend.audiodb_api_key == "newkey456" + + def test_default_api_key_is_123(self) -> None: + settings = AdvancedSettings() + assert settings.audiodb_api_key == "123" + + def test_frontend_default_api_key_is_123(self) -> None: + frontend = AdvancedSettingsFrontend() + assert frontend.audiodb_api_key == "123" + + +class TestApiKeyEmptyGuard: + def test_empty_key_coerced_to_default(self) -> None: + settings = AdvancedSettings(audiodb_api_key="") + assert settings.audiodb_api_key == "123" + + def test_whitespace_key_coerced_to_default(self) -> None: + settings = AdvancedSettings(audiodb_api_key=" ") + assert settings.audiodb_api_key == "123" + + def test_valid_key_preserved(self) -> None: + settings = AdvancedSettings(audiodb_api_key="premium_key") + assert settings.audiodb_api_key == "premium_key" + + +class TestRecentlyViewedBytesTTLRoundTrip: + def test_default_backend_value(self) -> None: + settings = AdvancedSettings() + assert settings.cache_ttl_recently_viewed_bytes == 172800 + + def test_default_frontend_value(self) -> None: + frontend = AdvancedSettingsFrontend() + assert frontend.cache_ttl_recently_viewed_bytes == 48 + + def test_roundtrip_preserves_value(self) -> None: + backend = AdvancedSettings(cache_ttl_recently_viewed_bytes=172800) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_recently_viewed_bytes == 48 + restored = frontend.to_backend() + assert restored.cache_ttl_recently_viewed_bytes == 172800 + + def test_roundtrip_custom_value(self) -> None: + backend = AdvancedSettings(cache_ttl_recently_viewed_bytes=36000) + frontend = AdvancedSettingsFrontend.from_backend(backend) + assert frontend.cache_ttl_recently_viewed_bytes == 10 + restored = frontend.to_backend() + assert restored.cache_ttl_recently_viewed_bytes == 36000 + + def test_backend_validation_rejects_too_low(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_recently_viewed_bytes=3599) + + def test_backend_validation_rejects_too_high(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(cache_ttl_recently_viewed_bytes=604801) + + def test_frontend_validation_rejects_too_high(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettingsFrontend(cache_ttl_recently_viewed_bytes=169) + + def test_frontend_validation_rejects_too_low(self) -> None: + with pytest.raises(msgspec.ValidationError): + AdvancedSettingsFrontend(cache_ttl_recently_viewed_bytes=0) diff --git a/backend/tests/test_background_task_logging.py b/backend/tests/test_background_task_logging.py new file mode 100644 index 0000000..2102790 --- /dev/null +++ b/backend/tests/test_background_task_logging.py @@ -0,0 +1,52 @@ +import asyncio +import logging +from unittest.mock import AsyncMock, patch + +import pytest + +from core.tasks import cleanup_cache_periodically, sync_request_statuses_periodically + + +@pytest.mark.asyncio +async def test_cleanup_cache_logs_errors_at_error_level(caplog): + cache = AsyncMock() + cache.cleanup_expired.side_effect = RuntimeError("cache boom") + + caplog.set_level(logging.ERROR, logger="core.tasks") + + async def fake_sleep(_: int) -> None: + if cache.cleanup_expired.await_count: + raise asyncio.CancelledError() + + with patch("core.tasks.asyncio.sleep", side_effect=fake_sleep): + await cleanup_cache_periodically(cache, interval=1) + + assert any( + record.levelno == logging.ERROR + and record.name == "core.tasks" + and "Cache cleanup task failed" in record.message + for record in caplog.records + ) + + +@pytest.mark.asyncio +async def test_request_status_sync_logs_errors_at_error_level(caplog): + requests_page_service = AsyncMock() + requests_page_service.sync_request_statuses.side_effect = RuntimeError("sync boom") + + caplog.set_level(logging.ERROR, logger="core.tasks") + + async def fake_sleep(_: int) -> None: + if requests_page_service.sync_request_statuses.await_count: + raise asyncio.CancelledError() + + with pytest.raises(asyncio.CancelledError): + with patch("core.tasks.asyncio.sleep", side_effect=fake_sleep): + await sync_request_statuses_periodically(requests_page_service, interval=1) + + assert any( + record.levelno == logging.ERROR + and record.name == "core.tasks" + and "Periodic request status sync failed" in record.message + for record in caplog.records + ) diff --git a/backend/tests/test_cache_cleanup.py b/backend/tests/test_cache_cleanup.py new file mode 100644 index 0000000..b864def --- /dev/null +++ b/backend/tests/test_cache_cleanup.py @@ -0,0 +1,407 @@ +"""Unit tests for cache cleanup gaps — cover deletion, store pruning, genre disk cleanup.""" + +import asyncio +import json +import sqlite3 +import tempfile +import time +from datetime import datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Step 1 — CoverDiskCache: delete_by_identifiers, cleanup_expired, demote_orphaned +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def cover_cache_dir(tmp_path: Path): + return tmp_path / "covers" + + +@pytest.fixture() +def cover_disk_cache(cover_cache_dir: Path): + from repositories.coverart_disk_cache import CoverDiskCache + + return CoverDiskCache(cache_dir=cover_cache_dir) + + +def _write_cover_files(cache_dir: Path, filename: str, *, meta: dict | None = None): + cache_dir.mkdir(parents=True, exist_ok=True) + (cache_dir / f"{filename}.bin").write_bytes(b"\x89PNG") + meta_data = meta or {"is_monitored": False} + (cache_dir / f"{filename}.meta.json").write_text(json.dumps(meta_data)) + + +class TestDeleteByIdentifiers: + @pytest.mark.asyncio() + async def test_deletes_matching_files(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + h = get_cache_filename("rg_abc", "500") + _write_cover_files(cover_cache_dir, h) + assert (cover_cache_dir / f"{h}.bin").exists() + + count = await cover_disk_cache.delete_by_identifiers([("rg_abc", "500")]) + assert count >= 1 + assert not (cover_cache_dir / f"{h}.bin").exists() + assert not (cover_cache_dir / f"{h}.meta.json").exists() + + @pytest.mark.asyncio() + async def test_returns_zero_for_missing(self, cover_disk_cache, cover_cache_dir): + cover_cache_dir.mkdir(parents=True, exist_ok=True) + count = await cover_disk_cache.delete_by_identifiers([("rg_missing", "500")]) + assert count == 0 + + +class TestCleanupExpired: + def test_removes_expired_non_monitored(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + h = get_cache_filename("rg_old", "250") + _write_cover_files( + cover_cache_dir, + h, + meta={"is_monitored": False, "expires_at": time.time() - 3600}, + ) + + count = cover_disk_cache.cleanup_expired() + assert count == 1 + assert not (cover_cache_dir / f"{h}.bin").exists() + + def test_keeps_monitored_covers(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + h = get_cache_filename("rg_lib", "250") + _write_cover_files( + cover_cache_dir, + h, + meta={"is_monitored": True}, + ) + + count = cover_disk_cache.cleanup_expired() + assert count == 0 + assert (cover_cache_dir / f"{h}.bin").exists() + + +class TestDemoteOrphaned: + def test_demotes_orphaned_monitored(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + h = get_cache_filename("rg_gone", "500") + _write_cover_files( + cover_cache_dir, + h, + meta={"is_monitored": True}, + ) + + count = cover_disk_cache.demote_orphaned(set()) + assert count == 1 + + meta = json.loads((cover_cache_dir / f"{h}.meta.json").read_text()) + assert meta["is_monitored"] is False + assert "expires_at" in meta + + def test_keeps_valid_monitored(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + h = get_cache_filename("rg_keep", "500") + _write_cover_files( + cover_cache_dir, + h, + meta={"is_monitored": True}, + ) + + count = cover_disk_cache.demote_orphaned({h}) + assert count == 0 + + +# --------------------------------------------------------------------------- +# Step 2 — CoverArtRepository: delete_covers_for_album/artist +# --------------------------------------------------------------------------- + + +class TestCoverArtRepositoryDeletion: + @pytest.mark.asyncio() + async def test_delete_covers_for_album(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + mbid = "test-album-mbid" + for suffix in ("500", "250", "1200", "orig"): + h = get_cache_filename(f"rg_{mbid}", suffix) + _write_cover_files(cover_cache_dir, h) + + # Create a minimal mock CoverArtRepository + from repositories.coverart_repository import CoverArtRepository + + repo = object.__new__(CoverArtRepository) + repo._disk_cache = cover_disk_cache + + class FakeLRU: + def __init__(self): + self._data = {} + + async def evict(self, key): + self._data.pop(key, None) + + repo._cover_memory_cache = FakeLRU() + + count = await repo.delete_covers_for_album(mbid) + assert count >= 4 + + @pytest.mark.asyncio() + async def test_delete_covers_for_artist(self, cover_disk_cache, cover_cache_dir): + from repositories.coverart_disk_cache import get_cache_filename + + mbid = "test-artist-mbid" + for size in ("250", "500"): + h = get_cache_filename(f"artist_{mbid}_{size}", "img") + _write_cover_files(cover_cache_dir, h) + h_unsuffixed = get_cache_filename(f"artist_{mbid}", "img") + _write_cover_files(cover_cache_dir, h_unsuffixed) + + from repositories.coverart_repository import CoverArtRepository + + repo = object.__new__(CoverArtRepository) + repo._disk_cache = cover_disk_cache + + class FakeLRU: + def __init__(self): + self._data = {} + + async def evict(self, key): + self._data.pop(key, None) + + repo._cover_memory_cache = FakeLRU() + + count = await repo.delete_covers_for_artist(mbid) + assert count >= 3 + + +# --------------------------------------------------------------------------- +# Step 5 — YouTube cascade delete & orphan cleanup +# --------------------------------------------------------------------------- + + +class TestYouTubeStoreCascade: + @pytest.fixture() + def yt_db(self, tmp_path): + db_path = tmp_path / "test.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE youtube_links (album_id TEXT PRIMARY KEY, video_id TEXT)" + ) + conn.execute( + "CREATE TABLE youtube_track_links (album_id TEXT, track_number INT, disc_number INT, " + "album_name TEXT, track_name TEXT, video_id TEXT, artist_name TEXT, embed_url TEXT, created_at TEXT, " + "PRIMARY KEY (album_id, disc_number, track_number))" + ) + conn.execute("INSERT INTO youtube_links VALUES ('a1', 'v1')") + conn.execute( + "INSERT INTO youtube_track_links VALUES ('a1', 1, 1, 'Album', 'Track', 'v1', 'Artist', 'url', '2024-01-01')" + ) + conn.execute( + "INSERT INTO youtube_track_links VALUES ('orphan', 2, 1, 'Album', 'Track', 'v2', 'Artist', 'url', '2024-01-01')" + ) + conn.commit() + conn.close() + return db_path + + @pytest.mark.asyncio() + async def test_delete_youtube_link_cascades(self, yt_db): + from infrastructure.persistence.youtube_store import YouTubeStore + + store = YouTubeStore.__new__(YouTubeStore) + store._db_path = str(yt_db) + store._write_lock = asyncio.Lock() + + async def _write(fn): + conn = sqlite3.connect(str(yt_db)) + try: + result = fn(conn) + conn.commit() + return result + finally: + conn.close() + + store._write = _write + + await store.delete_youtube_link("a1") + + conn = sqlite3.connect(str(yt_db)) + assert conn.execute("SELECT COUNT(*) FROM youtube_links").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM youtube_track_links WHERE album_id='a1'").fetchone()[0] == 0 + conn.close() + + @pytest.mark.asyncio() + async def test_delete_orphaned_track_links(self, yt_db): + from infrastructure.persistence.youtube_store import YouTubeStore + + store = YouTubeStore.__new__(YouTubeStore) + store._db_path = str(yt_db) + store._write_lock = asyncio.Lock() + + async def _write(fn): + conn = sqlite3.connect(str(yt_db)) + try: + result = fn(conn) + conn.commit() + return result + finally: + conn.close() + + store._write = _write + + count = await store.delete_orphaned_track_links() + assert count == 1 + + conn = sqlite3.connect(str(yt_db)) + remaining = conn.execute("SELECT album_id FROM youtube_track_links").fetchall() + assert all(r[0] != "orphan" for r in remaining) + conn.close() + + +# --------------------------------------------------------------------------- +# Step 6 — RequestHistoryStore.prune_old_terminal_requests +# --------------------------------------------------------------------------- + + +class TestRequestHistoryPruning: + @pytest.fixture() + def rh_db(self, tmp_path): + db_path = tmp_path / "rh.db" + conn = sqlite3.connect(str(db_path)) + conn.execute( + "CREATE TABLE request_history (" + "musicbrainz_id_lower TEXT PRIMARY KEY, status TEXT, " + "requested_at TEXT, completed_at TEXT, lidarr_album_id TEXT)" + ) + old_date = "2020-01-01T00:00:00" + recent_date = datetime.now().isoformat() + conn.execute( + "INSERT INTO request_history VALUES (?, ?, ?, ?, ?)", + ("old-imported", "imported", old_date, old_date, "1"), + ) + conn.execute( + "INSERT INTO request_history VALUES (?, ?, ?, ?, ?)", + ("old-failed", "failed", old_date, old_date, "2"), + ) + conn.execute( + "INSERT INTO request_history VALUES (?, ?, ?, ?, ?)", + ("active-pending", "pending", recent_date, None, "3"), + ) + conn.execute( + "INSERT INTO request_history VALUES (?, ?, ?, ?, ?)", + ("recent-imported", "imported", recent_date, recent_date, "4"), + ) + conn.commit() + conn.close() + return db_path + + @pytest.mark.asyncio() + async def test_prunes_old_terminal(self, rh_db): + from infrastructure.persistence.request_history import RequestHistoryStore + + store = RequestHistoryStore.__new__(RequestHistoryStore) + store._db_path = str(rh_db) + store._write_lock = asyncio.Lock() + + async def _write(fn): + conn = sqlite3.connect(str(rh_db)) + conn.row_factory = sqlite3.Row + try: + result = fn(conn) + conn.commit() + return result + finally: + conn.close() + + store._write = _write + + count = await store.prune_old_terminal_requests(days=30) + assert count == 2 + + conn = sqlite3.connect(str(rh_db)) + remaining = conn.execute("SELECT musicbrainz_id_lower FROM request_history").fetchall() + ids = {r[0] for r in remaining} + assert "active-pending" in ids + assert "recent-imported" in ids + assert "old-imported" not in ids + conn.close() + + +# --------------------------------------------------------------------------- +# Step 8 — AdvancedSettings new fields +# --------------------------------------------------------------------------- + + +class TestAdvancedSettingsNewFields: + def test_default_values(self): + from api.v1.schemas.advanced_settings import AdvancedSettings + + settings = AdvancedSettings() + assert settings.request_history_retention_days == 180 + assert settings.ignored_releases_retention_days == 365 + assert settings.orphan_cover_demote_interval_hours == 24 + assert settings.store_prune_interval_hours == 6 + + def test_validation_rejects_out_of_range(self): + from api.v1.schemas.advanced_settings import AdvancedSettings + import msgspec + + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(request_history_retention_days=5) + + with pytest.raises(msgspec.ValidationError): + AdvancedSettings(orphan_cover_demote_interval_hours=0) + + +# --------------------------------------------------------------------------- +# Step 9 — GenreService.clear_disk_cache +# --------------------------------------------------------------------------- + + +class TestGenreDiskCacheClear: + def test_clears_json_files(self, tmp_path): + genre_dir = tmp_path / "genre_sections" + genre_dir.mkdir() + (genre_dir / "listenbrainz.json").write_text("{}") + (genre_dir / "lastfm.json").write_text("{}") + + from services.home.genre_service import GenreService + + svc = object.__new__(GenreService) + svc._genre_section_dir = genre_dir + + count = svc.clear_disk_cache() + assert count == 2 + assert not list(genre_dir.glob("*.json")) + + def test_returns_zero_for_missing_dir(self, tmp_path): + from services.home.genre_service import GenreService + + svc = object.__new__(GenreService) + svc._genre_section_dir = tmp_path / "nonexistent" + + assert svc.clear_disk_cache() == 0 + + +# --------------------------------------------------------------------------- +# Step 11 — HomeService.clear_genre_disk_cache facade +# --------------------------------------------------------------------------- + + +class TestHomeServiceGenreFacade: + def test_delegates_to_genre_service(self): + from services.home.facade import HomeService + + svc = object.__new__(HomeService) + mock_genre = MagicMock() + mock_genre.clear_disk_cache.return_value = 3 + svc._genre = mock_genre + + result = svc.clear_genre_disk_cache() + assert result == 3 + mock_genre.clear_disk_cache.assert_called_once() diff --git a/backend/tests/test_cache_key_contracts.py b/backend/tests/test_cache_key_contracts.py new file mode 100644 index 0000000..08964b1 --- /dev/null +++ b/backend/tests/test_cache_key_contracts.py @@ -0,0 +1,104 @@ +"""Contract tests — every key function must produce keys that start with its prefix constant.""" + +import pytest + +from infrastructure.cache.cache_keys import ( + LIDARR_PREFIX, + LIDARR_REQUESTED_PREFIX, + MB_ALBUM_SEARCH_PREFIX, + MB_ARTIST_DETAIL_PREFIX, + MB_ARTIST_SEARCH_PREFIX, + MB_RELEASE_DETAIL_PREFIX, + MB_RG_DETAIL_PREFIX, + PREFERENCES_PREFIX, + WIKIDATA_IMAGE_PREFIX, + WIKIDATA_URL_PREFIX, + WIKIPEDIA_PREFIX, + lidarr_artist_mbids_key, + lidarr_library_albums_key, + lidarr_library_artists_key, + lidarr_library_grouped_key, + lidarr_library_mbids_key, + lidarr_raw_albums_key, + lidarr_requested_mbids_key, + lidarr_status_key, + mb_album_search_key, + mb_artist_detail_key, + mb_artist_search_key, + mb_release_group_key, + mb_release_key, + preferences_key, + wikidata_artist_image_key, + wikidata_url_key, + wikipedia_extract_key, +) + + +@pytest.mark.parametrize( + "generated_key, expected_prefix", + [ + (mb_artist_search_key("test", 10, 0), MB_ARTIST_SEARCH_PREFIX), + (mb_artist_detail_key("abc-123"), MB_ARTIST_DETAIL_PREFIX), + (mb_album_search_key("test", 10, 0), MB_ALBUM_SEARCH_PREFIX), + (mb_release_group_key("abc"), MB_RG_DETAIL_PREFIX), + (mb_release_key("abc"), MB_RELEASE_DETAIL_PREFIX), + (lidarr_library_albums_key(), LIDARR_PREFIX), + (lidarr_library_albums_key(include_unmonitored=True), LIDARR_PREFIX), + (lidarr_library_artists_key(), LIDARR_PREFIX), + (lidarr_library_mbids_key(), LIDARR_PREFIX), + (lidarr_artist_mbids_key(), LIDARR_PREFIX), + (lidarr_raw_albums_key(), LIDARR_PREFIX), + (lidarr_library_grouped_key(), LIDARR_PREFIX), + (lidarr_requested_mbids_key(), LIDARR_REQUESTED_PREFIX), + (lidarr_status_key(), LIDARR_PREFIX), + (wikidata_artist_image_key("Q123"), WIKIDATA_IMAGE_PREFIX), + (wikidata_url_key("artist-1"), WIKIDATA_URL_PREFIX), + (wikipedia_extract_key("https://en.wikipedia.org/wiki/Test"), WIKIPEDIA_PREFIX), + (preferences_key(), PREFERENCES_PREFIX), + ], + ids=[ + "mb_artist_search", + "mb_artist_detail", + "mb_album_search", + "mb_release_group", + "mb_release", + "lidarr_library_albums_monitored", + "lidarr_library_albums_all", + "lidarr_library_artists", + "lidarr_library_mbids", + "lidarr_artist_mbids", + "lidarr_raw_albums", + "lidarr_library_grouped", + "lidarr_requested_mbids", + "lidarr_status", + "wikidata_image", + "wikidata_url", + "wikipedia_extract", + "preferences", + ], +) +def test_key_starts_with_prefix(generated_key: str, expected_prefix: str): + assert generated_key.startswith(expected_prefix), ( + f"Key {generated_key!r} does not start with prefix {expected_prefix!r}" + ) + + +@pytest.mark.parametrize( + "group_fn", + [ + pytest.param("musicbrainz_prefixes", id="musicbrainz"), + pytest.param("listenbrainz_prefixes", id="listenbrainz"), + pytest.param("lastfm_prefixes", id="lastfm"), + pytest.param("home_prefixes", id="home"), + ], +) +def test_invalidation_groups_return_list_of_strings(group_fn: str): + from infrastructure.cache import cache_keys + + fn = getattr(cache_keys, group_fn) + result = fn() + assert isinstance(result, list) + assert len(result) > 0, f"{group_fn}() returned an empty list" + assert all(isinstance(p, str) for p in result), ( + f"{group_fn}() contains non-string entries" + ) diff --git a/backend/tests/test_config_validation.py b/backend/tests/test_config_validation.py new file mode 100644 index 0000000..bb972da --- /dev/null +++ b/backend/tests/test_config_validation.py @@ -0,0 +1,217 @@ +"""Tests for config validation hardening and log_level application (CONSOLIDATED-08).""" + +import logging +from pathlib import Path +from unittest.mock import patch + +import msgspec +import pytest +from pydantic import ValidationError as PydanticValidationError + +from core.config import Settings +from core.exceptions import ConfigurationError + + +# Helpers + +def _make_settings(**overrides) -> Settings: + """Build a Settings with sensible defaults, applying overrides.""" + defaults = { + "lidarr_url": "http://lidarr:8686", + "jellyfin_url": "http://jellyfin:8096", + "lidarr_api_key": "test-key", + "config_file_path": Path("/tmp/musicseerr-test-config.json"), + } + defaults.update(overrides) + return Settings(**defaults) + + +def _write_config(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + f.write(msgspec.json.encode(data)) + + +# A. Config Validation — Critical errors raise + +class TestValidateConfigCriticalErrors: + def test_valid_config_creates_settings(self) -> None: + settings = _make_settings() + assert settings.lidarr_url == "http://lidarr:8686" + + def test_invalid_url_scheme_raises(self) -> None: + with pytest.raises((ConfigurationError, PydanticValidationError)): + _make_settings(lidarr_url="ftp://lidarr:8686") + + def test_invalid_jellyfin_url_scheme_raises(self) -> None: + with pytest.raises((ConfigurationError, PydanticValidationError)): + _make_settings(jellyfin_url="ftp://jellyfin:8096") + + def test_missing_api_key_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + settings = _make_settings(lidarr_api_key="") + assert settings.lidarr_api_key == "" + assert any("LIDARR_API_KEY" in r.message for r in caplog.records) + + def test_http_pool_mismatch_warns_but_does_not_raise(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + settings = _make_settings(http_max_connections=10, http_max_keepalive=20) + assert settings.http_max_connections == 10 + assert any("http_max_connections" in r.message for r in caplog.records) + + +# B. log_level field validator + +class TestLogLevelValidator: + @pytest.mark.parametrize("level", ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + def test_valid_levels_accepted(self, level: str) -> None: + settings = _make_settings(log_level=level) + assert settings.log_level == level + + def test_normalises_to_uppercase(self) -> None: + settings = _make_settings(log_level="debug") + assert settings.log_level == "DEBUG" + + def test_invalid_level_raises(self) -> None: + with pytest.raises(PydanticValidationError, match="(?i)invalid log_level"): + _make_settings(log_level="VERBOSE") + + def test_mixed_case_normalised(self) -> None: + settings = _make_settings(log_level="Warning") + assert settings.log_level == "WARNING" + + +# C. load_from_file — type validation + +class TestLoadFromFileTypeValidation: + def test_wrong_type_raises_configuration_error(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"port": "banana"}) + + settings = _make_settings(config_file_path=config_path) + with pytest.raises(ConfigurationError, match="type errors"): + settings.load_from_file() + + def test_coercible_type_succeeds(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"port": "8688"}) + + settings = _make_settings(config_file_path=config_path) + settings.load_from_file() + assert settings.port == 8688 + + def test_unknown_key_warns(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"totally_unknown_key": "value"}) + + settings = _make_settings(config_file_path=config_path) + with caplog.at_level(logging.WARNING): + settings.load_from_file() + assert any("Unknown config key" in r.message for r in caplog.records) + + def test_invalid_url_in_file_raises(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"lidarr_url": "ftp://bad-scheme:8686"}) + + settings = _make_settings(config_file_path=config_path) + with pytest.raises(ConfigurationError, match="(?i)critical configuration"): + settings.load_from_file() + + def test_valid_config_file_loads(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"port": 9999, "lidarr_api_key": "new-key"}) + + settings = _make_settings(config_file_path=config_path) + settings.load_from_file() + assert settings.port == 9999 + assert settings.lidarr_api_key == "new-key" + + def test_invalid_log_level_in_file_raises(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"log_level": "verbose"}) + + settings = _make_settings(config_file_path=config_path) + with pytest.raises(ConfigurationError, match="(?i)invalid log_level"): + settings.load_from_file() + + def test_log_level_normalised_through_file(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"log_level": "debug"}) + + settings = _make_settings(config_file_path=config_path) + settings.load_from_file() + assert settings.log_level == "DEBUG" + + def test_url_trailing_slash_stripped_through_file(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"lidarr_url": "http://lidarr:8686/"}) + + settings = _make_settings(config_file_path=config_path) + settings.load_from_file() + assert settings.lidarr_url == "http://lidarr:8686" + + def test_failed_load_does_not_partially_mutate(self, tmp_path: Path) -> None: + config_path = tmp_path / "config.json" + _write_config(config_path, {"port": 7777, "lidarr_url": "ftp://bad:1234"}) + + settings = _make_settings(config_file_path=config_path) + original_port = settings.port + with pytest.raises(ConfigurationError): + settings.load_from_file() + assert settings.port == original_port + + +# D. Log level application at startup + +class TestLogLevelApplication: + def test_log_level_applied_to_root_logger(self) -> None: + settings = _make_settings(log_level="DEBUG") + configured_level = getattr(logging, settings.log_level, logging.INFO) + root = logging.getLogger() + original = root.level + try: + root.setLevel(configured_level) + assert root.getEffectiveLevel() == logging.DEBUG + finally: + root.setLevel(original) + + def test_log_level_warning_applied(self) -> None: + settings = _make_settings(log_level="WARNING") + configured_level = getattr(logging, settings.log_level, logging.INFO) + root = logging.getLogger() + original = root.level + try: + root.setLevel(configured_level) + assert root.getEffectiveLevel() == logging.WARNING + finally: + root.setLevel(original) + + +# E. get_settings() cache safety + +class TestGetSettingsCacheSafety: + def test_failed_load_does_not_poison_cache(self, tmp_path: Path) -> None: + import core.config as config_module + + bad_path = tmp_path / "bad_config.json" + _write_config(bad_path, {"lidarr_url": "ftp://invalid"}) + + saved = config_module._settings + try: + config_module._settings = None + + with patch.object( + Settings, "model_post_init", lambda self, _ctx: None + ): + pass + + with patch.dict( + "os.environ", + {"CONFIG_FILE_PATH": str(bad_path)}, + ): + with pytest.raises((ConfigurationError, Exception)): + config_module.get_settings() + + assert config_module._settings is None + finally: + config_module._settings = saved diff --git a/backend/tests/test_dependencies_package.py b/backend/tests/test_dependencies_package.py new file mode 100644 index 0000000..ad9194a --- /dev/null +++ b/backend/tests/test_dependencies_package.py @@ -0,0 +1,82 @@ +"""Tests for the dependencies package structure and registry.""" + +import importlib + +import pytest + +from core.dependencies._registry import _singleton_registry, clear_all_singletons + + +class TestSingletonRegistry: + def test_registry_has_expected_count(self): + assert len(_singleton_registry) == 52 + + def test_all_entries_have_cache_clear(self): + for fn in _singleton_registry: + assert hasattr(fn, "cache_clear"), f"{fn.__name__} missing cache_clear" + + def test_clear_all_singletons_calls_cache_clear(self): + before = [fn.cache_info().currsize for fn in _singleton_registry] + clear_all_singletons() + after = [fn.cache_info().currsize for fn in _singleton_registry] + assert all(s == 0 for s in after) + + +class TestReExportCompleteness: + def test_init_exports_all_providers(self): + init = importlib.import_module("core.dependencies") + from core.dependencies import cache_providers, repo_providers, service_providers + + for mod in (cache_providers, repo_providers, service_providers): + for name in dir(mod): + obj = getattr(mod, name) + if name.startswith("get_") and getattr(obj, "__module__", "") == mod.__name__: + assert hasattr(init, name), f"{name} not re-exported from __init__" + + def test_init_exports_all_type_aliases(self): + init = importlib.import_module("core.dependencies") + from core.dependencies import type_aliases + + for name in dir(type_aliases): + if name.endswith("Dep"): + assert hasattr(init, name), f"{name} not re-exported from __init__" + + def test_init_exports_cleanup_functions(self): + from core.dependencies import ( + init_app_state, + cleanup_app_state, + clear_lastfm_dependent_caches, + clear_listenbrainz_dependent_caches, + clear_all_singletons, + ) + assert callable(init_app_state) + assert callable(cleanup_app_state) + assert callable(clear_lastfm_dependent_caches) + assert callable(clear_listenbrainz_dependent_caches) + assert callable(clear_all_singletons) + + +class TestSingletonDecorator: + def test_singleton_caches_return_value(self): + from core.dependencies._registry import singleton + + call_count = 0 + + @singleton + def my_provider(): + nonlocal call_count + call_count += 1 + return object() + + a = my_provider() + b = my_provider() + assert a is b + assert call_count == 1 + + my_provider.cache_clear() + c = my_provider() + assert c is not a + assert call_count == 2 + + # clean up: remove from registry + _singleton_registry.remove(my_provider) diff --git a/backend/tests/test_discover_queue_manager.py b/backend/tests/test_discover_queue_manager.py new file mode 100644 index 0000000..21b71c1 --- /dev/null +++ b/backend/tests/test_discover_queue_manager.py @@ -0,0 +1,303 @@ +"""Tests for DiscoverQueueManager background queue building.""" + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api.v1.schemas.discover import DiscoverQueueEnrichment, DiscoverQueueItemLight, DiscoverQueueResponse +from services.discover_queue_manager import DiscoverQueueManager, QueueBuildStatus + + +def _make_queue(n: int = 3) -> DiscoverQueueResponse: + return DiscoverQueueResponse( + items=[ + DiscoverQueueItemLight( + release_group_mbid=f"mbid-{i}", + album_name=f"Album {i}", + artist_name=f"Artist {i}", + artist_mbid=f"artist-{i}", + cover_url=None, + recommendation_reason="test", + ) + for i in range(n) + ], + queue_id="test-queue-id", + ) + + +def _make_manager( + queue: DiscoverQueueResponse | None = None, + build_error: Exception | None = None, + ttl: int = 86400, +) -> DiscoverQueueManager: + discover = AsyncMock() + if build_error: + discover.build_queue.side_effect = build_error + else: + discover.build_queue.return_value = queue or _make_queue() + discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) + + prefs = MagicMock() + adv = MagicMock() + adv.discover_queue_ttl = ttl + prefs.get_advanced_settings.return_value = adv + + return DiscoverQueueManager(discover, prefs) + + +@pytest.mark.asyncio +async def test_initial_status_is_idle(): + expect_assertions = True + mgr = _make_manager() + status = mgr.get_status("listenbrainz") + assert status.status == "idle" + assert status.source == "listenbrainz" + + +@pytest.mark.asyncio +async def test_start_build_changes_status(): + expect_assertions = True + mgr = _make_manager() + result = await mgr.start_build("listenbrainz") + assert result.action == "started" + assert result.status in ("building", "ready") + + +@pytest.mark.asyncio +async def test_build_produces_ready_queue(): + expect_assertions = True + queue = _make_queue(5) + mgr = _make_manager(queue=queue) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + status = mgr.get_status("listenbrainz") + assert status.status == "ready" + assert status.item_count == 5 + built_queue = mgr.get_queue("listenbrainz") + assert built_queue is not None + assert all(item.enrichment is not None for item in built_queue.items) + + +@pytest.mark.asyncio +async def test_enrichment_failures_fall_back_to_empty_enrichment(): + expect_assertions = True + queue = _make_queue(2) + mgr = _make_manager(queue=queue) + mgr._discover.enrich_queue_item.side_effect = RuntimeError("enrichment failed") + + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + built_queue = mgr.get_queue("listenbrainz") + assert built_queue is not None + assert all(item.enrichment is not None for item in built_queue.items) + + +@pytest.mark.asyncio +async def test_get_queue_returns_cached(): + expect_assertions = True + queue = _make_queue(3) + mgr = _make_manager(queue=queue) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + result = mgr.get_queue("listenbrainz") + assert result is not None + assert len(result.items) == 3 + + +@pytest.mark.asyncio +async def test_consume_queue_returns_and_clears(): + expect_assertions = True + mgr = _make_manager() + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + consumed = await mgr.consume_queue("listenbrainz") + assert consumed is not None + assert len(consumed.items) == 3 + + assert mgr.get_queue("listenbrainz") is None + assert mgr.get_status("listenbrainz").status == "idle" + + +@pytest.mark.asyncio +async def test_build_error_sets_error_status(): + expect_assertions = True + mgr = _make_manager(build_error=RuntimeError("test fail")) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + status = mgr.get_status("listenbrainz") + assert status.status == "error" + assert "test fail" in (status.error or "") + + +@pytest.mark.asyncio +async def test_already_building_is_no_op(): + expect_assertions = True + + slow_discover = AsyncMock() + + async def slow_build(**kwargs): + await asyncio.sleep(5) + return _make_queue() + + slow_discover.build_queue.side_effect = slow_build + prefs = MagicMock() + adv = MagicMock() + adv.discover_queue_ttl = 86400 + prefs.get_advanced_settings.return_value = adv + + mgr = DiscoverQueueManager(slow_discover, prefs) + await mgr.start_build("listenbrainz") + result = await mgr.start_build("listenbrainz") + assert result.action == "already_building" + + mgr.invalidate("listenbrainz") + + +@pytest.mark.asyncio +async def test_force_rebuild_when_ready(): + expect_assertions = True + mgr = _make_manager() + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + result = await mgr.start_build("listenbrainz", force=True) + assert result.action == "started" + + +@pytest.mark.asyncio +async def test_invalidate_resets_state(): + expect_assertions = True + mgr = _make_manager() + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + mgr.invalidate("listenbrainz") + status = mgr.get_status("listenbrainz") + assert status.status == "idle" + + +@pytest.mark.asyncio +async def test_separate_sources_are_independent(): + expect_assertions = True + mgr = _make_manager() + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + lb_status = mgr.get_status("listenbrainz") + lfm_status = mgr.get_status("lastfm") + assert lb_status.status == "ready" + assert lfm_status.status == "idle" + + +@pytest.mark.asyncio +async def test_consume_queue_rejects_stale(): + expect_assertions = True + mgr = _make_manager(ttl=1) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + assert mgr.get_status("listenbrainz").status == "ready" + + mgr._get_state("listenbrainz").built_at = time.time() - 10 + + consumed = await mgr.consume_queue("listenbrainz") + assert consumed is None + assert mgr.get_status("listenbrainz").status == "idle" + + +@pytest.mark.asyncio +async def test_get_queue_rejects_stale(): + expect_assertions = True + mgr = _make_manager(ttl=1) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + assert mgr.get_queue("listenbrainz") is not None + + mgr._get_state("listenbrainz").built_at = time.time() - 10 + + assert mgr.get_queue("listenbrainz") is None + + +@pytest.mark.asyncio +async def test_stale_flag_in_status(): + expect_assertions = True + mgr = _make_manager(ttl=1) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + status = mgr.get_status("listenbrainz") + assert status.stale is False + + mgr._get_state("listenbrainz").built_at = time.time() - 10 + + status = mgr.get_status("listenbrainz") + assert status.stale is True + + +@pytest.mark.asyncio +async def test_build_prewarms_covers(): + expect_assertions = True + queue = _make_queue(3) + cover_repo = AsyncMock() + cover_repo.get_release_group_cover = AsyncMock(return_value=(b"img", "image/jpeg", "caa")) + + discover = AsyncMock() + discover.build_queue.return_value = queue + discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) + + prefs = MagicMock() + adv = MagicMock() + adv.discover_queue_ttl = 86400 + prefs.get_advanced_settings.return_value = adv + + mgr = DiscoverQueueManager(discover, prefs, cover_repo=cover_repo) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.3) + + assert cover_repo.get_release_group_cover.call_count == 3 + called_mbids = sorted( + call.args[0] for call in cover_repo.get_release_group_cover.call_args_list + ) + assert called_mbids == ["mbid-0", "mbid-1", "mbid-2"] + + +@pytest.mark.asyncio +async def test_build_prewarm_skipped_without_cover_repo(): + expect_assertions = True + mgr = _make_manager() + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.1) + + assert mgr.get_status("listenbrainz").status == "ready" + + +@pytest.mark.asyncio +async def test_build_prewarm_failure_does_not_break_queue(): + expect_assertions = True + queue = _make_queue(2) + cover_repo = AsyncMock() + cover_repo.get_release_group_cover = AsyncMock(side_effect=RuntimeError("fetch failed")) + + discover = AsyncMock() + discover.build_queue.return_value = queue + discover.enrich_queue_item = AsyncMock(return_value=DiscoverQueueEnrichment()) + + prefs = MagicMock() + adv = MagicMock() + adv.discover_queue_ttl = 86400 + prefs.get_advanced_settings.return_value = adv + + mgr = DiscoverQueueManager(discover, prefs, cover_repo=cover_repo) + await mgr.start_build("listenbrainz") + await asyncio.sleep(0.3) + + assert mgr.get_status("listenbrainz").status == "ready" + assert mgr.get_queue("listenbrainz") is not None diff --git a/backend/tests/test_error_leakage.py b/backend/tests/test_error_leakage.py new file mode 100644 index 0000000..56148e2 --- /dev/null +++ b/backend/tests/test_error_leakage.py @@ -0,0 +1,74 @@ +"""Tests that exception handlers do not leak internal details.""" + +import pytest +from fastapi import FastAPI +import httpx + +from core.exceptions import ExternalServiceError +from core.exception_handlers import ( + external_service_error_handler, + circuit_open_error_handler, + general_exception_handler, +) +from infrastructure.resilience.retry import CircuitOpenError + + +def _build_app() -> FastAPI: + app = FastAPI() + + @app.get("/raise-general") + async def raise_general(): + raise RuntimeError("secret internal path /app/main.py") + + @app.get("/raise-external") + async def raise_external(): + raise ExternalServiceError("connection to 10.0.0.5:8096 refused") + + @app.get("/raise-circuit") + async def raise_circuit(): + raise CircuitOpenError("JellyfinRepository after 5 failures") + + app.add_exception_handler(ExternalServiceError, external_service_error_handler) + app.add_exception_handler(CircuitOpenError, circuit_open_error_handler) + app.add_exception_handler(Exception, general_exception_handler) + + return app + + +@pytest.mark.asyncio +async def test_general_exception_handler_hides_details(): + app = _build_app() + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/raise-general") + + body = resp.json() + assert resp.status_code == 500 + assert body["error"]["message"] == "Internal server error" + assert "/app/main.py" not in resp.text + + +@pytest.mark.asyncio +async def test_external_service_error_hides_details(): + app = _build_app() + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/raise-external") + + body = resp.json() + assert resp.status_code == 503 + assert body["error"]["message"] == "External service unavailable" + assert "10.0.0.5" not in resp.text + + +@pytest.mark.asyncio +async def test_circuit_open_error_hides_details(): + app = _build_app() + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/raise-circuit") + + body = resp.json() + assert resp.status_code == 503 + assert body["error"]["message"] == "Service temporarily unavailable" + assert "JellyfinRepository" not in resp.text diff --git a/backend/tests/test_graceful_shutdown.py b/backend/tests/test_graceful_shutdown.py new file mode 100644 index 0000000..9b5b345 --- /dev/null +++ b/backend/tests/test_graceful_shutdown.py @@ -0,0 +1,54 @@ +import asyncio +import pytest +from core.task_registry import TaskRegistry + + +@pytest.fixture(autouse=True) +def clean_registry(): + TaskRegistry.get_instance().reset() + yield + TaskRegistry.get_instance().reset() + + +@pytest.mark.asyncio +async def test_cancel_all_stops_long_running_tasks(): + registry = TaskRegistry.get_instance() + tasks = [asyncio.create_task(asyncio.sleep(1000)) for _ in range(5)] + for i, t in enumerate(tasks): + registry.register(f"long-{i}", t) + await registry.cancel_all(grace_period=2.0) + assert all(t.done() for t in tasks) + + +@pytest.mark.asyncio +async def test_cancel_all_respects_grace_period(): + registry = TaskRegistry.get_instance() + + async def stubborn_task(): + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + await asyncio.sleep(10) + + t = asyncio.create_task(stubborn_task()) + registry.register("stubborn", t) + await registry.cancel_all(grace_period=0.5) + assert len(registry.get_all()) == 0 + + +@pytest.mark.asyncio +async def test_shutdown_with_empty_registry(): + registry = TaskRegistry.get_instance() + await registry.cancel_all(grace_period=1.0) + assert len(registry.get_all()) == 0 + + +@pytest.mark.asyncio +async def test_shutdown_with_already_done_tasks(): + registry = TaskRegistry.get_instance() + t = asyncio.create_task(asyncio.sleep(0)) + registry.register("already-done", t) + await t + await asyncio.sleep(0.05) + await registry.cancel_all(grace_period=1.0) + assert len(registry.get_all()) == 0 diff --git a/backend/tests/test_home_integration_helpers.py b/backend/tests/test_home_integration_helpers.py new file mode 100644 index 0000000..a01cdf5 --- /dev/null +++ b/backend/tests/test_home_integration_helpers.py @@ -0,0 +1,193 @@ +"""Tests for HomeIntegrationHelpers — all 8 integration checks + resolve_source.""" + +import pytest +from unittest.mock import MagicMock + +from services.home.integration_helpers import HomeIntegrationHelpers + + +def _make_prefs(**overrides): + prefs = MagicMock() + + lb = MagicMock() + lb.enabled = overrides.get("lb_enabled", True) + lb.username = overrides.get("lb_username", "testuser") + lb.user_token = "tok" + prefs.get_listenbrainz_connection.return_value = lb + + jf = MagicMock() + jf.enabled = overrides.get("jf_enabled", False) + jf.jellyfin_url = overrides.get("jf_url", "") + jf.api_key = overrides.get("jf_api_key", "") + prefs.get_jellyfin_connection.return_value = jf + + lidarr = MagicMock() + lidarr.lidarr_url = overrides.get("lidarr_url", "") + lidarr.lidarr_api_key = overrides.get("lidarr_api_key", "") + prefs.get_lidarr_connection.return_value = lidarr + + yt = MagicMock() + yt.enabled = overrides.get("yt_enabled", False) + yt.api_enabled = overrides.get("yt_api_enabled", False) + yt.has_valid_api_key = MagicMock(return_value=overrides.get("yt_valid_key", False)) + prefs.get_youtube_connection.return_value = yt + + lf = MagicMock() + lf.enabled = overrides.get("lf_enabled", False) + lf.music_path = overrides.get("lf_music_path", "") + prefs.get_local_files_connection.return_value = lf + + nd = MagicMock() + nd.enabled = overrides.get("nd_enabled", False) + nd.navidrome_url = overrides.get("nd_url", "") + nd.username = overrides.get("nd_username", "") + nd.password = overrides.get("nd_password", "") + prefs.get_navidrome_connection.return_value = nd + + lfm = MagicMock() + lfm.enabled = overrides.get("lfm_enabled", False) + lfm.username = overrides.get("lfm_username", "") + prefs.get_lastfm_connection.return_value = lfm + prefs.is_lastfm_enabled.return_value = overrides.get("lfm_enabled", False) + + source = MagicMock() + source.source = overrides.get("primary_source", "listenbrainz") + prefs.get_primary_music_source.return_value = source + + return prefs + + +class TestIntegrationFlags: + def test_listenbrainz_enabled(self): + h = HomeIntegrationHelpers(_make_prefs(lb_enabled=True, lb_username="u")) + assert h.is_listenbrainz_enabled() is True + + def test_listenbrainz_disabled_no_username(self): + h = HomeIntegrationHelpers(_make_prefs(lb_enabled=True, lb_username="")) + assert h.is_listenbrainz_enabled() is False + + def test_jellyfin_enabled(self): + h = HomeIntegrationHelpers( + _make_prefs(jf_enabled=True, jf_url="http://jf", jf_api_key="key") + ) + assert h.is_jellyfin_enabled() is True + + def test_jellyfin_disabled_missing_url(self): + h = HomeIntegrationHelpers( + _make_prefs(jf_enabled=True, jf_url="", jf_api_key="key") + ) + assert h.is_jellyfin_enabled() is False + + def test_lidarr_configured(self): + h = HomeIntegrationHelpers( + _make_prefs(lidarr_url="http://l", lidarr_api_key="k") + ) + assert h.is_lidarr_configured() is True + + def test_lidarr_not_configured(self): + h = HomeIntegrationHelpers(_make_prefs()) + assert h.is_lidarr_configured() is False + + def test_youtube_enabled(self): + h = HomeIntegrationHelpers(_make_prefs(yt_enabled=True)) + assert h.is_youtube_enabled() is True + + def test_youtube_api_enabled(self): + h = HomeIntegrationHelpers( + _make_prefs(yt_enabled=True, yt_api_enabled=True, yt_valid_key=True) + ) + assert h.is_youtube_api_enabled() is True + + def test_local_files_enabled(self): + h = HomeIntegrationHelpers( + _make_prefs(lf_enabled=True, lf_music_path="/music") + ) + assert h.is_local_files_enabled() is True + + def test_navidrome_enabled(self): + h = HomeIntegrationHelpers( + _make_prefs( + nd_enabled=True, nd_url="http://nd", nd_username="u", nd_password="p" + ) + ) + assert h.is_navidrome_enabled() is True + + def test_navidrome_disabled_missing_password(self): + h = HomeIntegrationHelpers( + _make_prefs(nd_enabled=True, nd_url="http://nd", nd_username="u", nd_password="") + ) + assert h.is_navidrome_enabled() is False + + def test_lastfm_enabled(self): + h = HomeIntegrationHelpers(_make_prefs(lfm_enabled=True)) + assert h.is_lastfm_enabled() is True + + +class TestResolveSource: + def test_explicit_listenbrainz(self): + h = HomeIntegrationHelpers( + _make_prefs(lb_enabled=True, lb_username="u", primary_source="lastfm") + ) + assert h.resolve_source("listenbrainz") == "listenbrainz" + + def test_explicit_lastfm(self): + h = HomeIntegrationHelpers( + _make_prefs(lfm_enabled=True, primary_source="listenbrainz") + ) + assert h.resolve_source("lastfm") == "lastfm" + + def test_none_uses_global(self): + h = HomeIntegrationHelpers(_make_prefs(primary_source="lastfm", lfm_enabled=True)) + assert h.resolve_source(None) == "lastfm" + + def test_fallback_to_lastfm_when_lb_disabled(self): + h = HomeIntegrationHelpers( + _make_prefs( + lb_enabled=False, lb_username="", lfm_enabled=True, primary_source="listenbrainz" + ) + ) + assert h.resolve_source(None) == "lastfm" + + def test_fallback_to_lb_when_lfm_disabled(self): + h = HomeIntegrationHelpers( + _make_prefs( + lb_enabled=True, lb_username="u", lfm_enabled=False, primary_source="lastfm" + ) + ) + assert h.resolve_source(None) == "listenbrainz" + + +class TestExecuteTasks: + @pytest.mark.asyncio + async def test_execute_tasks_returns_results(self): + h = HomeIntegrationHelpers(_make_prefs()) + + async def task_a(): + return "a_result" + + async def task_b(): + return "b_result" + + results = await h.execute_tasks({"a": task_a(), "b": task_b()}) + assert results["a"] == "a_result" + assert results["b"] == "b_result" + + @pytest.mark.asyncio + async def test_execute_tasks_handles_failures(self): + h = HomeIntegrationHelpers(_make_prefs()) + + async def good(): + return "ok" + + async def bad(): + raise ValueError("boom") + + results = await h.execute_tasks({"good": good(), "bad": bad()}) + assert results["good"] == "ok" + assert results["bad"] is None + + @pytest.mark.asyncio + async def test_execute_tasks_empty(self): + h = HomeIntegrationHelpers(_make_prefs()) + results = await h.execute_tasks({}) + assert results == {} diff --git a/backend/tests/test_home_section_builders.py b/backend/tests/test_home_section_builders.py new file mode 100644 index 0000000..7fa8e69 --- /dev/null +++ b/backend/tests/test_home_section_builders.py @@ -0,0 +1,84 @@ +"""Tests for HomeSectionBuilders — verify section shapes.""" + +from unittest.mock import MagicMock + +from api.v1.schemas.home import HomeSection, ServicePrompt +from api.v1.schemas.library import LibraryAlbum +from services.home.section_builders import HomeSectionBuilders + + +def _make_builders(): + transformers = MagicMock() + transformers.lidarr_album_to_home.side_effect = lambda a: {"album": a.album} + transformers.lidarr_artist_to_home.side_effect = lambda a: {"name": a.get("name")} if a.get("name") else None + return HomeSectionBuilders(transformers) + + +class TestBuildRecentlyAdded: + def test_returns_home_section(self): + b = _make_builders() + albums = [ + LibraryAlbum(artist="A", album=f"Album{i}", monitored=True) + for i in range(20) + ] + section = b.build_recently_added_section(albums) + assert isinstance(section, HomeSection) + assert section.title == "Recently Added" + assert section.type == "albums" + assert len(section.items) == 15 # capped at 15 + + +class TestBuildLibraryArtists: + def test_sorts_by_album_count(self): + b = _make_builders() + artists = [ + {"name": "Low", "mbid": "a", "album_count": 1}, + {"name": "High", "mbid": "b", "album_count": 10}, + ] + section = b.build_library_artists_section(artists) + assert isinstance(section, HomeSection) + assert section.title == "Your Artists" + assert len(section.items) <= 15 + + +class TestBuildServicePrompts: + def test_no_prompts_when_all_enabled(self): + b = _make_builders() + prompts = b.build_service_prompts( + lb_enabled=True, lidarr_configured=True, lfm_enabled=True + ) + assert prompts == [] + + def test_all_prompts_when_nothing_enabled(self): + b = _make_builders() + prompts = b.build_service_prompts( + lb_enabled=False, lidarr_configured=False, lfm_enabled=False + ) + assert len(prompts) > 0 + services = {p.service for p in prompts} + assert "lidarr-connection" in services + + def test_prompts_are_service_prompt_type(self): + b = _make_builders() + prompts = b.build_service_prompts( + lb_enabled=False, lidarr_configured=False, lfm_enabled=False + ) + for p in prompts: + assert isinstance(p, ServicePrompt) + + +class TestBuildGenreList: + def test_returns_section_with_correct_shape(self): + transformers = MagicMock() + from api.v1.schemas.home import HomeGenre + transformers.extract_genres_from_library.return_value = [ + HomeGenre(name="rock"), HomeGenre(name="pop") + ] + b = HomeSectionBuilders(transformers) + albums = [ + LibraryAlbum(artist="A", album="X", monitored=True), + ] + section = b.build_genre_list_section(albums) + assert isinstance(section, HomeSection) + assert section.type == "genres" + assert len(section.items) == 2 diff --git a/backend/tests/test_lastfm_cache_invalidation.py b/backend/tests/test_lastfm_cache_invalidation.py new file mode 100644 index 0000000..f459a31 --- /dev/null +++ b/backend/tests/test_lastfm_cache_invalidation.py @@ -0,0 +1,73 @@ +"""Tests that Last.fm cache invalidation covers all dependent service factories.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from core import dependencies as deps +from core.dependencies import cleanup as _cleanup_mod + + +LASTFM_DEPENDENT_FACTORIES = [ + "get_artist_discovery_service", + "get_artist_enrichment_service", + "get_album_enrichment_service", + "get_search_enrichment_service", + "get_scrobble_service", + "get_home_charts_service", + "get_home_service", + "get_discover_service", + "get_lastfm_auth_service", +] + + +def test_clear_lastfm_dependent_caches_clears_all_consumers(): + mocks = {} + patches = [] + for name in LASTFM_DEPENDENT_FACTORIES: + mock_fn = MagicMock() + mock_fn.cache_clear = MagicMock() + # Patch in the cleanup module's namespace where the function is actually called + p = patch.object(_cleanup_mod, name, mock_fn) + p.start() + patches.append(p) + mocks[name] = mock_fn + + try: + deps.clear_lastfm_dependent_caches() + for name, mock_fn in mocks.items(): + assert mock_fn.cache_clear.called, f"{name}.cache_clear() was not called" + finally: + for p in patches: + p.stop() + + +def test_all_lastfm_factories_are_tracked(): + """Verify every @lru_cache factory that receives lastfm_repo is in the clear list.""" + import ast + import inspect + + # After the dependencies package split, scan the sub-module that holds + # service providers (where lastfm_repo usage lives). + from core.dependencies import service_providers as svc_mod + + source = inspect.getsource(svc_mod) + tree = ast.parse(source) + + factories_with_lastfm = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + if not node.name.startswith("get_"): + continue + body_source = ast.get_source_segment(source, node) + if body_source and "get_lastfm_repository()" in body_source: + if node.name != "get_lastfm_repository": + factories_with_lastfm.add(node.name) + + tracked = set(LASTFM_DEPENDENT_FACTORIES) + missing = factories_with_lastfm - tracked + assert not missing, ( + f"Factories that use lastfm_repo but are NOT in " + f"clear_lastfm_dependent_caches: {missing}" + ) diff --git a/backend/tests/test_lidarr_skip_unconfigured.py b/backend/tests/test_lidarr_skip_unconfigured.py new file mode 100644 index 0000000..aa2bd92 --- /dev/null +++ b/backend/tests/test_lidarr_skip_unconfigured.py @@ -0,0 +1,191 @@ +"""Tests for skipping Lidarr when not configured (no API key).""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from core.config import Settings +from core.exceptions import ExternalServiceError + + +@pytest.fixture +def unconfigured_settings(): + settings = MagicMock(spec=Settings) + settings.lidarr_url = "http://lidarr:8686" + settings.lidarr_api_key = "" + return settings + + +@pytest.fixture +def configured_settings(): + settings = MagicMock(spec=Settings) + settings.lidarr_url = "http://lidarr:8686" + settings.lidarr_api_key = "test-api-key-123" + return settings + + +class TestLidarrBaseIsConfigured: + def test_not_configured_when_api_key_empty(self, unconfigured_settings): + from repositories.lidarr.base import LidarrBase + + base = LidarrBase(unconfigured_settings, MagicMock(), MagicMock()) + assert base.is_configured() is False + + def test_configured_when_api_key_set(self, configured_settings): + from repositories.lidarr.base import LidarrBase + + base = LidarrBase(configured_settings, MagicMock(), MagicMock()) + assert base.is_configured() is True + + +class TestLidarrRequestGuard: + @pytest.mark.asyncio + async def test_request_raises_when_not_configured(self, unconfigured_settings): + from repositories.lidarr.base import LidarrBase + + base = LidarrBase(unconfigured_settings, MagicMock(), MagicMock()) + with pytest.raises(ExternalServiceError, match="not configured"): + await base._request("GET", "/api/v1/album") + + +class TestAlbumServiceSkipsLidarr: + @pytest.mark.asyncio + async def test_basic_info_skips_lidarr_when_unconfigured(self): + """When Lidarr is not configured, get_album_basic_info must not call any Lidarr methods.""" + from services.album_service import AlbumService + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + lidarr.get_requested_mbids = AsyncMock() + lidarr.get_album_details = AsyncMock() + + mb_repo = MagicMock() + mb_repo.get_release_group_by_id = AsyncMock(return_value={ + "id": "f50a3b6f-27f0-3832-bd3f-3568dc557d95", + "title": "Beatles for Sale", + "primary-type": "Album", + "artist-credit": [{"artist": {"id": "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d", "name": "The Beatles"}}], + "first-release-date": "1964-12-04", + "releases": [], + }) + mb_repo.get_release_group = mb_repo.get_release_group_by_id + + memory_cache = AsyncMock() + memory_cache.get.return_value = None + disk_cache = AsyncMock() + disk_cache.get_album.return_value = None + library_db = AsyncMock() + library_db.get_album_by_mbid.return_value = None + prefs = MagicMock() + prefs.get_advanced_settings.return_value = MagicMock( + cache_ttl_album_library=86400, + cache_ttl_album_non_library=86400, + ) + audiodb_svc = MagicMock() + audiodb_svc.fetch_and_cache_album_thumb = AsyncMock(return_value=None) + audiodb_svc.fetch_and_cache_album_images = AsyncMock(return_value=None) + + svc = AlbumService( + lidarr_repo=lidarr, + mb_repo=mb_repo, + memory_cache=memory_cache, + disk_cache=disk_cache, + library_db=library_db, + preferences_service=prefs, + audiodb_image_service=audiodb_svc, + ) + + result = await svc.get_album_basic_info("f50a3b6f-27f0-3832-bd3f-3568dc557d95") + + lidarr.get_requested_mbids.assert_not_called() + lidarr.get_album_details.assert_not_called() + assert result.title == "Beatles for Sale" + assert result.artist_name == "The Beatles" + + +class TestCoverArtSkipsLidarr: + @pytest.mark.asyncio + async def test_album_cover_skips_lidarr_when_unconfigured(self): + from repositories.coverart_album import AlbumCoverFetcher + from pathlib import Path + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + lidarr.get_album_image_url = AsyncMock() + + fetcher = AlbumCoverFetcher.__new__(AlbumCoverFetcher) + fetcher._lidarr_repo = lidarr + + result = await fetcher._fetch_from_lidarr("test-id", Path("/tmp/test"), size=500) + + assert result is None + lidarr.get_album_image_url.assert_not_called() + + @pytest.mark.asyncio + async def test_artist_cover_skips_lidarr_when_unconfigured(self): + from repositories.coverart_artist import ArtistImageFetcher + from pathlib import Path + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + lidarr.get_artist_image_url = AsyncMock() + + fetcher = ArtistImageFetcher.__new__(ArtistImageFetcher) + fetcher._lidarr_repo = lidarr + + result = await fetcher._fetch_from_lidarr("test-id", None, Path("/tmp/test")) + + assert result is None + lidarr.get_artist_image_url.assert_not_called() + + +class TestRequestServiceSkipsLidarr: + @pytest.mark.asyncio + async def test_request_album_raises_when_unconfigured(self): + from services.request_service import RequestService + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + + svc = RequestService(lidarr, MagicMock(), MagicMock()) + + with pytest.raises(ExternalServiceError, match="not configured"): + await svc.request_album("test-mbid") + + +class TestLibraryServiceSkipsLidarr: + @pytest.mark.asyncio + async def test_get_library_mbids_returns_empty_when_unconfigured(self): + from services.library_service import LibraryService + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + + svc = LibraryService.__new__(LibraryService) + svc._lidarr_repo = lidarr + + result = await svc.get_library_mbids() + assert result == [] + + @pytest.mark.asyncio + async def test_get_requested_mbids_returns_empty_when_unconfigured(self): + from services.library_service import LibraryService + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + + svc = LibraryService.__new__(LibraryService) + svc._lidarr_repo = lidarr + + result = await svc.get_requested_mbids() + assert result == [] + + @pytest.mark.asyncio + async def test_sync_library_raises_when_unconfigured(self): + from services.library_service import LibraryService + + lidarr = MagicMock() + lidarr.is_configured.return_value = False + + svc = LibraryService.__new__(LibraryService) + svc._lidarr_repo = lidarr + + with pytest.raises(ExternalServiceError, match="not configured"): + await svc.sync_library() diff --git a/backend/tests/test_phase9_observability.py b/backend/tests/test_phase9_observability.py new file mode 100644 index 0000000..1df9e14 --- /dev/null +++ b/backend/tests/test_phase9_observability.py @@ -0,0 +1,376 @@ +"""Phase 9 observability contract tests. + +Verifies that every key log event fires with the required fields, and that +SSE / cache-stats wiring propagates AudioDB data end-to-end. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import msgspec +import pytest + +from tests.helpers import assert_log_fields +from repositories.audiodb_models import ( + AudioDBArtistImages, + AudioDBArtistResponse, + AudioDBAlbumImages, + AudioDBAlbumResponse, +) +from services.audiodb_image_service import AudioDBImageService + +TEST_MBID = "cc197bad-dc9c-440d-a5b5-d52ba2e14234" +TEST_ALBUM_MBID = "1dc4c347-a1db-32aa-b14f-bc9cc507b843" + +SAMPLE_ARTIST_RESP = AudioDBArtistResponse( + idArtist="111239", + strArtist="Coldplay", + strMusicBrainzID=TEST_MBID, + strArtistThumb="https://example.com/thumb.jpg", + strArtistFanart="https://example.com/fanart.jpg", +) + +SAMPLE_ALBUM_RESP = AudioDBAlbumResponse( + idAlbum="2115888", + strAlbum="Parachutes", + strMusicBrainzID=TEST_ALBUM_MBID, + strAlbumThumb="https://example.com/album_thumb.jpg", + strAlbumBack="https://example.com/album_back.jpg", +) + + +def _make_settings(enabled=True, name_search_fallback=False): + s = MagicMock() + s.audiodb_enabled = enabled + s.audiodb_name_search_fallback = name_search_fallback + s.cache_ttl_audiodb_found = 604800 + s.cache_ttl_audiodb_not_found = 86400 + s.cache_ttl_audiodb_library = 1209600 + return s + + +def _make_image_service(settings=None, disk_cache=None, repo=None): + if settings is None: + settings = _make_settings() + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + if disk_cache is None: + disk_cache = AsyncMock() + disk_cache.get_audiodb_artist = AsyncMock(return_value=None) + disk_cache.get_audiodb_album = AsyncMock(return_value=None) + disk_cache.set_audiodb_artist = AsyncMock() + disk_cache.set_audiodb_album = AsyncMock() + if repo is None: + repo = AsyncMock() + return AudioDBImageService( + audiodb_repo=repo, + disk_cache=disk_cache, + preferences_service=prefs, + ) + + + + +CACHE_REQUIRED_FIELDS = ["action", "entity_type", "mbid", "lookup_source"] + + +class TestCacheLogContract: + @pytest.mark.asyncio + async def test_miss_includes_lookup_source(self, caplog): + svc = _make_image_service() + with caplog.at_level("DEBUG"): + await svc.get_cached_artist_images(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=miss" in m for m in msgs) + + @pytest.mark.asyncio + async def test_corrupt_includes_lookup_source(self, caplog): + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value="not-a-dict") + disk.delete_entity = AsyncMock() + svc = _make_image_service(disk_cache=disk) + with caplog.at_level("DEBUG"): + await svc.get_cached_artist_images(TEST_MBID) + + msgs = assert_log_fields( + caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS, + ) + assert any("action=corrupt" in m for m in msgs) + + @pytest.mark.asyncio + async def test_hit_includes_lookup_source(self, caplog): + images = AudioDBArtistImages( + thumb_url="https://example.com/thumb.jpg", + is_negative=False, + lookup_source="mbid", + ) + raw = msgspec.structs.asdict(images) + disk = AsyncMock() + disk.get_audiodb_artist = AsyncMock(return_value=raw) + svc = _make_image_service(disk_cache=disk) + with caplog.at_level("DEBUG"): + await svc.get_cached_artist_images(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=hit" in m for m in msgs) + + @pytest.mark.asyncio + async def test_write_mbid_includes_lookup_source(self, caplog): + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(return_value=SAMPLE_ARTIST_RESP) + svc = _make_image_service(repo=repo) + with caplog.at_level("DEBUG"): + await svc.fetch_and_cache_artist_images(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=write" in m and "lookup_source=mbid" in m for m in msgs) + + @pytest.mark.asyncio + async def test_fetch_error_mbid_includes_lookup_source(self, caplog): + repo = AsyncMock() + repo.get_artist_by_mbid = AsyncMock(side_effect=Exception("network")) + svc = _make_image_service(repo=repo) + with caplog.at_level("DEBUG"): + await svc.fetch_and_cache_artist_images(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=fetch_error" in m and "lookup_source=mbid" in m for m in msgs) + + @pytest.mark.asyncio + async def test_album_miss_includes_lookup_source(self, caplog): + svc = _make_image_service() + with caplog.at_level("DEBUG"): + await svc.get_cached_album_images(TEST_ALBUM_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=miss" in m and "entity_type=album" in m for m in msgs) + + @pytest.mark.asyncio + async def test_album_write_mbid_includes_lookup_source(self, caplog): + repo = AsyncMock() + repo.get_album_by_mbid = AsyncMock(return_value=SAMPLE_ALBUM_RESP) + svc = _make_image_service(repo=repo) + with caplog.at_level("DEBUG"): + await svc.fetch_and_cache_album_images(TEST_ALBUM_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.cache", CACHE_REQUIRED_FIELDS) + assert any("action=write" in m and "entity_type=album" in m for m in msgs) + + + + +LOOKUP_REQUIRED_FIELDS = ["entity", "lookup_type", "found", "elapsed_ms"] + + +class TestLookupLogContract: + @pytest.fixture(autouse=True) + def _reset_resilience(self): + from repositories.audiodb_repository import _audiodb_circuit_breaker + _audiodb_circuit_breaker.reset() + yield + _audiodb_circuit_breaker.reset() + + @pytest.fixture(autouse=True) + def _stub_retry_sleep(self): + with patch("infrastructure.resilience.retry.asyncio.sleep", new=AsyncMock()): + yield + + def _make_repo(self): + from repositories.audiodb_repository import AudioDBRepository + client = AsyncMock(spec=httpx.AsyncClient) + prefs = MagicMock() + settings = MagicMock() + settings.audiodb_enabled = True + settings.audiodb_api_key = "test_key" + prefs.get_advanced_settings.return_value = settings + return AudioDBRepository( + http_client=client, + preferences_service=prefs, + api_key="test_key", + ) + + def _mock_response(self, status_code=200, json_data=None): + resp = MagicMock(spec=httpx.Response) + resp.status_code = status_code + resp.content = msgspec.json.encode(json_data or {}) + return resp + + @pytest.mark.asyncio + async def test_artist_mbid_found_logs_lookup(self, caplog): + repo = self._make_repo() + data = {"artists": [{ + "idArtist": "111239", "strArtist": "Coldplay", + "strMusicBrainzID": TEST_MBID, + "strArtistThumb": "https://example.com/thumb.jpg", + }]} + repo._client.get = AsyncMock(return_value=self._mock_response(200, data)) + with caplog.at_level("DEBUG"): + await repo.get_artist_by_mbid(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.lookup", LOOKUP_REQUIRED_FIELDS) + assert any("found=true" in m and "entity=artist" in m for m in msgs) + + @pytest.mark.asyncio + async def test_artist_mbid_not_found_logs_lookup(self, caplog): + repo = self._make_repo() + repo._client.get = AsyncMock(return_value=self._mock_response(200, {"artists": None})) + with caplog.at_level("DEBUG"): + await repo.get_artist_by_mbid(TEST_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.lookup", LOOKUP_REQUIRED_FIELDS) + assert any("found=false" in m for m in msgs) + + @pytest.mark.asyncio + async def test_album_mbid_found_logs_lookup(self, caplog): + repo = self._make_repo() + data = {"album": [{ + "idAlbum": "2115888", "strAlbum": "Parachutes", + "strMusicBrainzID": TEST_ALBUM_MBID, + "strAlbumThumb": "https://example.com/thumb.jpg", + }]} + repo._client.get = AsyncMock(return_value=self._mock_response(200, data)) + with caplog.at_level("DEBUG"): + await repo.get_album_by_mbid(TEST_ALBUM_MBID) + + msgs = assert_log_fields(caplog.records, "audiodb.lookup", LOOKUP_REQUIRED_FIELDS) + assert any("found=true" in m and "entity=album" in m for m in msgs) + + @pytest.mark.asyncio + async def test_name_search_logs_lookup(self, caplog): + repo = self._make_repo() + data = {"artists": [{ + "idArtist": "111239", "strArtist": "Coldplay", + "strMusicBrainzID": TEST_MBID, + "strArtistThumb": "https://example.com/thumb.jpg", + }]} + repo._client.get = AsyncMock(return_value=self._mock_response(200, data)) + with caplog.at_level("DEBUG"): + await repo.search_artist_by_name("Coldplay") + + msgs = assert_log_fields(caplog.records, "audiodb.lookup", LOOKUP_REQUIRED_FIELDS) + assert any("lookup_type=name" in m for m in msgs) + + + + +class TestPrewarmLogContract: + def _make_status_service(self): + status = MagicMock() + status.update_phase = AsyncMock() + status.update_progress = AsyncMock() + status.persist_progress = AsyncMock() + status.is_cancelled.return_value = False + return status + + def _make_precache_service(self, audiodb_svc=None, prefs=None): + from services.library_precache_service import LibraryPrecacheService + + if audiodb_svc is None: + audiodb_svc = AsyncMock() + audiodb_svc.get_cached_artist_images = AsyncMock(return_value=None) + audiodb_svc.get_cached_album_images = AsyncMock(return_value=None) + audiodb_svc.fetch_and_cache_artist_images = AsyncMock(return_value=None) + audiodb_svc.fetch_and_cache_album_images = AsyncMock(return_value=None) + if prefs is None: + settings = MagicMock() + settings.audiodb_enabled = True + settings.audiodb_name_search_fallback = False + prefs = MagicMock() + prefs.get_advanced_settings.return_value = settings + return LibraryPrecacheService( + lidarr_repo=AsyncMock(), + cover_repo=AsyncMock(), + preferences_service=prefs, + sync_state_store=AsyncMock(), + genre_index=AsyncMock(), + library_db=AsyncMock(), + audiodb_image_service=audiodb_svc, + ) + + @pytest.mark.asyncio + async def test_prewarm_progress_logs_fire(self, caplog): + svc = AsyncMock() + images = AudioDBArtistImages(thumb_url="https://x.com/t.jpg", is_negative=False) + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.get_cached_album_images = AsyncMock(return_value=None) + svc.fetch_and_cache_artist_images = AsyncMock(return_value=images) + svc.fetch_and_cache_album_images = AsyncMock(return_value=None) + + precache = self._make_precache_service(audiodb_svc=svc) + status = self._make_status_service() + + artists = [{"mbid": TEST_MBID, "name": f"Artist{i}"} for i in range(1)] + with caplog.at_level("DEBUG"): + with patch.object(precache, '_download_audiodb_bytes', new_callable=AsyncMock, return_value=True): + await precache._precache_audiodb_data(artists, [], status) + + complete_logs = [ + r.message for r in caplog.records + if r.message.startswith("audiodb.prewarm") and "action=complete" in r.message + ] + assert len(complete_logs) >= 1 + for msg in complete_logs: + assert "processed=" in msg + assert "total=" in msg + + @pytest.mark.asyncio + async def test_prewarm_calls_update_phase_audiodb(self): + svc = AsyncMock() + svc.get_cached_artist_images = AsyncMock(return_value=None) + svc.get_cached_album_images = AsyncMock(return_value=None) + svc.fetch_and_cache_artist_images = AsyncMock(return_value=None) + + precache = self._make_precache_service(audiodb_svc=svc) + status = self._make_status_service() + + artists = [{"mbid": TEST_MBID, "name": "Coldplay"}] + await precache._precache_audiodb_data(artists, [], status) + + phase_calls = [ + c for c in status.update_phase.call_args_list + if c.args[0] == "audiodb_prewarm" + ] + assert len(phase_calls) >= 1, "Expected update_phase('audiodb_prewarm', ...) call" + + + + +class TestCacheStatsAudioDBWiring: + @pytest.mark.asyncio + async def test_get_stats_includes_audiodb_counts(self): + from services.cache_service import CacheService + + disk_cache = MagicMock() + disk_cache.get_stats.return_value = { + "total_count": 100, + "album_count": 60, + "artist_count": 40, + "audiodb_artist_count": 15, + "audiodb_album_count": 25, + } + + mem_cache = MagicMock() + mem_cache.size.return_value = 10 + mem_cache.estimate_memory_bytes.return_value = 2048 + + library_db = AsyncMock() + library_db.get_stats = AsyncMock(return_value={ + "artist_count": 5, + "album_count": 8, + "db_size_bytes": 4096, + }) + + svc = CacheService( + cache=mem_cache, + library_db=library_db, + disk_cache=disk_cache, + ) + svc._stats_cache_ttl = 0 + + with patch("services.cache_service.CACHE_DIR") as mock_dir: + mock_dir.exists.return_value = False + stats = await svc.get_stats() + + assert stats.disk_audiodb_artist_count == 15 + assert stats.disk_audiodb_album_count == 25 diff --git a/backend/tests/test_precache_phases.py b/backend/tests/test_precache_phases.py new file mode 100644 index 0000000..fdc9d90 --- /dev/null +++ b/backend/tests/test_precache_phases.py @@ -0,0 +1,106 @@ +"""Tests for precache phase classes — construction and basic behavior.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from services.precache.artist_phase import ArtistPhase +from services.precache.album_phase import AlbumPhase +from services.precache.audiodb_phase import AudioDBPhase +from services.precache.orchestrator import LibraryPrecacheService + + +class TestPhaseConstruction: + def test_artist_phase_constructs(self): + phase = ArtistPhase( + lidarr_repo=AsyncMock(), + cover_repo=AsyncMock(), + preferences_service=MagicMock(), + genre_index=AsyncMock(), + sync_state_store=AsyncMock(), + ) + assert phase is not None + + def test_album_phase_constructs(self): + phase = AlbumPhase( + cover_repo=AsyncMock(), + preferences_service=MagicMock(), + sync_state_store=AsyncMock(), + ) + assert phase is not None + + def test_audiodb_phase_constructs(self): + phase = AudioDBPhase( + cover_repo=AsyncMock(), + preferences_service=MagicMock(), + audiodb_image_service=AsyncMock(), + ) + assert phase is not None + + def test_orchestrator_constructs_and_creates_phases(self): + svc = LibraryPrecacheService( + lidarr_repo=AsyncMock(), + cover_repo=AsyncMock(), + preferences_service=MagicMock(), + sync_state_store=AsyncMock(), + genre_index=AsyncMock(), + library_db=AsyncMock(), + audiodb_image_service=AsyncMock(), + ) + assert isinstance(svc._artist_phase, ArtistPhase) + assert isinstance(svc._album_phase, AlbumPhase) + assert isinstance(svc._audiodb_phase, AudioDBPhase) + + +class TestOrchestratorDelegation: + def test_sort_by_cover_priority_delegates(self, tmp_path): + cover_repo = MagicMock() + cover_repo.cache_dir = tmp_path + svc = LibraryPrecacheService( + lidarr_repo=AsyncMock(), + cover_repo=cover_repo, + preferences_service=MagicMock(), + sync_state_store=AsyncMock(), + genre_index=AsyncMock(), + library_db=AsyncMock(), + ) + items = [{"mbid": "a", "name": "A"}, {"mbid": "b", "name": "B"}] + result = svc._sort_by_cover_priority(items, "artist") + assert len(result) == 2 + + @pytest.mark.asyncio + async def test_check_cache_needs_delegates(self): + audiodb_svc = AsyncMock() + audiodb_svc.get_cached_artist_images = AsyncMock(return_value=None) + audiodb_svc.get_cached_album_images = AsyncMock(return_value=None) + + svc = LibraryPrecacheService( + lidarr_repo=AsyncMock(), + cover_repo=AsyncMock(), + preferences_service=MagicMock(), + sync_state_store=AsyncMock(), + genre_index=AsyncMock(), + library_db=AsyncMock(), + audiodb_image_service=audiodb_svc, + ) + + artists = [{"mbid": "cc197bad-dc9c-440d-a5b5-d52ba2e14234", "name": "Test"}] + needed_artists, needed_albums = await svc._check_audiodb_cache_needs(artists, []) + assert len(needed_artists) == 1 + assert len(needed_albums) == 0 + + +class TestShimIdentity: + def test_shim_reexports_same_class(self): + from services.library_precache_service import LibraryPrecacheService as ShimClass + from services.precache.orchestrator import LibraryPrecacheService as RealClass + assert ShimClass is RealClass + + def test_home_shim_reexports_same_class(self): + from services.home_service import HomeService as ShimClass + from services.home.facade import HomeService as RealClass + assert ShimClass is RealClass + + def test_charts_shim_reexports_same_class(self): + from services.home_charts_service import HomeChartsService as ShimClass + from services.home.charts_service import HomeChartsService as RealClass + assert ShimClass is RealClass diff --git a/backend/tests/test_rate_limiter_middleware.py b/backend/tests/test_rate_limiter_middleware.py new file mode 100644 index 0000000..35311a0 --- /dev/null +++ b/backend/tests/test_rate_limiter_middleware.py @@ -0,0 +1,128 @@ +"""Tests for TokenBucketRateLimiter and RateLimitMiddleware.""" + +import pytest +from fastapi import FastAPI +from fastapi.responses import PlainTextResponse +import httpx + +from infrastructure.resilience.rate_limiter import TokenBucketRateLimiter +from middleware import RateLimitMiddleware + + +# TokenBucketRateLimiter unit tests + + +@pytest.mark.asyncio +async def test_remaining_full_at_start(): + limiter = TokenBucketRateLimiter(rate=10.0, capacity=20) + assert limiter.remaining == 20 + + +@pytest.mark.asyncio +async def test_remaining_decreases_after_acquire(): + limiter = TokenBucketRateLimiter(rate=10.0, capacity=20) + await limiter.try_acquire() + assert limiter.remaining == 19 + + +@pytest.mark.asyncio +async def test_retry_after_zero_when_tokens_available(): + limiter = TokenBucketRateLimiter(rate=10.0, capacity=20) + assert limiter.retry_after() == 0.0 + + +@pytest.mark.asyncio +async def test_retry_after_positive_when_exhausted(): + limiter = TokenBucketRateLimiter(rate=1.0, capacity=1) + await limiter.try_acquire() + assert limiter.retry_after() > 0 + + +# Middleware helpers + + +def _build_app( + default_rate: float = 100.0, + default_capacity: int = 200, + overrides: dict | None = None, +) -> FastAPI: + app = FastAPI() + + @app.get("/api/v1/test") + async def api_test(): + return PlainTextResponse("ok") + + @app.get("/health") + async def health(): + return PlainTextResponse("healthy") + + @app.get("/api/v1/special") + async def api_special(): + return PlainTextResponse("special") + + app.add_middleware( + RateLimitMiddleware, + default_rate=default_rate, + default_capacity=default_capacity, + overrides=overrides, + ) + return app + + +# RateLimitMiddleware integration tests + + +@pytest.mark.asyncio +async def test_middleware_allows_request(): + app = _build_app() + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/api/v1/test") + + assert resp.status_code == 200 + assert "X-RateLimit-Limit" in resp.headers + assert "X-RateLimit-Remaining" in resp.headers + + +@pytest.mark.asyncio +async def test_middleware_returns_429_when_exhausted(): + app = _build_app(default_rate=1.0, default_capacity=1) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + await client.get("/api/v1/test") # consumes the single token + resp = await client.get("/api/v1/test") + + assert resp.status_code == 429 + assert "Retry-After" in resp.headers + + +@pytest.mark.asyncio +async def test_middleware_skips_non_api_paths(): + app = _build_app(default_rate=1.0, default_capacity=1) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + # Exhaust the limiter via an API path + await client.get("/api/v1/test") + await client.get("/api/v1/test") + # /health should still work — not rate-limited + resp = await client.get("/health") + + assert resp.status_code == 200 + assert "X-RateLimit-Limit" not in resp.headers + + +@pytest.mark.asyncio +async def test_middleware_per_route_override(): + overrides = {"/api/v1/special": (100.0, 500)} + app = _build_app(default_rate=1.0, default_capacity=1, overrides=overrides) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + # Default limiter has capacity=1, override has capacity=500 + await client.get("/api/v1/test") # consumes default token + resp_default = await client.get("/api/v1/test") # should be 429 + + resp_override = await client.get("/api/v1/special") # uses override, plenty of tokens + + assert resp_default.status_code == 429 + assert resp_override.status_code == 200 + assert resp_override.headers["X-RateLimit-Limit"] == "500" diff --git a/backend/tests/test_task_registry.py b/backend/tests/test_task_registry.py new file mode 100644 index 0000000..d6c5afd --- /dev/null +++ b/backend/tests/test_task_registry.py @@ -0,0 +1,124 @@ +import asyncio +import pytest +from core.task_registry import TaskRegistry + + +@pytest.fixture(autouse=True) +def clean_registry(): + TaskRegistry.get_instance().reset() + yield + TaskRegistry.get_instance().reset() + + +def test_singleton(): + a = TaskRegistry.get_instance() + b = TaskRegistry.get_instance() + assert a is b + + +@pytest.mark.asyncio +async def test_register_and_get_all(): + registry = TaskRegistry.get_instance() + tasks = [asyncio.create_task(asyncio.sleep(10)) for _ in range(3)] + for i, t in enumerate(tasks): + registry.register(f"task-{i}", t) + assert len(registry.get_all()) == 3 + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_duplicate_running_task_raises(): + registry = TaskRegistry.get_instance() + t = asyncio.create_task(asyncio.sleep(10)) + registry.register("dup", t) + with pytest.raises(RuntimeError, match="already running"): + registry.register("dup", asyncio.create_task(asyncio.sleep(10))) + t.cancel() + await asyncio.gather(t, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_duplicate_done_task_replaces(): + registry = TaskRegistry.get_instance() + t1 = asyncio.create_task(asyncio.sleep(0)) + registry.register("done-test", t1) + await t1 + await asyncio.sleep(0.05) + t2 = asyncio.create_task(asyncio.sleep(10)) + registry.register("done-test", t2) + assert registry.get_all()["done-test"] is t2 + t2.cancel() + await asyncio.gather(t2, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_unregister(): + registry = TaskRegistry.get_instance() + t = asyncio.create_task(asyncio.sleep(10)) + registry.register("unreg", t) + registry.unregister("unreg") + assert len(registry.get_all()) == 0 + t.cancel() + await asyncio.gather(t, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_auto_unregister_on_completion(): + registry = TaskRegistry.get_instance() + t = asyncio.create_task(asyncio.sleep(0)) + registry.register("auto-unreg", t) + await t + await asyncio.sleep(0.05) + assert "auto-unreg" not in registry.get_all() + + +@pytest.mark.asyncio +async def test_cancel_all_cancels_tasks(): + registry = TaskRegistry.get_instance() + tasks = [asyncio.create_task(asyncio.sleep(100)) for _ in range(5)] + for i, t in enumerate(tasks): + registry.register(f"cancel-{i}", t) + await registry.cancel_all(grace_period=2.0) + assert all(t.done() for t in tasks) + + +@pytest.mark.asyncio +async def test_cancel_all_with_grace_period(): + registry = TaskRegistry.get_instance() + + async def slow_cleanup(): + try: + await asyncio.sleep(100) + except asyncio.CancelledError: + await asyncio.sleep(5) + + t = asyncio.create_task(slow_cleanup()) + registry.register("slow", t) + await registry.cancel_all(grace_period=0.5) + assert len(registry.get_all()) == 0 + + +@pytest.mark.asyncio +async def test_is_running(): + registry = TaskRegistry.get_instance() + t = asyncio.create_task(asyncio.sleep(0)) + registry.register("running-check", t) + assert registry.is_running("running-check") is True + await t + await asyncio.sleep(0.05) + assert registry.is_running("running-check") is False + + +@pytest.mark.asyncio +async def test_reset(): + registry = TaskRegistry.get_instance() + tasks = [asyncio.create_task(asyncio.sleep(10)) for _ in range(3)] + for i, t in enumerate(tasks): + registry.register(f"reset-{i}", t) + registry.reset() + assert len(registry.get_all()) == 0 + for t in tasks: + t.cancel() + await asyncio.gather(*tasks, return_exceptions=True) diff --git a/backend/tests/test_url_validation.py b/backend/tests/test_url_validation.py new file mode 100644 index 0000000..8ce5df7 --- /dev/null +++ b/backend/tests/test_url_validation.py @@ -0,0 +1,56 @@ +"""Tests for validate_service_url in infrastructure.validators.""" + +import pytest + +from core.exceptions import ValidationError +from infrastructure.validators import validate_service_url + + +def test_valid_http_url(): + result = validate_service_url("http://192.168.1.100:8096") + assert result == "http://192.168.1.100:8096" + + +def test_valid_https_url(): + result = validate_service_url("https://jellyfin.local:8096") + assert result == "https://jellyfin.local:8096" + + +def test_valid_localhost_url(): + result = validate_service_url("http://localhost:8096") + assert result == "http://localhost:8096" + + +def test_blocks_file_scheme(): + with pytest.raises(ValidationError): + validate_service_url("file:///etc/passwd") + + +def test_blocks_gopher_scheme(): + with pytest.raises(ValidationError): + validate_service_url("gopher://evil.com") + + +def test_blocks_data_scheme(): + with pytest.raises(ValidationError): + validate_service_url("data:text/html,") + + +def test_blocks_ftp_scheme(): + with pytest.raises(ValidationError): + validate_service_url("ftp://evil.com") + + +def test_blocks_empty_string(): + with pytest.raises(ValidationError): + validate_service_url("") + + +def test_blocks_no_hostname(): + with pytest.raises(ValidationError): + validate_service_url("http://") + + +def test_custom_label_in_error(): + with pytest.raises(ValidationError, match="Lidarr URL"): + validate_service_url("", label="Lidarr URL") diff --git a/config/config.example.json b/config/config.example.json new file mode 100644 index 0000000..8246b08 --- /dev/null +++ b/config/config.example.json @@ -0,0 +1,17 @@ +{ + "lidarr_url": "", + "lidarr_api_key": "", + "jellyfin_url": "", + "contact_email": "", + "quality_profile_id": null, + "metadata_profile_id": null, + "root_folder_path": "", + "port": 4243, + "audiodb_api_key": "", + "audiodb_premium": false, + "user_preferences": { + "primary_types": ["album", "ep", "single"], + "secondary_types": ["studio"], + "release_statuses": ["official"] + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..a2df07f --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,25 @@ +services: + musicseerr: + image: ghcr.io/habirabbu/musicseerr:latest + container_name: musicseerr + environment: + - PUID=1000 # User ID — run `id` on your host to find yours + - PGID=1000 # Group ID — run `id` on your host to find yours + - PORT=8688 # Internal port (must match the right side of "ports" below) + - TZ=Etc/UTC # Timezone — e.g. America/New_York, Europe/London + ports: + - "8688:8688" # : — change the left side to remap + volumes: + - ./config:/app/config # Persistent app configuration + - ./cache:/app/cache # Cover art & metadata cache + # Optional: mount your music library for local file playback + # The left side is the host path to your music (same as Lidarr's root folder). + # The right side (/music) must match the "Music Directory Path" in Settings > Local Files. + # - /path/to/music:/music:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8688/health"] + interval: 30s + timeout: 10s + start_period: 15s + retries: 3 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..b418b54 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e + +PUID=${PUID:-1000} +PGID=${PGID:-1000} + +groupmod -o -g "$PGID" musicseerr 2>/dev/null || true +usermod -o -u "$PUID" musicseerr 2>/dev/null || true + +TARGET_UID=$(id -u musicseerr) +TARGET_GID=$(id -g musicseerr) + +# Only chown directories whose top-level ownership differs from the target. +# Nested mismatches from manual edits require a rebuild or manual chown. +for dir in /app/cache /app/config; do + if [ -d "$dir" ]; then + CURRENT=$(stat -c '%u:%g' "$dir") + if [ "$CURRENT" != "$TARGET_UID:$TARGET_GID" ]; then + chown -R musicseerr:musicseerr "$dir" + fi + fi +done + +exec gosu musicseerr:musicseerr "$@" diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..43d3a6c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +__screenshots__/ diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3f7802c --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,15 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..75842c4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,38 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project in the current directory +npx sv create + +# create a new project in my-app +npx sv create my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..9888647 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,42 @@ +import prettier from 'eslint-config-prettier'; +import { fileURLToPath } from 'node:url'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + '@typescript-eslint/no-explicit-any': 'error' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9824f5d --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4955 @@ +{ + "name": "frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.1", + "dependencies": { + "lucide-svelte": "^0.575.0" + }, + "devDependencies": { + "@eslint/compat": "^1.4.0", + "@eslint/js": "^9.36.0", + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@tailwindcss/cli": "^4.1.14", + "@tailwindcss/postcss": "^4.1.14", + "@types/node": "^22", + "@vitest/browser": "^3.2.4", + "daisyui": "^5.3.1", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.0", + "playwright": "^1.55.1", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vitest": "^3.2.4", + "vitest-browser-svelte": "^1.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/compat": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.40 || 9" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-6.1.1.tgz", + "integrity": "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.50.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.2.tgz", + "integrity": "sha512-875hTUkEbz+MyJIxWbQjfMaekqdmEKUUfR7JyKcpfMRZqcGyrO9Gd+iS1D/Dx8LpE5FEtutWGOtlAh4ReSAiOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.18.tgz", + "integrity": "sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "enhanced-resolve": "^5.18.3", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.18" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.9.tgz", + "integrity": "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/browser": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-3.2.4.tgz", + "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.6.1", + "@vitest/mocker": "3.2.4", + "@vitest/utils": "3.2.4", + "magic-string": "^0.30.17", + "sirv": "^3.0.1", + "tinyrainbow": "^2.0.0", + "ws": "^8.18.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "3.2.4", + "webdriverio": "^7.0.0 || ^8.0.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/daisyui": { + "version": "5.5.18", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.18.tgz", + "integrity": "sha512-VVzjpOitMGB6DWIBeRSapbjdOevFqyzpk9u5Um6a4tyId3JFrU5pbtF0vgjXDth76mJZbueN/j9Ok03SPrh/og==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-svelte": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.14.0.tgz", + "integrity": "sha512-Isw0GvaMm0yHxAj71edAdGFh28ufYs+6rk2KlbbZphnqZAzrH3Se3t12IFh2H9+1F/jlDhBBL4oiOJmLqmYX0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.6.1", + "@jridgewell/sourcemap-codec": "^1.5.0", + "esutils": "^2.0.3", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", + "postcss": "^8.4.49", + "postcss-load-config": "^3.1.4", + "postcss-safe-parser": "^7.0.0", + "semver": "^7.6.3", + "svelte-eslint-parser": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": "^8.57.1 || ^9.0.0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/known-css-properties": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-svelte": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.575.0.tgz", + "integrity": "sha512-Tu15tJfbmRNPaU61yeNFf3jfRHs8ABA+NwTt7TWmwVbhlSA3H7sW65tX6RttcP7HGV4aHUlYhXixZOlntoFBdw==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss-safe-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz", + "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", + "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "5.49.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.2.tgz", + "integrity": "sha512-PYLwnngYzyhKzqDlGVlCH4z+NVI8mC0/bTv15vw25CcdOhxENsOHIbQ36oj5DIf3oBazM+STbCAvaskpxtBmWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.6.tgz", + "integrity": "sha512-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-eslint-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.4.1.tgz", + "integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.24.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + } + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-1.1.0.tgz", + "integrity": "sha512-o98mCzKkWBjvmaGzi69rvyBd1IJ7zFPGI0jcID9vI4F5DmdG//YxkIbeQ7TS27hAVR+MULnBZNja2DUiuUBZyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "^2.1.0 || ^3.0.0 || ^4.0.0-0", + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^2.1.0 || ^3.0.0 || ^4.0.0-0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5886fba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "tailwind": "tailwindcss -i ./src/app.css -o ./static/tailwind.css --watch", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test:unit": "vitest", + "test": "npm run test:unit -- --run" + }, + "devDependencies": { + "@eslint/compat": "^1.4.0", + "@eslint/js": "^9.36.0", + "@sveltejs/adapter-auto": "^6.1.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.43.2", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@tailwindcss/cli": "^4.1.14", + "@tailwindcss/postcss": "^4.1.14", + "@types/node": "^22", + "@vitest/browser": "^3.2.4", + "daisyui": "^5.3.1", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.0", + "playwright": "^1.55.1", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7", + "vitest": "^3.2.4", + "vitest-browser-svelte": "^1.1.0" + }, + "dependencies": { + "lucide-svelte": "^0.575.0" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..fe1e17e --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; \ No newline at end of file diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..fab925c --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,317 @@ +@import 'tailwindcss'; +@plugin "daisyui" { + themes: dark --default; +} +@plugin "daisyui/theme" { + name: 'dark'; + default: true; + --color-base-300: #1f271b; + --color-base-200: #161d12; + --color-base-100: #0d120a; + --color-primary: #aed5f2; + --color-primary-content: #071520; + --color-accent: #bbdb9b; + --color-accent-content: #0f1a0a; +} + +@theme { + --animate-shimmer: shimmer 2s ease-in-out infinite; + --animate-glow-pulse: glow-pulse 2.5s ease-in-out infinite; + --animate-float: float 3s ease-in-out infinite; + --animate-fade-in-up: fade-in-up 0.5s ease-out forwards; + --animate-note-float: note-float 6s ease-in-out infinite; + --animate-slide-in-left: slide-in-left 0.2s ease-out forwards; + --animate-slide-in-right: slide-in-right 0.2s ease-out forwards; + --animate-gradient-shift: gradient-shift 3s ease-in-out infinite; + --animate-equalizer-1: equalizer-1 0.8s ease-in-out infinite; + --animate-equalizer-2: equalizer-2 0.6s ease-in-out infinite; + --animate-equalizer-3: equalizer-3 0.7s ease-in-out infinite; +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} +@keyframes glow-pulse { + 0%, + 100% { + box-shadow: 0 0 8px rgba(174, 213, 242, 0.15); + } + 50% { + box-shadow: 0 0 20px rgba(174, 213, 242, 0.3); + } +} +@keyframes float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-8px); + } +} +@keyframes fade-in-up { + 0% { + opacity: 0; + transform: translateY(12px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} +@keyframes note-float { + 0% { + opacity: 0; + transform: translateY(0) rotate(0deg); + } + 15% { + opacity: 0.15; + } + 85% { + opacity: 0.08; + } + 100% { + opacity: 0; + transform: translateY(-60px) rotate(15deg); + } +} +@keyframes slide-in-left { + 0% { + opacity: 0; + transform: translate(-8px, -50%); + } + 100% { + opacity: 1; + transform: translate(0, -50%); + } +} +@keyframes slide-in-right { + 0% { + opacity: 0; + transform: translate(8px, -50%); + } + 100% { + opacity: 1; + transform: translate(0, -50%); + } +} +@keyframes gradient-shift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} +@keyframes equalizer-1 { + 0%, + 100% { + height: 4px; + } + 50% { + height: 14px; + } +} +@keyframes equalizer-2 { + 0%, + 100% { + height: 10px; + } + 50% { + height: 4px; + } +} +@keyframes equalizer-3 { + 0%, + 100% { + height: 6px; + } + 50% { + height: 16px; + } +} + +@keyframes discover-reveal { + 0% { + opacity: 0; + transform: translateY(16px) scale(0.98); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes hero-glow { + 0%, + 100% { + box-shadow: 0 0 0 1px rgb(var(--hero-glow-color) / 0.08), 0 2px 20px rgb(var(--hero-glow-color) / 0.04); + } + 50% { + box-shadow: 0 0 0 1px rgb(var(--hero-glow-color) / 0.18), 0 4px 30px rgb(var(--hero-glow-color) / 0.10); + } +} + +:root { + --brand-library: 34 197 94; + --brand-listenbrainz: 251 146 60; + --brand-jellyfin: 168 85 247; + --brand-localfiles: 20 184 166; + --brand-discover: 56 189 248; + --brand-navidrome: 99 102 241; + --brand-lastfm: 213 16 7; + --brand-hero: 161 161 170; + --color-youtube: #ff0000; + --color-youtube-hover: #cc0000; +} + +@utility btn-lastfm { + background-color: rgb(var(--brand-lastfm)); + color: white; + border-color: rgb(var(--brand-lastfm)); + &:hover { + background-color: rgb(var(--brand-lastfm) / 0.85); + border-color: rgb(var(--brand-lastfm) / 0.85); + } +} + +@utility scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +@utility grid-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + @media (min-width: 640px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + } + @media (min-width: 768px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + @media (min-width: 1024px) { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + @media (min-width: 1280px) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } +} + +@utility grid-cards-dense { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + @media (min-width: 640px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + @media (min-width: 768px) { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + @media (min-width: 1024px) { + grid-template-columns: repeat(8, minmax(0, 1fr)); + } + @media (min-width: 1280px) { + grid-template-columns: repeat(10, minmax(0, 1fr)); + } +} + +@utility grid-cards-overview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + @media (min-width: 640px) { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + @media (min-width: 768px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + @media (min-width: 1024px) { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@utility stagger-fade-in { + & > * { + opacity: 0; + animation: fade-in-up 0.5s ease-out forwards; + } + & > *:nth-child(1) { + animation-delay: 0ms; + } + & > *:nth-child(2) { + animation-delay: 100ms; + } + & > *:nth-child(3) { + animation-delay: 200ms; + } + & > *:nth-child(4) { + animation-delay: 300ms; + } + & > *:nth-child(5) { + animation-delay: 400ms; + } + & > *:nth-child(6) { + animation-delay: 500ms; + } +} + +@utility skeleton-shimmer { + position: relative; + overflow: hidden; + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.04) 40%, + rgba(255, 255, 255, 0.08) 50%, + rgba(255, 255, 255, 0.04) 60%, + transparent 100% + ); + background-size: 200% 100%; + animation: shimmer 2s ease-in-out infinite; + } +} + +@utility discover-section-enter { + & > * { + opacity: 0; + animation: discover-reveal 0.6s ease-out forwards; + } + & > *:nth-child(1) { animation-delay: 0ms; } + & > *:nth-child(2) { animation-delay: 120ms; } + & > *:nth-child(3) { animation-delay: 240ms; } + & > *:nth-child(4) { animation-delay: 360ms; } + & > *:nth-child(5) { animation-delay: 480ms; } + & > *:nth-child(6) { animation-delay: 600ms; } + & > *:nth-child(7) { animation-delay: 720ms; } + & > *:nth-child(8) { animation-delay: 840ms; } + & > *:nth-child(9) { animation-delay: 960ms; } + & > *:nth-child(10) { animation-delay: 1080ms; } + & > *:nth-child(11) { animation-delay: 1200ms; } + & > *:nth-child(12) { animation-delay: 1320ms; } +} + +@media (prefers-reduced-motion: reduce) { + .discover-section-enter > *, + .stagger-fade-in > * { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..1d8cccd --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,6 @@ +declare global { + namespace App { + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..c36dfd4 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/demo.spec.ts b/frontend/src/demo.spec.ts new file mode 100644 index 0000000..e07cbbd --- /dev/null +++ b/frontend/src/demo.spec.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('sum test', () => { + it('adds 1 + 2 to equal 3', () => { + expect(1 + 2).toBe(3); + }); +}); diff --git a/frontend/src/lib/actions/infiniteSentinel.ts b/frontend/src/lib/actions/infiniteSentinel.ts new file mode 100644 index 0000000..ed5ac46 --- /dev/null +++ b/frontend/src/lib/actions/infiniteSentinel.ts @@ -0,0 +1,41 @@ +type InfiniteSentinelOptions = { + enabled: boolean; + onIntersect: () => void; + rootMargin?: string; +}; + +export function infiniteSentinel(node: HTMLElement, options: InfiniteSentinelOptions) { + let observer: IntersectionObserver | null = null; + + function disconnect() { + observer?.disconnect(); + observer = null; + } + + function setup(nextOptions: InfiniteSentinelOptions) { + disconnect(); + if (!nextOptions.enabled) return; + + observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + nextOptions.onIntersect(); + } + }, + { rootMargin: nextOptions.rootMargin ?? '400px 0px' } + ); + + observer.observe(node); + } + + setup(options); + + return { + update(nextOptions: InfiniteSentinelOptions) { + setup(nextOptions); + }, + destroy() { + disconnect(); + } + }; +} diff --git a/frontend/src/lib/api/client.spec.ts b/frontend/src/lib/api/client.spec.ts new file mode 100644 index 0000000..34f7437 --- /dev/null +++ b/frontend/src/lib/api/client.spec.ts @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/utils/navigationAbort', () => ({ + pageFetch: vi.fn() +})); + +import { api, ApiError } from './client'; +import { pageFetch } from '$lib/utils/navigationAbort'; + +const mockPageFetch = vi.mocked(pageFetch); +const mockGlobalFetch = vi.fn(); +globalThis.fetch = mockGlobalFetch; + +function jsonResponse(data: unknown, status = 200): Response { + const body = JSON.stringify(data); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve(body), + json: () => Promise.resolve(data) + } as unknown as Response; +} + +function emptyResponse(status = 204): Response { + return { + ok: true, + status, + headers: new Headers({ 'content-length': '0' }), + text: () => Promise.resolve(''), + json: () => Promise.reject(new Error('no body')) + } as unknown as Response; +} + +function errorResponse(status: number, body?: unknown): Response { + const text = body ? JSON.stringify(body) : ''; + return { + ok: false, + status, + headers: new Headers(), + text: () => Promise.resolve(text), + json: () => (body ? Promise.resolve(body) : Promise.reject(new Error('no json'))) + } as unknown as Response; +} + +beforeEach(() => { + mockPageFetch.mockReset(); + mockGlobalFetch.mockReset(); +}); + +describe('api client', () => { + describe('api.get (navigation-aware)', () => { + it('calls pageFetch with GET and returns parsed JSON', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ name: 'test' })); + const result = await api.get<{ name: string }>('/api/v1/test'); + expect(mockPageFetch).toHaveBeenCalledWith('/api/v1/test', expect.objectContaining({ method: 'GET' })); + expect(result).toEqual({ name: 'test' }); + }); + + it('passes signal through', async () => { + const controller = new AbortController(); + mockPageFetch.mockResolvedValue(jsonResponse({ ok: true })); + await api.get('/api/v1/test', { signal: controller.signal }); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/test', + expect.objectContaining({ signal: controller.signal }) + ); + }); + + it('passes cache option through', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ ok: true })); + await api.get('/api/v1/test', { cache: 'no-cache' }); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/test', + expect.objectContaining({ cache: 'no-cache' }) + ); + }); + }); + + describe('api.global.get (no navigation abort)', () => { + it('calls globalThis.fetch instead of pageFetch', async () => { + mockGlobalFetch.mockResolvedValue(jsonResponse({ name: 'global' })); + const result = await api.global.get<{ name: string }>('/api/v1/global'); + expect(mockGlobalFetch).toHaveBeenCalledWith('/api/v1/global', expect.objectContaining({ method: 'GET' })); + expect(mockPageFetch).not.toHaveBeenCalled(); + expect(result).toEqual({ name: 'global' }); + }); + }); + + describe('api.post', () => { + it('sends JSON body with Content-Type header', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ id: 1 }, 201)); + const result = await api.post<{ id: number }>('/api/v1/items', { name: 'new' }); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/items', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'new' }) + }) + ); + const sentHeaders = new Headers(mockPageFetch.mock.calls[0]![1]!.headers as HeadersInit); + expect(sentHeaders.get('Content-Type')).toBe('application/json'); + expect(result).toEqual({ id: 1 }); + }); + + it('sends no body when body is undefined', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ ok: true })); + await api.post('/api/v1/trigger'); + const call = mockPageFetch.mock.calls[0]; + expect(call[1]).not.toHaveProperty('body'); + }); + + it('preserves caller-supplied Headers instance when adding Content-Type', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ ok: true })); + const headers = new Headers({ Authorization: 'Bearer token123' }); + await api.post('/api/v1/data', { key: 'val' }, { headers }); + const call = mockPageFetch.mock.calls[0]!; + const sentHeaders = new Headers(call[1]!.headers as HeadersInit); + expect(sentHeaders.get('Authorization')).toBe('Bearer token123'); + expect(sentHeaders.get('Content-Type')).toBe('application/json'); + }); + }); + + describe('api.put', () => { + it('sends PUT with JSON body', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ updated: true })); + await api.put('/api/v1/items/1', { name: 'updated' }); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/items/1', + expect.objectContaining({ method: 'PUT' }) + ); + }); + }); + + describe('api.patch', () => { + it('sends PATCH with JSON body', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ patched: true })); + await api.patch('/api/v1/items/1', { name: 'patched' }); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/items/1', + expect.objectContaining({ method: 'PATCH' }) + ); + }); + }); + + describe('api.delete', () => { + it('sends DELETE and handles 204', async () => { + mockPageFetch.mockResolvedValue(emptyResponse(204)); + await api.delete('/api/v1/items/1'); + expect(mockPageFetch).toHaveBeenCalledWith( + '/api/v1/items/1', + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('returns typed JSON for 200 DELETE responses', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ success: true, artist_removed: true })); + const data = await api.delete<{ success: boolean; artist_removed: boolean }>('/api/v1/items/1'); + expect(data).toEqual({ success: true, artist_removed: true }); + }); + }); + + describe('api.head', () => { + it('returns raw Response without parsing', async () => { + const rawRes = jsonResponse({}, 200); + mockPageFetch.mockResolvedValue(rawRes); + const result = await api.head('/api/v1/stream/123'); + expect(result).toBe(rawRes); + }); + }); + + describe('api.global.head', () => { + it('uses global fetch for HEAD', async () => { + const rawRes = jsonResponse({}, 200); + mockGlobalFetch.mockResolvedValue(rawRes); + const result = await api.global.head('/api/v1/stream/123'); + expect(result).toBe(rawRes); + expect(mockPageFetch).not.toHaveBeenCalled(); + }); + }); + + describe('api.upload', () => { + it('sends FormData without Content-Type header', async () => { + mockPageFetch.mockResolvedValue(jsonResponse({ url: '/cover.jpg' })); + const formData = new FormData(); + formData.append('file', new Blob(['test']), 'test.jpg'); + const result = await api.upload<{ url: string }>('/api/v1/upload', formData); + const call = mockPageFetch.mock.calls[0]!; + expect(call[1]!.body).toBe(formData); + expect(call[1]!.headers).toBeUndefined(); + expect(result).toEqual({ url: '/cover.jpg' }); + }); + }); + + describe('handleResponse', () => { + it('returns undefined for 204 responses', async () => { + mockPageFetch.mockResolvedValue(emptyResponse(204)); + const result = await api.get('/api/v1/empty'); + expect(result).toBeUndefined(); + }); + + it('returns undefined for content-length: 0', async () => { + mockPageFetch.mockResolvedValue(emptyResponse(200)); + const result = await api.get('/api/v1/empty'); + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty body text', async () => { + const res = { + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(' ') + } as unknown as Response; + mockPageFetch.mockResolvedValue(res); + const result = await api.get('/api/v1/empty'); + expect(result).toBeUndefined(); + }); + + it('throws ApiError with backend error envelope', async () => { + mockPageFetch.mockResolvedValue( + errorResponse(422, { + error: { code: 'VALIDATION', message: 'Name is required', details: { field: 'name' } } + }) + ); + try { + await api.get('/api/v1/test'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + const err = e as ApiError; + expect(err.status).toBe(422); + expect(err.message).toBe('Name is required'); + expect(err.code).toBe('VALIDATION'); + expect(err.details).toEqual({ field: 'name' }); + } + }); + + it('throws ApiError with detail field (FastAPI style)', async () => { + mockPageFetch.mockResolvedValue(errorResponse(400, { detail: 'Bad request' })); + try { + await api.get('/api/v1/test'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).message).toBe('Bad request'); + } + }); + + it('throws ApiError with raw text when not JSON', async () => { + mockPageFetch.mockResolvedValue(errorResponse(500, undefined)); + const res = { + ok: false, + status: 500, + headers: new Headers(), + text: () => Promise.resolve('Internal Server Error') + } as unknown as Response; + mockPageFetch.mockResolvedValue(res); + try { + await api.get('/api/v1/test'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).message).toBe('Internal Server Error'); + } + }); + + it('throws ApiError with fallback message when text() fails', async () => { + const res = { + ok: false, + status: 503, + headers: new Headers(), + text: () => Promise.resolve('') + } as unknown as Response; + mockPageFetch.mockResolvedValue(res); + try { + await api.get('/api/v1/test'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).message).toBe('Request failed with status 503'); + } + }); + + it('throws ApiError on malformed JSON body', async () => { + const res = { + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve('{malformed}') + } as unknown as Response; + mockPageFetch.mockResolvedValue(res); + try { + await api.get('/api/v1/test'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).message).toBe('Failed to parse response JSON'); + } + }); + }); + + describe('ApiError', () => { + it('extends Error with correct name', () => { + const err = new ApiError(404, 'Not found', 'NOT_FOUND'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('ApiError'); + expect(err.status).toBe(404); + expect(err.message).toBe('Not found'); + expect(err.code).toBe('NOT_FOUND'); + }); + }); +}); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..091d285 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,108 @@ +import { pageFetch } from '$lib/utils/navigationAbort'; + +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly details: unknown; + + constructor(status: number, message: string, code = '', details: unknown = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.details = details; + } +} + +interface RequestOptions extends Omit { + signal?: AbortSignal; + raw?: boolean; + cache?: RequestCache; +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + const text = await res.text().catch(() => ''); + let message = text || `Request failed with status ${res.status}`; + let code = ''; + let details: unknown = null; + try { + const parsed = JSON.parse(text); + if (parsed?.error?.message) { + message = parsed.error.message; + code = parsed.error.code ?? ''; + details = parsed.error.details ?? null; + } else if (parsed?.detail) { + message = parsed.detail; + } + } catch { + // text wasn't JSON — use raw text as message + } + throw new ApiError(res.status, message, code, details); + } + + if (res.status === 204 || res.headers.get('content-length') === '0') { + return undefined as T; + } + + const text = await res.text().catch(() => ''); + if (text.trim() === '') { + return undefined as T; + } + + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError(res.status, 'Failed to parse response JSON'); + } +} + +type FetchFn = typeof fetch; + +interface ApiClient { + get(url: string, opts?: RequestOptions): Promise; + post(url: string, body?: unknown, opts?: RequestOptions): Promise; + put(url: string, body?: unknown, opts?: RequestOptions): Promise; + patch(url: string, body?: unknown, opts?: RequestOptions): Promise; + delete(url: string, opts?: RequestOptions): Promise; + head(url: string, opts?: RequestOptions): Promise; + upload(url: string, body: FormData, opts?: RequestOptions): Promise; +} + +function createClient(fetchFn: FetchFn): ApiClient { + async function request(method: string, url: string, body?: unknown, opts?: RequestOptions): Promise { + const { raw, ...fetchOpts } = opts ?? {}; + const init: RequestInit = { method, ...fetchOpts }; + + if (body !== undefined && body !== null) { + if (body instanceof FormData) { + init.body = body; + } else { + const headers = new Headers(init.headers as HeadersInit | undefined); + headers.set('Content-Type', 'application/json'); + init.headers = headers; + init.body = JSON.stringify(body); + } + } + + const res = await fetchFn(url, init); + + if (raw) return res as unknown as T; + return handleResponse(res); + } + + return { + get: (url: string, opts?: RequestOptions) => request('GET', url, undefined, opts), + post: (url: string, body?: unknown, opts?: RequestOptions) => request('POST', url, body, opts), + put: (url: string, body?: unknown, opts?: RequestOptions) => request('PUT', url, body, opts), + patch: (url: string, body?: unknown, opts?: RequestOptions) => request('PATCH', url, body, opts), + delete: (url: string, opts?: RequestOptions) => request('DELETE', url, undefined, opts), + head: (url: string, opts?: RequestOptions) => request('HEAD', url, undefined, { ...opts, raw: true }), + upload: (url: string, body: FormData, opts?: RequestOptions) => request('POST', url, body, opts), + }; +} + +const navClient = createClient(pageFetch); +const globalClient = createClient((...args) => globalThis.fetch(...args)); + +export const api = Object.assign(navClient, { global: globalClient }); diff --git a/frontend/src/lib/api/playlists.spec.ts b/frontend/src/lib/api/playlists.spec.ts new file mode 100644 index 0000000..3c6a1ae --- /dev/null +++ b/frontend/src/lib/api/playlists.spec.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/constants', () => ({ + API: { + playlists: { + list: () => '/api/v1/playlists', + create: () => '/api/v1/playlists', + detail: (id: string) => `/api/v1/playlists/${id}`, + update: (id: string) => `/api/v1/playlists/${id}`, + delete: (id: string) => `/api/v1/playlists/${id}`, + addTracks: (id: string) => `/api/v1/playlists/${id}/tracks`, + removeTrack: (id: string, trackId: string) => + `/api/v1/playlists/${id}/tracks/${trackId}`, + removeTracks: (id: string) => `/api/v1/playlists/${id}/tracks/batch-remove`, + updateTrack: (id: string, trackId: string) => + `/api/v1/playlists/${id}/tracks/${trackId}`, + reorderTrack: (id: string) => `/api/v1/playlists/${id}/tracks/reorder`, + uploadCover: (id: string) => `/api/v1/playlists/${id}/cover`, + deleteCover: (id: string) => `/api/v1/playlists/${id}/cover`, + checkTracks: () => '/api/v1/playlists/check-tracks', + resolveSources: (id: string) => `/api/v1/playlists/${id}/resolve-sources` + } + } +})); + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPut = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock('$lib/api/client', () => ({ + api: { + global: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + patch: (...args: unknown[]) => mockPatch(...args), + delete: (...args: unknown[]) => mockDelete(...args), + upload: (...args: unknown[]) => mockUpload(...args), + } + }, + ApiError: class ApiError extends Error { + status: number; + code: string; + details: unknown; + constructor(status: number, message: string, code = '', details: unknown = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.details = details; + } + } +})); + +import { + fetchPlaylists, + fetchPlaylist, + createPlaylist, + updatePlaylist, + deletePlaylist, + addTracksToPlaylist, + removeTrackFromPlaylist, + updatePlaylistTrack, + reorderPlaylistTrack, + uploadPlaylistCover, + deletePlaylistCover, + queueItemToTrackData +} from './playlists'; +import type { QueueItem } from '$lib/player/types'; + +beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + mockPut.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + mockUpload.mockReset(); +}); + +describe('playlists API client', () => { + describe('fetchPlaylists', () => { + it('calls api.global.get and unwraps .playlists', async () => { + const playlists = [{ id: 'p1', name: 'My Playlist' }]; + mockGet.mockResolvedValue({ playlists }); + + const result = await fetchPlaylists(); + + expect(mockGet).toHaveBeenCalledWith('/api/v1/playlists'); + expect(result).toEqual(playlists); + }); + + it('throws on API error', async () => { + mockGet.mockRejectedValue(new Error('Server error')); + await expect(fetchPlaylists()).rejects.toThrow('Server error'); + }); + }); + + describe('fetchPlaylist', () => { + it('calls api.global.get with correct ID', async () => { + const detail = { id: 'p1', name: 'Test', tracks: [] }; + mockGet.mockResolvedValue(detail); + + const result = await fetchPlaylist('p1'); + + expect(mockGet).toHaveBeenCalledWith('/api/v1/playlists/p1', { signal: undefined }); + expect(result).toEqual(detail); + }); + + it('forwards AbortSignal when provided', async () => { + const detail = { id: 'p1', name: 'Test', tracks: [] }; + const controller = new AbortController(); + mockGet.mockResolvedValue(detail); + + await fetchPlaylist('p1', { signal: controller.signal }); + + expect(mockGet).toHaveBeenCalledWith('/api/v1/playlists/p1', { + signal: controller.signal + }); + }); + }); + + describe('createPlaylist', () => { + it('sends POST with { name } body', async () => { + const detail = { id: 'p2', name: 'New', tracks: [] }; + mockPost.mockResolvedValue(detail); + + const result = await createPlaylist('New'); + + expect(mockPost).toHaveBeenCalledWith('/api/v1/playlists', { name: 'New' }); + expect(result).toEqual(detail); + }); + }); + + describe('updatePlaylist', () => { + it('sends PUT with data body', async () => { + const detail = { id: 'p1', name: 'Renamed' }; + mockPut.mockResolvedValue(detail); + + await updatePlaylist('p1', { name: 'Renamed' }); + + expect(mockPut).toHaveBeenCalledWith('/api/v1/playlists/p1', { name: 'Renamed' }); + }); + }); + + describe('deletePlaylist', () => { + it('calls api.global.delete on correct URL', async () => { + mockDelete.mockResolvedValue(undefined); + await deletePlaylist('p1'); + expect(mockDelete).toHaveBeenCalledWith('/api/v1/playlists/p1'); + }); + + it('throws on error', async () => { + mockDelete.mockRejectedValue(new Error('Not found')); + await expect(deletePlaylist('p1')).rejects.toThrow('Not found'); + }); + }); + + describe('addTracksToPlaylist', () => { + it('sends POST with { tracks, position } and unwraps .tracks', async () => { + const tracks = [{ track_name: 'Song', artist_name: 'Art', album_name: 'Alb', source_type: 'jellyfin' }]; + const responseTracks = [{ id: 't1', position: 0, track_name: 'Song' }]; + mockPost.mockResolvedValue({ tracks: responseTracks }); + + const result = await addTracksToPlaylist('p1', tracks, 5); + + const call = mockPost.mock.calls[0]; + expect(call[0]).toBe('/api/v1/playlists/p1/tracks'); + expect(call[1].tracks).toEqual(tracks); + expect(call[1].position).toBe(5); + expect(result).toEqual(responseTracks); + }); + + it('omits position when not provided', async () => { + const tracks = [{ track_name: 'Song', artist_name: 'Art', album_name: 'Alb', source_type: 'jellyfin' }]; + mockPost.mockResolvedValue({ tracks: [] }); + + await addTracksToPlaylist('p1', tracks); + + const body = mockPost.mock.calls[0][1]; + expect(body).not.toHaveProperty('position'); + }); + }); + + describe('removeTrackFromPlaylist', () => { + it('calls api.global.delete on correct URL', async () => { + mockDelete.mockResolvedValue(undefined); + await removeTrackFromPlaylist('p1', 't1'); + expect(mockDelete).toHaveBeenCalledWith('/api/v1/playlists/p1/tracks/t1'); + }); + }); + + describe('updatePlaylistTrack', () => { + it('sends PATCH with data body', async () => { + const track = { id: 't1', source_type: 'local' }; + mockPatch.mockResolvedValue(track); + + await updatePlaylistTrack('p1', 't1', { source_type: 'local' }); + + expect(mockPatch).toHaveBeenCalledWith('/api/v1/playlists/p1/tracks/t1', { source_type: 'local' }); + }); + }); + + describe('reorderPlaylistTrack', () => { + it('sends PATCH with { track_id, new_position }', async () => { + mockPatch.mockResolvedValue({ status: 'ok', message: 'Track reordered', actual_position: 3 }); + + const result = await reorderPlaylistTrack('p1', 't1', 3); + + expect(mockPatch).toHaveBeenCalledWith('/api/v1/playlists/p1/tracks/reorder', { + track_id: 't1', + new_position: 3 + }); + expect(result.actual_position).toBe(3); + }); + }); + + describe('uploadPlaylistCover', () => { + it('sends FormData via api.global.upload', async () => { + mockUpload.mockResolvedValue({ cover_url: '/covers/p1.jpg' }); + const file = new File(['img'], 'cover.jpg', { type: 'image/jpeg' }); + + const result = await uploadPlaylistCover('p1', file); + + const call = mockUpload.mock.calls[0]; + expect(call[0]).toBe('/api/v1/playlists/p1/cover'); + const formData = call[1] as FormData; + expect(formData.get('cover_image')).toBeTruthy(); + expect(result.cover_url).toBe('/covers/p1.jpg'); + }); + }); + + describe('deletePlaylistCover', () => { + it('calls api.global.delete on correct URL', async () => { + mockDelete.mockResolvedValue(undefined); + await deletePlaylistCover('p1'); + expect(mockDelete).toHaveBeenCalledWith('/api/v1/playlists/p1/cover'); + }); + }); + + describe('queueItemToTrackData', () => { + it('maps all fields correctly', () => { + const item: QueueItem = { + trackSourceId: 'vid-1', + trackName: 'My Track', + artistName: 'My Artist', + trackNumber: 3, + albumId: 'alb-1', + albumName: 'My Album', + coverUrl: '/cover.jpg', + sourceType: 'jellyfin', + artistId: 'art-1', + streamUrl: '/stream/vid-1', + format: 'aac', + availableSources: ['jellyfin', 'local'], + duration: 240 + }; + + const result = queueItemToTrackData(item); + + expect(result).toEqual({ + track_name: 'My Track', + artist_name: 'My Artist', + album_name: 'My Album', + album_id: 'alb-1', + artist_id: 'art-1', + track_source_id: 'vid-1', + cover_url: '/cover.jpg', + source_type: 'jellyfin', + available_sources: ['jellyfin', 'local'], + format: 'aac', + track_number: 3, + disc_number: null, + duration: 240 + }); + }); + + it('handles optional/null fields correctly', () => { + const item: QueueItem = { + trackSourceId: '', + trackName: 'Track', + artistName: 'Artist', + trackNumber: 1, + albumId: '', + albumName: 'Album', + coverUrl: null, + sourceType: 'local' + }; + + const result = queueItemToTrackData(item); + + expect(result.album_id).toBeNull(); + expect(result.artist_id).toBeNull(); + expect(result.track_source_id).toBeNull(); + expect(result.cover_url).toBeNull(); + expect(result.available_sources).toBeNull(); + expect(result.format).toBeNull(); + expect(result.track_number).toBe(1); + expect(result.duration).toBeNull(); + }); + + it('excludes streamUrl from output', () => { + const item: QueueItem = { + trackSourceId: 'vid-1', + trackName: 'Track', + artistName: 'Artist', + trackNumber: 1, + albumId: 'alb-1', + albumName: 'Album', + coverUrl: null, + sourceType: 'local', + streamUrl: '/stream/should-not-appear' + }; + + const result = queueItemToTrackData(item); + + expect(result).not.toHaveProperty('streamUrl'); + expect(result).not.toHaveProperty('stream_url'); + }); + }); +}); diff --git a/frontend/src/lib/api/playlists.ts b/frontend/src/lib/api/playlists.ts new file mode 100644 index 0000000..bee3456 --- /dev/null +++ b/frontend/src/lib/api/playlists.ts @@ -0,0 +1,174 @@ +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; +import type { QueueItem } from '$lib/player/types'; + +export interface PlaylistTrack { + id: string; + position: number; + track_name: string; + artist_name: string; + album_name: string; + album_id: string | null; + artist_id: string | null; + track_source_id: string | null; + cover_url: string | null; + source_type: string; + available_sources: string[] | null; + format: string | null; + track_number: number | null; + disc_number: number | null; + duration: number | null; + created_at: string; +} + +export interface PlaylistSummary { + id: string; + name: string; + track_count: number; + total_duration: number | null; + cover_urls: string[]; + custom_cover_url: string | null; + created_at: string; + updated_at: string; +} + +export interface PlaylistDetail extends PlaylistSummary { + tracks: PlaylistTrack[]; +} + +export interface TrackData { + track_name: string; + artist_name: string; + album_name: string; + album_id?: string | null; + artist_id?: string | null; + track_source_id?: string | null; + cover_url?: string | null; + source_type: string; + available_sources?: string[] | null; + format?: string | null; + track_number?: number | null; + disc_number?: number | null; + duration?: number | null; +} + +export function queueItemToTrackData(item: QueueItem): TrackData { + return { + track_name: item.trackName, + artist_name: item.artistName, + album_name: item.albumName, + album_id: item.albumId || null, + artist_id: item.artistId || null, + track_source_id: item.trackSourceId || null, + cover_url: item.coverUrl, + source_type: item.sourceType, + available_sources: item.availableSources ?? null, + format: item.format ?? null, + track_number: item.trackNumber ?? null, + disc_number: item.discNumber ?? null, + duration: item.duration ?? null + }; +} + +export async function fetchPlaylists(): Promise { + const data = await api.global.get<{ playlists: PlaylistSummary[] }>(API.playlists.list()); + return data.playlists; +} + +export async function fetchPlaylist( + id: string, + options?: { signal?: AbortSignal } +): Promise { + return api.global.get(API.playlists.detail(id), { signal: options?.signal }); +} + +export async function createPlaylist(name: string): Promise { + return api.global.post(API.playlists.create(), { name }); +} + +export async function updatePlaylist( + id: string, + data: { name?: string } +): Promise { + return api.global.put(API.playlists.update(id), data); +} + +export async function deletePlaylist(id: string): Promise { + await api.global.delete(API.playlists.delete(id)); +} + +export async function addTracksToPlaylist( + id: string, + tracks: TrackData[], + position?: number +): Promise { + const body: { tracks: TrackData[]; position?: number } = { tracks }; + if (position != null) body.position = position; + const data = await api.global.post<{ tracks: PlaylistTrack[] }>(API.playlists.addTracks(id), body); + return data.tracks; +} + +export async function removeTrackFromPlaylist( + id: string, + trackId: string +): Promise { + await api.global.delete(API.playlists.removeTrack(id, trackId)); +} + +export async function removeTracksFromPlaylist( + id: string, + trackIds: string[] +): Promise { + await api.global.post(API.playlists.removeTracks(id), { track_ids: trackIds }); +} + +export async function updatePlaylistTrack( + id: string, + trackId: string, + data: { source_type?: string; available_sources?: string[] } +): Promise { + return api.global.patch(API.playlists.updateTrack(id, trackId), data); +} + +export async function reorderPlaylistTrack( + id: string, + trackId: string, + newPosition: number +): Promise<{ actual_position: number }> { + return api.global.patch<{ actual_position: number }>( + API.playlists.reorderTrack(id), + { track_id: trackId, new_position: newPosition } + ); +} + +export async function uploadPlaylistCover( + id: string, + file: File +): Promise<{ cover_url: string }> { + const formData = new FormData(); + formData.append('cover_image', file); + return api.global.upload<{ cover_url: string }>(API.playlists.uploadCover(id), formData); +} + +export async function deletePlaylistCover(id: string): Promise { + await api.global.delete(API.playlists.deleteCover(id)); +} + +export async function checkTrackMembership( + tracks: { track_name: string; artist_name: string; album_name: string }[] +): Promise> { + const data = await api.global.post<{ membership: Record }>( + API.playlists.checkTracks(), + { tracks } + ); + return data.membership; +} + +export async function resolvePlaylistSources( + id: string +): Promise> { + const data = await api.global.post<{ sources: Record }>( + API.playlists.resolveSources(id) + ); + return data.sources; +} diff --git a/frontend/src/lib/assets/favicon.svg b/frontend/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/frontend/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/frontend/src/lib/colors.ts b/frontend/src/lib/colors.ts new file mode 100644 index 0000000..b4bafc6 --- /dev/null +++ b/frontend/src/lib/colors.ts @@ -0,0 +1,9 @@ +export const colors = { + + + + + primary: '#AED5F2', + secondary: '#1F271B', + accent: '#BBDB9B', +} as const; diff --git a/frontend/src/lib/components/AddToPlaylistModal.svelte b/frontend/src/lib/components/AddToPlaylistModal.svelte new file mode 100644 index 0000000..212287b --- /dev/null +++ b/frontend/src/lib/components/AddToPlaylistModal.svelte @@ -0,0 +1,281 @@ + + + + + + + + diff --git a/frontend/src/lib/components/AddToPlaylistModal.svelte.spec.ts b/frontend/src/lib/components/AddToPlaylistModal.svelte.spec.ts new file mode 100644 index 0000000..6d45346 --- /dev/null +++ b/frontend/src/lib/components/AddToPlaylistModal.svelte.spec.ts @@ -0,0 +1,256 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AddToPlaylistModal from './AddToPlaylistModal.svelte'; +import type { QueueItem } from '$lib/player/types'; + +const mockFetchPlaylists = vi.fn(); +const mockCreatePlaylist = vi.fn(); +const mockAddTracksToPlaylist = vi.fn(); +const mockCheckTrackMembership = vi.fn(); +const mockQueueItemToTrackData = vi.fn((item: QueueItem) => ({ + track_name: item.trackName, + artist_name: item.artistName, + album_name: item.albumName, + source_type: item.sourceType +})); + +vi.mock('$lib/api/playlists', () => ({ + fetchPlaylists: (...args: unknown[]) => mockFetchPlaylists(...args), + createPlaylist: (...args: unknown[]) => mockCreatePlaylist(...args), + addTracksToPlaylist: (...args: unknown[]) => mockAddTracksToPlaylist(...args), + checkTrackMembership: (...args: unknown[]) => mockCheckTrackMembership(...args), + queueItemToTrackData: (item: QueueItem) => mockQueueItemToTrackData(item) +})); + +function makeTrack(overrides: Partial = {}): QueueItem { + return { + trackSourceId: 'v1', + trackName: 'Test Track', + artistName: 'Test Artist', + trackNumber: 1, + albumId: 'a1', + albumName: 'Test Album', + coverUrl: null, + sourceType: 'local', + ...overrides + }; +} + +function makePlaylists() { + return [ + { + id: 'p1', + name: 'My Playlist', + track_count: 5, + total_duration: 600, + cover_urls: [], + custom_cover_url: null, + created_at: '2026-01-01', + updated_at: '2026-01-01' + }, + { + id: 'p2', + name: 'Another', + track_count: 3, + total_duration: 300, + cover_urls: [], + custom_cover_url: null, + created_at: '2026-01-02', + updated_at: '2026-01-02' + } + ]; +} + +type ModalRef = { open: (tracks: QueueItem[]) => void }; + +function renderModal() { + return render(AddToPlaylistModal, {} as Parameters>[1]); +} + +describe('AddToPlaylistModal.svelte', () => { + beforeEach(() => { + mockFetchPlaylists.mockReset(); + mockCreatePlaylist.mockReset(); + mockAddTracksToPlaylist.mockReset(); + mockCheckTrackMembership.mockReset(); + mockQueueItemToTrackData.mockClear(); + mockCheckTrackMembership.mockResolvedValue({}); + }); + + it('opening modal fetches playlists and renders list', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await expect.element(page.getByText('Another')).toBeVisible(); + expect(mockFetchPlaylists).toHaveBeenCalledOnce(); + }); + + it('shows loading skeletons while fetching', async () => { + let resolveFetch!: (value: unknown[]) => void; + mockFetchPlaylists.mockReturnValue( + new Promise((r) => { + resolveFetch = r; + }) + ); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + const skeletons = page.getByTestId('playlist-skeleton').all(); + expect((await skeletons).length).toBeGreaterThan(0); + + resolveFetch(makePlaylists()); + await expect.element(page.getByText('My Playlist')).toBeVisible(); + }); + + it('renders empty state when playlists list is empty', async () => { + mockFetchPlaylists.mockResolvedValue([]); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('No playlists yet')).toBeVisible(); + }); + + it('clicking add button calls addTracksToPlaylist with correct tracks', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockAddTracksToPlaylist.mockResolvedValue([]); + const track = makeTrack(); + const result = renderModal(); + (result.component as unknown as ModalRef).open([track]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + const addBtn = page.getByLabelText('Add to My Playlist'); + await addBtn.click(); + + await vi.waitFor(() => { + expect(mockAddTracksToPlaylist).toHaveBeenCalledOnce(); + expect(mockAddTracksToPlaylist.mock.calls[0][0]).toBe('p1'); + }); + }); + + it('after adding, button transitions from CirclePlus to Check', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockAddTracksToPlaylist.mockResolvedValue([]); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await page.getByLabelText('Add to My Playlist').click(); + + await expect.element(page.getByLabelText('Already in playlist').first()).toBeVisible(); + }); + + it('clicking add on same playlist twice is a no-op (addedSet guard)', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockAddTracksToPlaylist.mockResolvedValue([]); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await page.getByLabelText('Add to My Playlist').click(); + await expect.element(page.getByLabelText('Already in playlist').first()).toBeVisible(); + + expect(mockAddTracksToPlaylist).toHaveBeenCalledOnce(); + }); + + it('new playlist creation flow: creates, adds tracks, shows in list', async () => { + mockFetchPlaylists.mockResolvedValue([]); + mockCreatePlaylist.mockResolvedValue({ + id: 'p-new', + name: 'Fresh', + track_count: 0, + total_duration: null, + cover_urls: [], + custom_cover_url: null, + created_at: '2026-01-03', + updated_at: '2026-01-03', + tracks: [] + }); + mockAddTracksToPlaylist.mockResolvedValue([]); + + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('No playlists yet')).toBeVisible(); + + const input = page.getByPlaceholder('New playlist name...'); + await input.fill('Fresh'); + await page.getByLabelText('Create playlist').click(); + + expect(mockCreatePlaylist).toHaveBeenCalledWith('Fresh'); + await expect.element(page.getByText('Fresh')).toBeVisible(); + }); + + it('error during add shows error status and does not mark as added', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockAddTracksToPlaylist.mockRejectedValue(new Error('Network error')); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await page.getByLabelText('Add to My Playlist').click(); + + await expect.element(page.getByText("Couldn't add those tracks")).toBeVisible(); + + await expect + .element(page.getByLabelText('Add to My Playlist')) + .toBeVisible(); + }); + + it('shows tick for playlists where all tracks already exist', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockCheckTrackMembership.mockResolvedValue({ p1: [0] }); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + const tickBtn = page.getByLabelText('Already in playlist').first(); + await expect.element(tickBtn).toBeVisible(); + expect(tickBtn.element().hasAttribute('disabled')).toBe(true); + }); + + it('shows partial indicator when some tracks already exist', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockCheckTrackMembership.mockResolvedValue({ p1: [0] }); + const track1 = makeTrack({ trackName: 'Track 1' }); + const track2 = makeTrack({ trackName: 'Track 2', trackSourceId: 'v2' }); + const result = renderModal(); + (result.component as unknown as ModalRef).open([track1, track2]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + const partialBtn = page.getByLabelText('Add new tracks to My Playlist'); + await expect.element(partialBtn).toBeVisible(); + }); + + it('partial add only sends non-duplicate tracks', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockCheckTrackMembership.mockResolvedValue({ p1: [0] }); + mockAddTracksToPlaylist.mockResolvedValue([]); + const track1 = makeTrack({ trackName: 'Track 1' }); + const track2 = makeTrack({ trackName: 'Track 2', trackSourceId: 'v2' }); + const result = renderModal(); + (result.component as unknown as ModalRef).open([track1, track2]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await page.getByLabelText('Add new tracks to My Playlist').click(); + + await vi.waitFor(() => { + expect(mockAddTracksToPlaylist).toHaveBeenCalledOnce(); + const calledTracks = mockAddTracksToPlaylist.mock.calls[0][1]; + expect(calledTracks).toHaveLength(1); + expect(calledTracks[0].track_name).toBe('Track 2'); + }); + }); + + it('shows + for playlists with no overlap', async () => { + mockFetchPlaylists.mockResolvedValue(makePlaylists()); + mockCheckTrackMembership.mockResolvedValue({}); + const result = renderModal(); + (result.component as unknown as ModalRef).open([makeTrack()]); + + await expect.element(page.getByText('My Playlist')).toBeVisible(); + await expect.element(page.getByLabelText('Add to My Playlist')).toBeVisible(); + }); +}); diff --git a/frontend/src/lib/components/AlbumCard.svelte b/frontend/src/lib/components/AlbumCard.svelte new file mode 100644 index 0000000..014ab06 --- /dev/null +++ b/frontend/src/lib/components/AlbumCard.svelte @@ -0,0 +1,167 @@ + + + + diff --git a/frontend/src/lib/components/AlbumCard.svelte.spec.ts b/frontend/src/lib/components/AlbumCard.svelte.spec.ts new file mode 100644 index 0000000..cc1a14e --- /dev/null +++ b/frontend/src/lib/components/AlbumCard.svelte.spec.ts @@ -0,0 +1,97 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AlbumCard from './AlbumCard.svelte'; +import type { Album, EnrichmentSource } from '$lib/types'; + +const baseAlbum: Album = { + title: 'OK Computer', + artist: 'Radiohead', + year: 1997, + musicbrainz_id: 'b1392450-e666-3926-a536-22c65f834433', + in_library: false, + listen_count: 1200000, +}; + +function renderComponent(overrides: Partial<{ album: Album; enrichmentSource: EnrichmentSource }> = {}) { + return render(AlbumCard, { + props: { + album: overrides.album ?? baseAlbum, + enrichmentSource: overrides.enrichmentSource ?? 'none', + }, + } as Parameters>[1]); +} + +describe('AlbumCard.svelte', () => { + it('should display the album title', async () => { + renderComponent(); + await expect.element(page.getByText('OK Computer')).toBeInTheDocument(); + }); + + it('should display artist and year', async () => { + renderComponent(); + await expect.element(page.getByText(/1997/)).toBeInTheDocument(); + await expect.element(page.getByText(/Radiohead/)).toBeInTheDocument(); + }); + + it('should show Last.fm branded badge when source is lastfm', async () => { + renderComponent({ enrichmentSource: 'lastfm' }); + + const badge = page.getByTitle('Last.fm plays'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText(/Last\.fm/)).toBeInTheDocument(); + }); + + it('should show ListenBrainz branded badge when source is listenbrainz', async () => { + renderComponent({ enrichmentSource: 'listenbrainz' }); + + const badge = page.getByTitle('ListenBrainz plays'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText(/LB/)).toBeInTheDocument(); + }); + + it('should show generic badge when source is none', async () => { + renderComponent({ enrichmentSource: 'none' }); + + const badge = page.getByTitle('Plays'); + await expect.element(badge).toBeInTheDocument(); + + await expect.element(page.getByText(/Last\.fm/)).not.toBeInTheDocument(); + await expect.element(page.getByText(/\bLB\b/)).not.toBeInTheDocument(); + }); + + it('should not render listen count badge when listen_count is null', async () => { + renderComponent({ + album: { ...baseAlbum, listen_count: null }, + enrichmentSource: 'lastfm', + }); + + await expect + .element(page.getByTitle('Last.fm plays')) + .not.toBeInTheDocument(); + }); + + it('should render zero listen count as "0"', async () => { + renderComponent({ + album: { ...baseAlbum, listen_count: 0 }, + enrichmentSource: 'lastfm', + }); + + const badge = page.getByTitle('Last.fm plays'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText('Last.fm 0')).toBeInTheDocument(); + }); + + it('should display formatted count for large numbers', async () => { + renderComponent({ enrichmentSource: 'listenbrainz' }); + + await expect.element(page.getByText('LB 1.2M')).toBeInTheDocument(); + }); + + it('should use album-specific title for lastfm source', async () => { + renderComponent({ enrichmentSource: 'lastfm' }); + + const badge = page.getByTitle('Last.fm plays'); + await expect.element(badge).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/AlbumCardOverlay.svelte b/frontend/src/lib/components/AlbumCardOverlay.svelte new file mode 100644 index 0000000..3e6ea29 --- /dev/null +++ b/frontend/src/lib/components/AlbumCardOverlay.svelte @@ -0,0 +1,126 @@ + + +{#if hasPlaybackSource} + +
{ e.stopPropagation(); e.preventDefault(); }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); } }} + > +
+ +
+
+ +
+ {#if size === 'md'} + + {/if} + +
+{/if} diff --git a/frontend/src/lib/components/AlbumCardSkeleton.svelte b/frontend/src/lib/components/AlbumCardSkeleton.svelte new file mode 100644 index 0000000..a84985e --- /dev/null +++ b/frontend/src/lib/components/AlbumCardSkeleton.svelte @@ -0,0 +1,12 @@ + + +
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/AlbumGridSkeleton.svelte b/frontend/src/lib/components/AlbumGridSkeleton.svelte new file mode 100644 index 0000000..7db5735 --- /dev/null +++ b/frontend/src/lib/components/AlbumGridSkeleton.svelte @@ -0,0 +1,26 @@ + + +
+
+

{title}

+
+
+ +
+ {#each Array(count) as _} +
+
+
+
+
+
+
+
+
+
+ {/each} +
+
diff --git a/frontend/src/lib/components/AlbumImage.svelte b/frontend/src/lib/components/AlbumImage.svelte new file mode 100644 index 0000000..0c96a93 --- /dev/null +++ b/frontend/src/lib/components/AlbumImage.svelte @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/lib/components/AlbumSourceBar.svelte b/frontend/src/lib/components/AlbumSourceBar.svelte new file mode 100644 index 0000000..58156fd --- /dev/null +++ b/frontend/src/lib/components/AlbumSourceBar.svelte @@ -0,0 +1,88 @@ + + +
+
+
+ + {@render icon()} + + {sourceLabel} + {#if hasAnyTracks} + {trackCount}/{totalTracks} + {/if} + {#if extraBadge} + {extraBadge} + {/if} +
+ + {#if hasAnyTracks} +
+ + + + + {#if hasBulkActions} +
+ +
+ {/if} +
+ {/if} +
+
diff --git a/frontend/src/lib/components/AlbumSourceBar.svelte.spec.ts b/frontend/src/lib/components/AlbumSourceBar.svelte.spec.ts new file mode 100644 index 0000000..fb3e19e --- /dev/null +++ b/frontend/src/lib/components/AlbumSourceBar.svelte.spec.ts @@ -0,0 +1,101 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { createRawSnippet } from 'svelte'; +import AlbumSourceBar from './AlbumSourceBar.svelte'; + +const iconSnippet = createRawSnippet(() => ({ + render: () => '' +})); + +function makeProps(overrides: Record = {}) { + return { + sourceLabel: 'Jellyfin', + sourceColor: '#00a4dc', + trackCount: 10, + totalTracks: 12, + onPlayAll: vi.fn(), + onShuffle: vi.fn(), + icon: iconSnippet, + ...overrides + }; +} + +function renderBar(overrides: Record = {}) { + return render(AlbumSourceBar, { + props: makeProps(overrides) + } as unknown as Parameters>[1]); +} + +describe('AlbumSourceBar.svelte', () => { + it('renders source label and track count', async () => { + renderBar(); + await expect.element(page.getByText('Jellyfin')).toBeVisible(); + await expect.element(page.getByText('10/12')).toBeVisible(); + }); + + it('renders Play All and Shuffle buttons', async () => { + renderBar(); + await expect.element(page.getByText('Play All')).toBeVisible(); + await expect.element(page.getByText('Shuffle')).toBeVisible(); + }); + + it('fires onPlayAll callback when Play All is clicked', async () => { + const props = makeProps(); + render( + AlbumSourceBar, + { props } as unknown as Parameters>[1] + ); + await page.getByText('Play All').click(); + expect(props.onPlayAll).toHaveBeenCalledOnce(); + }); + + it('fires onShuffle callback when Shuffle is clicked', async () => { + const props = makeProps(); + render( + AlbumSourceBar, + { props } as unknown as Parameters>[1] + ); + await page.getByText('Shuffle').click(); + expect(props.onShuffle).toHaveBeenCalledOnce(); + }); + + it('shows context menu with "Add All to Playlist" when onAddAllToPlaylist is provided', async () => { + const onAddAllToPlaylist = vi.fn(); + renderBar({ onAddAllToPlaylist }); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await expect.element(page.getByText('Add All to Playlist')).toBeVisible(); + }); + + it('fires onAddAllToPlaylist callback when "Add All to Playlist" is clicked', async () => { + const onAddAllToPlaylist = vi.fn(); + renderBar({ onAddAllToPlaylist }); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await page.getByText('Add All to Playlist').click(); + expect(onAddAllToPlaylist).toHaveBeenCalledOnce(); + }); + + it('does not show context menu when no optional callbacks are provided', async () => { + renderBar(); + const triggers = page.getByLabelText('More actions'); + await expect.element(triggers).not.toBeInTheDocument(); + }); + + it('shows "Add All to Queue" and "Play All Next" in context menu when callbacks are provided', async () => { + const onAddAllToQueue = vi.fn(); + const onPlayAllNext = vi.fn(); + renderBar({ onAddAllToQueue, onPlayAllNext }); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await expect.element(page.getByText('Add All to Queue')).toBeVisible(); + await expect.element(page.getByText('Play All Next')).toBeVisible(); + }); + + it('hides buttons when trackCount is 0', async () => { + renderBar({ trackCount: 0 }); + const playAll = page.getByText('Play All'); + await expect.element(playAll).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/AlbumYouTubeBar.svelte b/frontend/src/lib/components/AlbumYouTubeBar.svelte new file mode 100644 index 0000000..bdaf55a --- /dev/null +++ b/frontend/src/lib/components/AlbumYouTubeBar.svelte @@ -0,0 +1,215 @@ + + +
+
+
+ + YouTube + {#if hasAnyTrackLinks} + {generatedCount}/{tracks.length} + {/if} +
+ +
+ {#if hasAnyTrackLinks} + + {/if} + + {#if albumLink?.video_id} + + {:else if apiConfigured} + + {:else} +
+ +
+ {/if} + + {#if !allTracksGenerated} + {#if apiConfigured} + + {:else} +
+ +
+ {/if} + {/if} + + + + Search + +
+
+
diff --git a/frontend/src/lib/components/AlphabetJumpNav.svelte b/frontend/src/lib/components/AlphabetJumpNav.svelte new file mode 100644 index 0000000..2c6a659 --- /dev/null +++ b/frontend/src/lib/components/AlphabetJumpNav.svelte @@ -0,0 +1,192 @@ + + +{#if show} + + + +{/if} + + diff --git a/frontend/src/lib/components/AlphabetJumpNav.svelte.spec.ts b/frontend/src/lib/components/AlphabetJumpNav.svelte.spec.ts new file mode 100644 index 0000000..1158668 --- /dev/null +++ b/frontend/src/lib/components/AlphabetJumpNav.svelte.spec.ts @@ -0,0 +1,54 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import AlphabetJumpNav from './AlphabetJumpNav.svelte'; + +function renderNav(letters: string[], props: Record = {}) { + for (const letter of letters) { + const el = document.createElement('div'); + el.id = `letter-${letter}`; + el.textContent = `Section ${letter}`; + document.body.appendChild(el); + } + + return render(AlphabetJumpNav, { + props: { letters, sectionIdPrefix: 'letter-', ...props }, + } as Parameters>[1]); +} + +describe('AlphabetJumpNav.svelte', () => { + it('renders letter buttons for A-Z and #', async () => { + renderNav(['A', 'B', 'Z']); + // Both desktop and mobile navs render; use .first() to avoid strict-mode errors + await expect.element(page.getByLabelText('Jump to A').first()).toBeInTheDocument(); + await expect.element(page.getByLabelText('Jump to Z').first()).toBeInTheDocument(); + await expect.element(page.getByLabelText('Jump to numbers and symbols').first()).toBeInTheDocument(); + }); + + it('disables letters without content', async () => { + renderNav(['A', 'M']); + await expect.element(page.getByLabelText('Jump to B').first()).toBeDisabled(); + await expect.element(page.getByLabelText('Jump to A').first()).not.toBeDisabled(); + }); + + it('does not render when fewer than 2 letters are provided', async () => { + renderNav(['A']); + const nav = page.getByRole('navigation'); + expect(nav.elements().length).toBe(0); + }); + + it('highlights the active letter with aria-current', async () => { + renderNav(['A', 'B', 'C']); + await new Promise((r) => setTimeout(r, 200)); + const activeButtons = document.querySelectorAll('[aria-current="true"]'); + expect(activeButtons.length).toBeGreaterThanOrEqual(1); + }); + + it('calls onBeforeJump when a letter is clicked', async () => { + const onBeforeJump = vi.fn(); + renderNav(['A', 'B'], { onBeforeJump }); + + await page.getByLabelText('Jump to B').first().click(); + expect(onBeforeJump).toHaveBeenCalledWith('B'); + }); +}); diff --git a/frontend/src/lib/components/ArtistCard.svelte b/frontend/src/lib/components/ArtistCard.svelte new file mode 100644 index 0000000..7c59a16 --- /dev/null +++ b/frontend/src/lib/components/ArtistCard.svelte @@ -0,0 +1,21 @@ + + + +
+ +
+ +
+

{artist.title}

+
+
diff --git a/frontend/src/lib/components/ArtistCardSkeleton.svelte b/frontend/src/lib/components/ArtistCardSkeleton.svelte new file mode 100644 index 0000000..e89b186 --- /dev/null +++ b/frontend/src/lib/components/ArtistCardSkeleton.svelte @@ -0,0 +1,29 @@ + + +{#if variant === 'detailed'} +
+
+
+
+
+
+
+
+
+
+{:else} +
+
+
+
+
+
+
+
+{/if} diff --git a/frontend/src/lib/components/ArtistDescription.svelte b/frontend/src/lib/components/ArtistDescription.svelte new file mode 100644 index 0000000..6ed5ad4 --- /dev/null +++ b/frontend/src/lib/components/ArtistDescription.svelte @@ -0,0 +1,75 @@ + + +
+ {#if loading} +
+
+
+
+
+ {:else if description} +
+ {#if descriptionExpanded} +
+ {@html description.replace(/\n\n/g, '

').replace(/\n/g, '
')} +
+ + {:else} +
+ {@html description.replace(/\n\n/g, '

').replace(/\n/g, '
')} +
+ {#if showViewMore} + + {/if} + {/if} +
+ {:else} +

No biography available

+ {/if} +
diff --git a/frontend/src/lib/components/ArtistHeaderSkeleton.svelte b/frontend/src/lib/components/ArtistHeaderSkeleton.svelte new file mode 100644 index 0000000..2baef06 --- /dev/null +++ b/frontend/src/lib/components/ArtistHeaderSkeleton.svelte @@ -0,0 +1,32 @@ + + +
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+ {#each Array(5) as _} +
+ {/each} +
+ +
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/ArtistHero.svelte b/frontend/src/lib/components/ArtistHero.svelte new file mode 100644 index 0000000..9496999 --- /dev/null +++ b/frontend/src/lib/components/ArtistHero.svelte @@ -0,0 +1,171 @@ + + +
+
+ + + +
+
+ {#if showBackButton} +
+ +
+ {/if} +
+
+
+
+ {#if !heroImageLoaded} +
+ + + + + +
+ {/if} + {#if useRemoteAvatar && resolvedRemoteAvatar && !avatarRemoteError} + {artist.name} { + avatarRemoteError = true; + heroImageLoaded = false; + }} + /> + {:else} + {artist.name} { + const target = e.currentTarget as HTMLImageElement; + target.style.display = 'none'; + }} + /> + {/if} +
+ {#if artist.in_library} +
+ + In Library +
+ {/if} +
+
+ +
+ {#if artist.type} + + {artist.type === 'Group' ? 'Band' : artist.type === 'Person' ? 'Artist' : artist.type} + + {/if} +

+ {artist.name} +

+ {#if artist.disambiguation} +

({artist.disambiguation})

+ {/if} + + {#if validLinks.length > 0} + + {/if} +
+
+
+
+
+ + diff --git a/frontend/src/lib/components/ArtistImage.svelte b/frontend/src/lib/components/ArtistImage.svelte new file mode 100644 index 0000000..9343c82 --- /dev/null +++ b/frontend/src/lib/components/ArtistImage.svelte @@ -0,0 +1,24 @@ + + + diff --git a/frontend/src/lib/components/ArtistLinks.svelte b/frontend/src/lib/components/ArtistLinks.svelte new file mode 100644 index 0000000..e6fcf24 --- /dev/null +++ b/frontend/src/lib/components/ArtistLinks.svelte @@ -0,0 +1,138 @@ + + +{#if groupedLinks.length > 0} +
+ {#each groupedLinks as group, i} + {#if i > 0} + + {/if} +
+ + {group.label} + + {#each group.links as link} + + {@html getLinkIcon(link.label)} + + {/each} +
+ {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/ArtistPageToc.svelte b/frontend/src/lib/components/ArtistPageToc.svelte new file mode 100644 index 0000000..7a3952d --- /dev/null +++ b/frontend/src/lib/components/ArtistPageToc.svelte @@ -0,0 +1,185 @@ + + +{#if showToc} + + + +{/if} diff --git a/frontend/src/lib/components/ArtistRemovedModal.svelte b/frontend/src/lib/components/ArtistRemovedModal.svelte new file mode 100644 index 0000000..f03a813 --- /dev/null +++ b/frontend/src/lib/components/ArtistRemovedModal.svelte @@ -0,0 +1,36 @@ + + + + + + diff --git a/frontend/src/lib/components/BackButton.svelte b/frontend/src/lib/components/BackButton.svelte new file mode 100644 index 0000000..a0c6b6b --- /dev/null +++ b/frontend/src/lib/components/BackButton.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/components/BaseImage.svelte b/frontend/src/lib/components/BaseImage.svelte new file mode 100644 index 0000000..bd82110 --- /dev/null +++ b/frontend/src/lib/components/BaseImage.svelte @@ -0,0 +1,226 @@ + + +
+ {#if showPlaceholder && (!imgLoaded || imgError || !hasSource)} +
+ {#if imageType === 'album'} + + + + + + + + + {:else} + + + + + + {/if} +
+ {/if} + {#if useRemoteUrl && resolvedRemoteUrl && !remoteError} + + {:else if hasSource && !imgError} + {#if lazy} + + {:else} + + {/if} + {/if} +
diff --git a/frontend/src/lib/components/BaseImage.svelte.spec.ts b/frontend/src/lib/components/BaseImage.svelte.spec.ts new file mode 100644 index 0000000..ceaa644 --- /dev/null +++ b/frontend/src/lib/components/BaseImage.svelte.spec.ts @@ -0,0 +1,117 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +let mockDirectRemoteEnabled = true; + +vi.mock('$lib/stores/imageSettings', () => ({ + imageSettingsStore: { + subscribe: vi.fn((cb: (v: unknown) => void) => { + cb({ directRemoteImagesEnabled: mockDirectRemoteEnabled }); + return () => {}; + }), + load: vi.fn() + } +})); + +import BaseImage from './BaseImage.svelte'; + +const validMbid = 'b1392450-e666-3926-a536-22c65f834433'; +const cdnUrl = 'https://r2.theaudiodb.com/images/media/artist/thumb/abc123.jpg'; + +function renderComponent(overrides: Partial<{ + mbid: string; + remoteUrl: string | null; + customUrl: string | null; + imageType: 'album' | 'artist'; + size: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'hero' | 'full'; + lazy: boolean; + alt: string; +}> = {}) { + return render(BaseImage, { + props: { + mbid: overrides.mbid ?? validMbid, + remoteUrl: overrides.remoteUrl ?? null, + customUrl: overrides.customUrl ?? null, + imageType: overrides.imageType ?? 'album', + size: overrides.size ?? 'md', + lazy: overrides.lazy ?? false, + alt: overrides.alt ?? 'Test Image', + }, + } as Parameters>[1]); +} + +describe('BaseImage.svelte — remoteUrl', () => { + beforeEach(() => { + mockDirectRemoteEnabled = true; + }); + + it('renders CDN URL with referrerpolicy when remoteUrl is set', async () => { + renderComponent({ remoteUrl: cdnUrl }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toBeInTheDocument(); + await expect.element(img).toHaveAttribute('referrerpolicy', 'no-referrer'); + await expect.element(img).toHaveAttribute('src', `${cdnUrl}/small`); + }); + + it('appends /medium suffix for lg size', async () => { + renderComponent({ remoteUrl: cdnUrl, size: 'lg' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toHaveAttribute('src', `${cdnUrl}/medium`); + }); + + it('uses original URL for full size', async () => { + renderComponent({ remoteUrl: cdnUrl, size: 'full' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toHaveAttribute('src', cdnUrl); + }); + + it('renders proxy img without referrerpolicy when remoteUrl is null', async () => { + renderComponent({ remoteUrl: null, imageType: 'album' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toBeInTheDocument(); + await expect.element(img).not.toHaveAttribute('referrerpolicy'); + }); + + it('renders proxy URL for artist when remoteUrl is null', async () => { + renderComponent({ remoteUrl: null, imageType: 'artist' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toBeInTheDocument(); + await expect.element(img).toHaveAttribute( + 'src', + `/api/v1/covers/artist/${validMbid}?size=250` + ); + }); + + it('renders proxy URL when remoteUrl is set but setting is disabled', async () => { + mockDirectRemoteEnabled = false; + renderComponent({ remoteUrl: cdnUrl, imageType: 'artist' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toBeInTheDocument(); + await expect.element(img).toHaveAttribute( + 'src', + `/api/v1/covers/artist/${validMbid}?size=250` + ); + await expect.element(img).not.toHaveAttribute('referrerpolicy'); + }); + + it('falls back to proxy URL when remote image errors', async () => { + renderComponent({ remoteUrl: cdnUrl, imageType: 'artist' }); + + const img = page.getByAltText('Test Image'); + await expect.element(img).toHaveAttribute('src', `${cdnUrl}/small`); + + img.element().dispatchEvent(new Event('error')); + + await expect.element(page.getByAltText('Test Image')).toHaveAttribute( + 'src', + `/api/v1/covers/artist/${validMbid}?size=250` + ); + }); +}); diff --git a/frontend/src/lib/components/CacheSyncIndicator.svelte b/frontend/src/lib/components/CacheSyncIndicator.svelte new file mode 100644 index 0000000..e2c51fb --- /dev/null +++ b/frontend/src/lib/components/CacheSyncIndicator.svelte @@ -0,0 +1,119 @@ + + +{#if syncStatus.showIndicator} +
+
+
+
+ {#if isComplete} +
+ +
+ Sync Complete + {:else if syncStatus.error} +
+ +
+ Sync Failed + {:else} +
+ +
+
+ {syncStatus.phaseLabel} + {#if syncStatus.phaseNumber > 0} + + {syncStatus.phaseNumber}/{syncStatus.totalPhases} + + {/if} +
+ {/if} +
+ +
+ + {#if syncStatus.isActive} +
+
+
+ +
+ {#if syncStatus.totalItems === 0} + Cached ✓ + {:else} + {syncStatus.processedItems} / {syncStatus.totalItems} + {syncStatus.progress}% + {/if} +
+ + {#if syncStatus.currentItem} +
+ {syncStatus.currentItem} +
+ {/if} + {/if} + + {#if syncStatus.error} +

{syncStatus.error}

+ {/if} +
+
+{/if} + + diff --git a/frontend/src/lib/components/CarouselSkeleton.svelte b/frontend/src/lib/components/CarouselSkeleton.svelte new file mode 100644 index 0000000..26059e6 --- /dev/null +++ b/frontend/src/lib/components/CarouselSkeleton.svelte @@ -0,0 +1,28 @@ + + +
+ Loading… + {#each Array(count) as _} +
+
+
+ {#if showSubtitle} +
+ {/if} +
+ {/each} +
diff --git a/frontend/src/lib/components/ContextMenu.svelte b/frontend/src/lib/components/ContextMenu.svelte new file mode 100644 index 0000000..51f1ace --- /dev/null +++ b/frontend/src/lib/components/ContextMenu.svelte @@ -0,0 +1,157 @@ + + + + + + +{#if isOpen} + +{/if} diff --git a/frontend/src/lib/components/ContextMenu.svelte.spec.ts b/frontend/src/lib/components/ContextMenu.svelte.spec.ts new file mode 100644 index 0000000..95b5433 --- /dev/null +++ b/frontend/src/lib/components/ContextMenu.svelte.spec.ts @@ -0,0 +1,92 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import ContextMenu from './ContextMenu.svelte'; +import { closeAllMenus } from './ContextMenu.svelte'; +import type { MenuItem } from './ContextMenu.svelte'; +import { ListPlus, ListStart } from 'lucide-svelte'; + +function makeItems(overrides: Partial[] = []): MenuItem[] { + const defaults: MenuItem[] = [ + { label: 'Add to Queue', icon: ListPlus, onclick: vi.fn() }, + { label: 'Play Next', icon: ListStart, onclick: vi.fn() }, + ]; + return defaults.map((d, i) => ({ ...d, ...overrides[i] })); +} + +function renderMenu(items: MenuItem[] = makeItems()) { + return render(ContextMenu, { + props: { items } + } as Parameters>[1]); +} + +describe('ContextMenu.svelte', () => { + beforeEach(() => { + closeAllMenus(); + }); + + it('renders the trigger button', async () => { + renderMenu(); + await expect.element(page.getByLabelText('More actions')).toBeInTheDocument(); + }); + + it('shows menu items when opened', async () => { + renderMenu(); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await expect.element(page.getByText('Add to Queue')).toBeVisible(); + await expect.element(page.getByText('Play Next')).toBeVisible(); + }); + + it('fires callback on item click and closes menu', async () => { + const items = makeItems(); + renderMenu(items); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await page.getByText('Add to Queue').click(); + expect(items[0].onclick).toHaveBeenCalledOnce(); + }); + + it('renders disabled items as non-interactive', async () => { + const items = makeItems([{ disabled: true }]); + renderMenu(items); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + const disabledBtn = page.getByText('Add to Queue'); + await expect.element(disabledBtn).toBeVisible(); + }); + + it('has correct ARIA roles', async () => { + renderMenu(); + const trigger = page.getByLabelText('More actions'); + await trigger.click(); + await expect.element(page.getByRole('menu')).toBeInTheDocument(); + const menuItems = page.getByRole('menuitem').all(); + expect(menuItems.length).toBeGreaterThanOrEqual(2); + }); + + it('does not have redundant role on summary', async () => { + renderMenu(); + const summary = page.getByLabelText('More actions'); + const roleAttr = await summary.element().getAttribute('role'); + expect(roleAttr).toBeNull(); + }); + + it('opening a second menu closes the first', async () => { + const items1: MenuItem[] = [{ label: 'Action A', icon: ListPlus, onclick: vi.fn() }]; + const items2: MenuItem[] = [{ label: 'Action B', icon: ListStart, onclick: vi.fn() }]; + + renderMenu(items1); + renderMenu(items2); + + const triggers = page.getByLabelText('More actions').all(); + expect((await triggers).length).toBe(2); + + await (await triggers)[0].click(); + await expect.element(page.getByText('Action A')).toBeVisible(); + + await (await triggers)[1].click(); + await expect.element(page.getByText('Action B')).toBeVisible(); + await expect.element(page.getByText('Action A')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DQInfoGrid.svelte b/frontend/src/lib/components/DQInfoGrid.svelte new file mode 100644 index 0000000..1ee7eeb --- /dev/null +++ b/frontend/src/lib/components/DQInfoGrid.svelte @@ -0,0 +1,88 @@ + + +
+ {#if enrichment.release_date} +
+ + + +
+ Released + {enrichment.release_date} +
+
+ {/if} + {#if enrichment.country} +
+ + + +
+ Origin + {countryToFlag(enrichment.country)} {enrichment.country} +
+
+ {/if} + {#if enrichment.listen_count != null} +
+ + + +
+ Plays + {enrichment.listen_count.toLocaleString()} +
+
+ {/if} + {#if inLibrary} +
+ + + +
+ Library + In Library +
+
+ {/if} +
+{#if showTags && enrichment.tags.length > 0} +
+ {#each enrichment.tags.slice(0, 6) as tag} + {tag} + {/each} +
+{/if} + + diff --git a/frontend/src/lib/components/DQVideoSection.svelte b/frontend/src/lib/components/DQVideoSection.svelte new file mode 100644 index 0000000..cc9a07f --- /dev/null +++ b/frontend/src/lib/components/DQVideoSection.svelte @@ -0,0 +1,226 @@ + + +{#if enriching} +
+{:else if enrichment?.youtube_url || ytSearchResult?.embed_url} + {@const videoUrl = enrichment?.youtube_url ?? ytSearchResult?.embed_url} +
+ +
+
+ {#if enrichment?.youtube_search_url} + + Search on YouTube + + {/if} + {#if quota && !compact} + {quota.used} / {quota.limit} lookups today + {/if} +
+{:else if ytSearching} +
+ +

{compact ? 'Searching…' : 'Searching for video preview…'}

+
+{:else if ytSearchResult?.error === 'quota_exceeded'} +
+ {#if !compact}{/if} +

YouTube limit reached{compact ? '' : ' for today'}

+ {#if enrichment} + + Search on YouTube + + {/if} +
+{:else if ytSearchResult?.error === 'not_found' || ytSearchResult?.error === 'request_failed'} +
+ {#if !compact}{/if} +

No video found

+ {#if enrichment} + + Search on YouTube + + {/if} +
+{:else if enrichment?.youtube_search_available} +
+ + {#if quota} +
+ + + {quota.remaining} / {quota.limit} lookups remaining today + +
+ {:else if !compact} +

Uses YouTube Data API

+ {/if} + {#if enrichment?.youtube_search_url} + + or search YouTube manually + + {/if} +
+{:else if enrichment} +
+ {#if compact} +

No preview available

+ Search on YouTube + {:else} + +

No video preview available

+ + + Search on YouTube + + {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/DegradedBanner.svelte b/frontend/src/lib/components/DegradedBanner.svelte new file mode 100644 index 0000000..a5a1f3b --- /dev/null +++ b/frontend/src/lib/components/DegradedBanner.svelte @@ -0,0 +1,45 @@ + + +{#if hasDegradation} +
+
+ + {sourceLabel} {verb} unavailable, so some results may be missing + +
+
+{/if} diff --git a/frontend/src/lib/components/DegradedBanner.svelte.spec.ts b/frontend/src/lib/components/DegradedBanner.svelte.spec.ts new file mode 100644 index 0000000..e0954e8 --- /dev/null +++ b/frontend/src/lib/components/DegradedBanner.svelte.spec.ts @@ -0,0 +1,57 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import DegradedBanner from './DegradedBanner.svelte'; +import { serviceStatusStore } from '$lib/stores/serviceStatus'; + +function renderBanner() { + return render(DegradedBanner); +} + +describe('DegradedBanner.svelte', () => { + beforeEach(() => { + serviceStatusStore.clear(); + }); + + it('is hidden when store is empty', async () => { + expect.assertions(1); + renderBanner(); + await expect + .element(page.getByRole('status')) + .not.toBeInTheDocument(); + }); + + it('renders when store has degraded services', async () => { + expect.assertions(1); + serviceStatusStore.recordFromResponse({ musicbrainz: 'error' }); + renderBanner(); + await expect + .element(page.getByText(/Musicbrainz is unavailable, so some results may be missing/)) + .toBeVisible(); + }); + + it('shows multiple degraded sources with plural verb', async () => { + expect.assertions(1); + serviceStatusStore.recordFromResponse({ musicbrainz: 'error', audiodb: 'degraded' }); + renderBanner(); + await expect + .element(page.getByText(/Musicbrainz, Audiodb are unavailable, so some results may be missing/)) + .toBeVisible(); + }); + + it('can be dismissed', async () => { + expect.assertions(2); + serviceStatusStore.recordFromResponse({ musicbrainz: 'error' }); + renderBanner(); + + await expect + .element(page.getByRole('status')) + .toBeVisible(); + + await page.getByLabelText('Dismiss').click(); + + await expect + .element(page.getByRole('status')) + .not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DeleteAlbumModal.svelte b/frontend/src/lib/components/DeleteAlbumModal.svelte new file mode 100644 index 0000000..de3b882 --- /dev/null +++ b/frontend/src/lib/components/DeleteAlbumModal.svelte @@ -0,0 +1,111 @@ + + + + + + diff --git a/frontend/src/lib/components/DiscoverArtistHero.svelte b/frontend/src/lib/components/DiscoverArtistHero.svelte new file mode 100644 index 0000000..7900db2 --- /dev/null +++ b/frontend/src/lib/components/DiscoverArtistHero.svelte @@ -0,0 +1,93 @@ + + +
+ + +
+
+
+ Because You Listen To +
+
+ +
+

+ {entry.seed_artist} +

+ {#if entry.listen_count > 0} + + + {entry.listen_count} listens this week + + {/if} +
+
+ +
+ +
+
+ + diff --git a/frontend/src/lib/components/DiscoverQueueCard.svelte b/frontend/src/lib/components/DiscoverQueueCard.svelte new file mode 100644 index 0000000..e2b99cd --- /dev/null +++ b/frontend/src/lib/components/DiscoverQueueCard.svelte @@ -0,0 +1,114 @@ + + +
+
+
+
+ {#if isBuilding && !hasCachedQueue} +
+ + + + +
+ {:else} + + {/if} +
+
+

Discover Queue

+ {#if hasCachedQueue} +

You have a queue in progress

+ {:else if isBuilding} +

Building your personalised queue…

+ {:else if isError} +

We couldn't build your queue

+ {:else if isReady} +

Your queue is ready!

+ {:else} +

Find new music tailored to your taste

+ {/if} +
+
+ {#if hasCachedQueue} + + {:else if isBuilding} + + {:else if isError} + + + {:else} + + {/if} +
+
+
diff --git a/frontend/src/lib/components/DiscoverQueueModal.svelte b/frontend/src/lib/components/DiscoverQueueModal.svelte new file mode 100644 index 0000000..72a51f2 --- /dev/null +++ b/frontend/src/lib/components/DiscoverQueueModal.svelte @@ -0,0 +1,566 @@ + + + + + + diff --git a/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte b/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte new file mode 100644 index 0000000..7aae7f1 --- /dev/null +++ b/frontend/src/lib/components/DiscoveryAlbumCarousel.svelte @@ -0,0 +1,59 @@ + + +
+

{title}

+ + {#if loading} + + {:else if !configured} +
+

Connect a music service in Settings to see recommendations

+ Configure +
+ {:else if albums.length === 0} +
+

{emptyMessage}

+
+ {:else} + + {#each albums as album} +
+ +
+ {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte new file mode 100644 index 0000000..73b704c --- /dev/null +++ b/frontend/src/lib/components/EmptyState.svelte @@ -0,0 +1,24 @@ + + +
+ +

{title}

+ {#if description} +

{description}

+ {/if} + {#if ctaLabel && ctaHref} + {ctaLabel} + {/if} +
diff --git a/frontend/src/lib/components/EqPanel.svelte b/frontend/src/lib/components/EqPanel.svelte new file mode 100644 index 0000000..01c1f97 --- /dev/null +++ b/frontend/src/lib/components/EqPanel.svelte @@ -0,0 +1,224 @@ + + +{#if open} + + + +
+
+
+

Equalizer

+ eqStore.toggleEq()} + aria-label="Toggle equalizer" + /> +
+ +
+ + {#if isYouTube} +
+

EQ is not available during YouTube playback

+
+ {/if} + +
+ +
+ +
+
+ +
+
+
+ {#each DB_TICKS as tick} + + {tick > 0 ? '+' : ''}{tick} + + {/each} +
+ +
+ {#each { length: EQ_BAND_COUNT } as _, i} +
+ + {eqStore.gains[i] > 0 ? '+' : ''}{eqStore.gains[i].toFixed(eqStore.gains[i] % 1 === 0 ? 0 : 1)} + + +
handlePointerDown(i, e)} + onpointermove={(e) => handlePointerMove(i, e)} + onpointerup={handlePointerUp} + onpointercancel={handlePointerUp} + role="slider" + aria-label="{EQ_FREQUENCY_LABELS[i]} Hz" + aria-valuemin={EQ_MIN_GAIN} + aria-valuemax={EQ_MAX_GAIN} + aria-valuenow={eqStore.gains[i]} + > +
+ +
+ +
+ +
+
+ + {EQ_FREQUENCY_LABELS[i]} +
+ {/each} +
+
+
+
+{/if} diff --git a/frontend/src/lib/components/GenreAlbumCard.svelte b/frontend/src/lib/components/GenreAlbumCard.svelte new file mode 100644 index 0000000..614df93 --- /dev/null +++ b/frontend/src/lib/components/GenreAlbumCard.svelte @@ -0,0 +1,62 @@ + + + e.key === 'Enter' && onclick()} + role={href || !onclick ? undefined : 'button'} + tabindex={href || !onclick ? undefined : 0} + class="card bg-base-200/50 hover:bg-base-200 hover:scale-[1.03] hover:shadow-lg transition-all duration-200 group {href || + onclick + ? 'cursor-pointer' + : 'cursor-default'}" +> +
+ + {#if showLibraryBadge || album.in_library} +
+ +
+ {/if} + {#if album.mbid && album.in_library} + + {/if} +
+
+

{album.name}

+

+ {album.artist_name || 'Unknown Artist'} + {#if album.release_date} + · {album.release_date} + {/if} +

+
+
diff --git a/frontend/src/lib/components/GenreArtistCard.svelte b/frontend/src/lib/components/GenreArtistCard.svelte new file mode 100644 index 0000000..9751c6d --- /dev/null +++ b/frontend/src/lib/components/GenreArtistCard.svelte @@ -0,0 +1,45 @@ + + + e.key === 'Enter' && onclick()} + role={href || !onclick ? undefined : 'button'} + tabindex={href || !onclick ? undefined : 0} + class="card bg-base-200/50 hover:bg-base-200 hover:scale-[1.03] hover:shadow-lg transition-all duration-200 group {href || + onclick + ? 'cursor-pointer' + : 'cursor-default'}" +> +
+ + {#if showLibraryBadge || artist.in_library} +
+ +
+ {/if} +
+
+

{artist.name}

+ {#if artist.listen_count} +

+ {artist.listen_count} album{artist.listen_count !== 1 ? 's' : ''} +

+ {/if} +
+
diff --git a/frontend/src/lib/components/GenreGrid.svelte b/frontend/src/lib/components/GenreGrid.svelte new file mode 100644 index 0000000..1efb1be --- /dev/null +++ b/frontend/src/lib/components/GenreGrid.svelte @@ -0,0 +1,139 @@ + + +
+
+

{title}

+
+
+ {#each genres.slice(0, 20) as genre} + {@const artistMbid = genreArtists?.[genre.name]} + {@const cdnUrl = genreArtistImages?.[genre.name] ?? null} + {@const useCdn = + cdnUrl && + $imageSettingsStore.directRemoteImagesEnabled && + !cdnFailedSet.has(genre.name)} + {@const hasImage = useCdn || artistMbid} + {@const isLoaded = loadedSet.has(genre.name)} + +
+ +
+ + {#if hasImage && !isLoaded} +
+ {/if} + + {#if useCdn} + onCdnError(genre.name)} + on:load={() => onImgLoad(genre.name)} + /> + {:else if artistMbid} + onImgLoad(genre.name)} + /> + {/if} + +
+ +
+ {#if genre.listen_count} + + {formatCount(genre.listen_count)} plays + + {/if} +

+ {genre.name} +

+
+ +
+
+ {/each} +
+
diff --git a/frontend/src/lib/components/HeroBackdrop.svelte b/frontend/src/lib/components/HeroBackdrop.svelte new file mode 100644 index 0000000..5901083 --- /dev/null +++ b/frontend/src/lib/components/HeroBackdrop.svelte @@ -0,0 +1,76 @@ + + +{#if imageUrl} + +{/if} + + diff --git a/frontend/src/lib/components/HomeSection.svelte b/frontend/src/lib/components/HomeSection.svelte new file mode 100644 index 0000000..b5e4202 --- /dev/null +++ b/frontend/src/lib/components/HomeSection.svelte @@ -0,0 +1,289 @@ + + +
+
+
+ {#if headerLink} + + {section.title} + + {:else} +

{section.title}

+ {/if} + {#if section.source === 'lastfm'} + + + Last.fm + + {:else if section.source === 'listenbrainz'} + + + ListenBrainz + + {:else if section.source} + {section.source} + {/if} +
+ {#if headerLink} + + See all + + + {/if} +
+ + {#if section.items.length === 0 && section.fallback_message && showConnectCard} +
+
+
+ {#if section.connect_service === 'listenbrainz'} + + {:else if section.connect_service === 'jellyfin'} + + {:else if section.connect_service === 'lastfm'} + + {:else} + + {/if} +
+

{section.fallback_message}

+ {#if section.connect_service} + + Connect {section.connect_service === 'listenbrainz' + ? 'ListenBrainz' + : section.connect_service === 'lastfm' + ? 'Last.fm' + : section.connect_service === 'jellyfin' + ? 'Jellyfin' + : section.connect_service} + + {/if} +
+
+ {:else if section.type === 'genres'} +
+ {#each section.items as item} + {#if isGenre(item)} + + {item.name} + {#if item.listen_count} + {formatListenCount(item.listen_count)} + {/if} + + {/if} + {/each} +
+ {:else} + + {#each section.items as item} + {#if isArtist(item)} + {@const artistHref = artistHrefOrNull(item.mbid)} +
+ +
+ + {#if !item.mbid} +
+ +
+ {/if} + {#if item.in_library} +
+ +
+ {/if} +
+
+

{item.name}

+ {#if item.listen_count} +

{formatListenCount(item.listen_count)}

+ {/if} +
+
+
+ {:else if isAlbum(item)} + {@const albumHref = albumHrefOrNull(item.mbid)} +
+ +
+ + {#if item.in_library} +
+ +
+ {/if} + {#if item.mbid && item.in_library} + + {/if} + {#if !item.mbid} + + {/if} +
+
+

{item.name}

+ {#if item.artist_name} +

{item.artist_name}

+ {/if} +
+
+
+ {:else if isTrack(item)} + {@const trackArtistHref = artistHrefOrNull(item.artist_mbid)} +
+ +
+ {#if item.image_url} + {item.album_name + {:else} +
+ +
+ {/if} +
+
+

{item.name}

+ {#if item.artist_name} +

{item.artist_name}

+ {/if} + {#if item.listened_at} +

{formatListenedAt(item.listened_at)}

+ {/if} + {#if !item.artist_mbid} +
+ +
+ {/if} +
+
+
+ {/if} + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/HorizontalCarousel.svelte b/frontend/src/lib/components/HorizontalCarousel.svelte new file mode 100644 index 0000000..a9d13c4 --- /dev/null +++ b/frontend/src/lib/components/HorizontalCarousel.svelte @@ -0,0 +1,78 @@ + + +
+ {#if showLeftArrow} + + {/if} + +
+ {@render children()} +
+ + {#if showRightArrow} + + {/if} +
+ diff --git a/frontend/src/lib/components/JellyfinIcon.svelte b/frontend/src/lib/components/JellyfinIcon.svelte new file mode 100644 index 0000000..0bd8262 --- /dev/null +++ b/frontend/src/lib/components/JellyfinIcon.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/LastFmAlbumEnrichment.svelte b/frontend/src/lib/components/LastFmAlbumEnrichment.svelte new file mode 100644 index 0000000..6fa4c43 --- /dev/null +++ b/frontend/src/lib/components/LastFmAlbumEnrichment.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/LastFmAlbumEnrichment.svelte.spec.ts b/frontend/src/lib/components/LastFmAlbumEnrichment.svelte.spec.ts new file mode 100644 index 0000000..335b5cd --- /dev/null +++ b/frontend/src/lib/components/LastFmAlbumEnrichment.svelte.spec.ts @@ -0,0 +1,129 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import LastFmAlbumEnrichment from './LastFmAlbumEnrichment.svelte'; +import type { LastFmAlbumEnrichment as LastFmAlbumEnrichmentType } from '$lib/types'; + +const fullEnrichment: LastFmAlbumEnrichmentType = { + summary: 'A groundbreaking album released in 1997.', + tags: [ + { name: 'alternative', url: 'https://last.fm/tag/alternative' }, + { name: 'electronic', url: 'https://last.fm/tag/electronic' }, + ], + listeners: 1200000, + playcount: 45000000, + url: 'https://www.last.fm/music/TestArtist/TestAlbum', +}; + +function renderComponent(props: Record = {}) { + return render(LastFmAlbumEnrichment, { + props: { enrichment: fullEnrichment, ...props }, + } as Parameters>[1]); +} + +describe('LastFmAlbumEnrichment.svelte', () => { + it('should show loading skeleton when loading', async () => { + renderComponent({ enrichment: null, loading: true }); + + const skeletons = document.querySelectorAll('.skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('should render nothing when not enabled', async () => { + renderComponent({ enabled: false }); + + await expect + .element(page.getByText('Last.fm')) + .not.toBeInTheDocument(); + }); + + it('should render nothing when enrichment is null', async () => { + renderComponent({ enrichment: null }); + + await expect + .element(page.getByText('Last.fm')) + .not.toBeInTheDocument(); + }); + + it('should display Last.fm badge when enrichment is present', async () => { + renderComponent(); + + await expect.element(page.getByText('Last.fm', { exact: true })).toBeInTheDocument(); + }); + + it('should display formatted listener count', async () => { + renderComponent(); + + await expect + .element(page.getByText('1.2M listeners')) + .toBeInTheDocument(); + }); + + it('should display formatted play count', async () => { + renderComponent(); + + await expect + .element(page.getByText('45.0M plays')) + .toBeInTheDocument(); + }); + + it('should display summary text', async () => { + renderComponent(); + + await expect + .element(page.getByText('A groundbreaking album released in 1997.')) + .toBeInTheDocument(); + }); + + it('should render tags as anchor links', async () => { + renderComponent(); + + const altLink = page.getByRole('link', { name: 'alternative' }); + await expect.element(altLink).toBeInTheDocument(); + await expect + .element(altLink) + .toHaveAttribute('href', '/genre?name=alternative'); + + const electronicLink = page.getByRole('link', { name: 'electronic' }); + await expect.element(electronicLink).toBeInTheDocument(); + await expect + .element(electronicLink) + .toHaveAttribute('href', '/genre?name=electronic'); + }); + + it('should display View on Last.fm link', async () => { + renderComponent(); + + const link = page.getByRole('link', { name: /View on Last\.fm/ }); + await expect.element(link).toBeInTheDocument(); + await expect + .element(link) + .toHaveAttribute('href', 'https://www.last.fm/music/TestArtist/TestAlbum'); + }); + + it('should hide stats section when both counts are zero', async () => { + renderComponent({ + enrichment: { ...fullEnrichment, listeners: 0, playcount: 0 }, + }); + + await expect + .element(page.getByText('listeners')) + .not.toBeInTheDocument(); + }); + + it('should render enrichment with only summary (no tags, no stats)', async () => { + renderComponent({ + enrichment: { + ...fullEnrichment, + tags: [], + listeners: 0, + playcount: 0, + }, + }); + + await expect.element(page.getByText('Last.fm', { exact: true })).toBeInTheDocument(); + await expect + .element(page.getByText('A groundbreaking album released in 1997.')) + .toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/LastFmEnrichment.svelte b/frontend/src/lib/components/LastFmEnrichment.svelte new file mode 100644 index 0000000..d4e057d --- /dev/null +++ b/frontend/src/lib/components/LastFmEnrichment.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/LastFmEnrichment.svelte.spec.ts b/frontend/src/lib/components/LastFmEnrichment.svelte.spec.ts new file mode 100644 index 0000000..171c2e7 --- /dev/null +++ b/frontend/src/lib/components/LastFmEnrichment.svelte.spec.ts @@ -0,0 +1,129 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import LastFmEnrichment from './LastFmEnrichment.svelte'; +import type { LastFmArtistEnrichment } from '$lib/types'; + +const fullEnrichment: LastFmArtistEnrichment = { + bio: 'A legendary rock band formed in the 1960s.', + summary: null, + tags: [ + { name: 'rock', url: 'https://last.fm/tag/rock' }, + { name: 'classic rock', url: 'https://last.fm/tag/classic+rock' }, + { name: 'british', url: 'https://last.fm/tag/british' }, + ], + listeners: 2500000, + playcount: 150000000, + similar_artists: [], + url: 'https://www.last.fm/music/TestArtist', +}; + +function renderComponent(props: Record = {}) { + return render(LastFmEnrichment, { + props: { enrichment: fullEnrichment, ...props }, + } as Parameters>[1]); +} + +describe('LastFmEnrichment.svelte', () => { + it('should show loading skeleton when loading', async () => { + renderComponent({ enrichment: null, loading: true }); + + const skeletons = document.querySelectorAll('.skeleton'); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it('should render nothing when not enabled', async () => { + renderComponent({ enabled: false }); + + await expect + .element(page.getByText('Last.fm')) + .not.toBeInTheDocument(); + }); + + it('should render nothing when enrichment is null', async () => { + renderComponent({ enrichment: null }); + + await expect + .element(page.getByText('Last.fm')) + .not.toBeInTheDocument(); + }); + + it('should display Last.fm badge when enrichment is present', async () => { + renderComponent(); + + await expect.element(page.getByText('Last.fm', { exact: true })).toBeInTheDocument(); + }); + + it('should display formatted listener count', async () => { + renderComponent(); + + await expect + .element(page.getByText('2.5M listeners')) + .toBeInTheDocument(); + }); + + it('should display formatted play count', async () => { + renderComponent(); + + await expect + .element(page.getByText('150.0M plays')) + .toBeInTheDocument(); + }); + + it('should display bio text', async () => { + renderComponent(); + + await expect + .element(page.getByText('A legendary rock band formed in the 1960s.')) + .toBeInTheDocument(); + }); + + it('should render tags as anchor links', async () => { + renderComponent(); + + const rockLink = page.getByRole('link', { name: 'rock', exact: true }); + await expect.element(rockLink).toBeInTheDocument(); + await expect.element(rockLink).toHaveAttribute('href', '/genre?name=rock'); + + const classicRockLink = page.getByRole('link', { name: 'classic rock' }); + await expect.element(classicRockLink).toBeInTheDocument(); + await expect + .element(classicRockLink) + .toHaveAttribute('href', '/genre?name=classic%20rock'); + }); + + it('should display View on Last.fm link', async () => { + renderComponent(); + + const link = page.getByRole('link', { name: /View on Last\.fm/ }); + await expect.element(link).toBeInTheDocument(); + await expect + .element(link) + .toHaveAttribute('href', 'https://www.last.fm/music/TestArtist'); + }); + + it('should hide stats section when both counts are zero', async () => { + renderComponent({ + enrichment: { ...fullEnrichment, listeners: 0, playcount: 0 }, + }); + + await expect + .element(page.getByText('listeners')) + .not.toBeInTheDocument(); + }); + + it('should render enrichment with only tags (no bio, no stats)', async () => { + renderComponent({ + enrichment: { + ...fullEnrichment, + bio: null, + listeners: 0, + playcount: 0, + }, + }); + + await expect.element(page.getByText('Last.fm', { exact: true })).toBeInTheDocument(); + const rockLink = page.getByRole('link', { name: 'rock', exact: true }); + await expect.element(rockLink).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/LastFmInfoCard.svelte b/frontend/src/lib/components/LastFmInfoCard.svelte new file mode 100644 index 0000000..e3d9050 --- /dev/null +++ b/frontend/src/lib/components/LastFmInfoCard.svelte @@ -0,0 +1,145 @@ + + +{#if loading} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+{:else if !enabled} +{:else if hasContent} +
+
+
+ + Last.fm + +
+ {#if url} + + + View on Last.fm + + {/if} +
+ + {#if hasStats} +
+ {#if listeners > 0} + + + {formatListenCount(listeners, true)} listeners + + {/if} + {#if playcount > 0} + + + {formatListenCount(playcount)} + + {/if} +
+ {/if} + + {#if hasText} +
+ {#if textExpanded} +
{text}
+ + {:else} +
+ {text} +
+ {#if showReadMore} + + {/if} + {/if} +
+ {/if} + + {#if hasTags} +
+ {#each tags.slice(0, 10) as tag} + + {tag.name} + + {/each} +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/LastFmPlaceholder.svelte b/frontend/src/lib/components/LastFmPlaceholder.svelte new file mode 100644 index 0000000..170917d --- /dev/null +++ b/frontend/src/lib/components/LastFmPlaceholder.svelte @@ -0,0 +1,24 @@ + + +
+ + + +
diff --git a/frontend/src/lib/components/LibraryAlbumsCarousel.svelte b/frontend/src/lib/components/LibraryAlbumsCarousel.svelte new file mode 100644 index 0000000..2e4010b --- /dev/null +++ b/frontend/src/lib/components/LibraryAlbumsCarousel.svelte @@ -0,0 +1,63 @@ + + +{#if loading} +
+

+ + In your library +

+ +
+{:else if libraryReleases.length > 0} +
+

+ + In your library +

+ + {#each libraryReleases as release (release.id)} +
+ +
+ {/each} +
+
+{/if} diff --git a/frontend/src/lib/components/LibraryBadge.svelte b/frontend/src/lib/components/LibraryBadge.svelte new file mode 100644 index 0000000..f645b36 --- /dev/null +++ b/frontend/src/lib/components/LibraryBadge.svelte @@ -0,0 +1,88 @@ + + + + +{#if showDeleteModal} + { showDeleteModal = false; }} + /> +{/if} + +{#if showArtistRemovedModal} + { showArtistRemovedModal = false; }} + /> +{/if} diff --git a/frontend/src/lib/components/LibraryFilterBar.svelte b/frontend/src/lib/components/LibraryFilterBar.svelte new file mode 100644 index 0000000..bb019dc --- /dev/null +++ b/frontend/src/lib/components/LibraryFilterBar.svelte @@ -0,0 +1,137 @@ + + +
+
+ + onSearchInput?.()} + aria-label={ariaLabel} + /> + {#if isSearching} + + {/if} +
+ + {#if hasSecondRow} +
+ {#if hasSortControls} + + + {/if} + + {#if hasGenreFilter} + + {/if} + + {#if resultCount != null && !loading} + {resultCount} results + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/LibraryPage.svelte b/frontend/src/lib/components/LibraryPage.svelte new file mode 100644 index 0000000..0f6839b --- /dev/null +++ b/frontend/src/lib/components/LibraryPage.svelte @@ -0,0 +1,292 @@ + + +
+
+ {@render headerIcon()} +

{headerTitle}

+ {#if ctrl.stats} + {(ctrl.stats as { total_albums?: number }).total_albums ?? 0} albums + {/if} +
+ + {#if statsPanel} + {@render statsPanel()} + {/if} + + {#if ctrl.recentAlbums.length > 0} +
+

{recentLabel}

+
+ {#each ctrl.recentAlbums as album (a.getAlbumId(album))} + + {/each} +
+
+ {/if} + + {#if a.supportsFavorites && ctrl.favoriteAlbums.length > 0} +
+

Favorites

+
+ {#each ctrl.favoriteAlbums as album (a.getAlbumId(album))} + + {/each} +
+
+ {/if} + +
+

All Albums

+ +
+ + {#if ctrl.fetchError} + + {/if} + + {#if ctrl.loading} +
+ {#each Array(12) as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else} +
+ {#each ctrl.albums as album (a.getAlbumId(album))} +
ctrl.openDetail(album)} + onkeydown={(e) => + (e.key === 'Enter' || e.key === ' ') && + (e.preventDefault(), ctrl.openDetail(album))} + role="button" + tabindex="0" + > +
+ +
+ {@render cardTopLeftBadge(album)} +
+
+ {#if cardTopRightExtra} + {@render cardTopRightExtra(album)} + {:else if a.getAlbumYear(album)} +
{a.getAlbumYear(album)}
+ {/if} + {#if contextMenuBackdrop} +
+ +
+ {:else} +
+ +
+ {/if} +
+ {#if cardBottomLeft} +
+ {@render cardBottomLeft(album)} +
+ {/if} +
+ {#if a.supportsShuffle} + + {/if} + +
+
+ +
+

+ {a.getAlbumName(album)} +

+ {#if cardBodyExtra} + {@render cardBodyExtra(album)} + {:else} +

{a.getArtistName(album)}

+ {/if} +
+
+ {/each} +
+ + {#if ctrl.albums.length === 0} +
+
+ {@render emptyIcon()} +

{emptyTitle}

+

{emptyDescription}

+
+
+ {/if} + + {#if ctrl.albums.length < ctrl.total} +
+ +
+ {/if} + {/if} +
+ + + + diff --git a/frontend/src/lib/components/LocalFilesIcon.svelte b/frontend/src/lib/components/LocalFilesIcon.svelte new file mode 100644 index 0000000..8f01367 --- /dev/null +++ b/frontend/src/lib/components/LocalFilesIcon.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/NavidromeIcon.svelte b/frontend/src/lib/components/NavidromeIcon.svelte new file mode 100644 index 0000000..37d8f32 --- /dev/null +++ b/frontend/src/lib/components/NavidromeIcon.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/NowPlayingIndicator.svelte b/frontend/src/lib/components/NowPlayingIndicator.svelte new file mode 100644 index 0000000..29ac876 --- /dev/null +++ b/frontend/src/lib/components/NowPlayingIndicator.svelte @@ -0,0 +1,18 @@ + + +
+ + + +
diff --git a/frontend/src/lib/components/PageHeader.svelte b/frontend/src/lib/components/PageHeader.svelte new file mode 100644 index 0000000..7c3eb2f --- /dev/null +++ b/frontend/src/lib/components/PageHeader.svelte @@ -0,0 +1,68 @@ + + +
+
+
+
+
+
+
+

+ {@render title()} +

+

+ {subtitle} +

+
+
+ {#if isUpdating} + + + Updating... + + {:else if lastUpdated && !loading} + + {/if} + +
+
+
+
diff --git a/frontend/src/lib/components/Pagination.svelte b/frontend/src/lib/components/Pagination.svelte new file mode 100644 index 0000000..16a500b --- /dev/null +++ b/frontend/src/lib/components/Pagination.svelte @@ -0,0 +1,31 @@ + + +{#if total > 1} +
+ + {current} / {total} + +
+{/if} diff --git a/frontend/src/lib/components/Player.svelte b/frontend/src/lib/components/Player.svelte new file mode 100644 index 0000000..737c915 --- /dev/null +++ b/frontend/src/lib/components/Player.svelte @@ -0,0 +1,296 @@ + + +{#if playerStore.isPlayerVisible && playerStore.nowPlaying} +
+ + +
+
+ {#if nowPlayingCoverUrl && !coverImgError} + {playerStore.nowPlaying.albumName} { coverImgError = true; }} + /> + {:else} +
+ +
+ {/if} + {#if playerStore.isPlaying} + + {/if} +
+ {#if playerStore.nowPlaying.trackName} +

{playerStore.nowPlaying.trackName}

+

+ {#if isAlbumLinkable(playerStore.nowPlaying.albumId)} + {playerStore.nowPlaying.albumName} + {:else} + {playerStore.nowPlaying.albumName} + {/if} + {' — '} + {#if playerStore.nowPlaying.artistId} + {playerStore.nowPlaying.artistName} + {:else} + {playerStore.nowPlaying.artistName} + {/if} +

+ {:else} +

+ {#if isAlbumLinkable(playerStore.nowPlaying.albumId)} + {playerStore.nowPlaying.albumName} + {:else} + {playerStore.nowPlaying.albumName} + {/if} +

+

+ {#if playerStore.nowPlaying.artistId} + {playerStore.nowPlaying.artistName} + {:else} + {playerStore.nowPlaying.artistName} + {/if} +

+ {/if} + {#if playerStore.hasQueue} +

Track {playerStore.currentTrackNumber} of {playerStore.queueLength}

+ {/if} + {#if playerStore.playbackState === 'error'} +

Track unavailable

+ {/if} +
+
+ +
+
+ {#if playerStore.hasQueue} + + {/if} + + + + + + +
+ +
+ {formatTime(playerStore.progress)} + + {formatTime(playerStore.duration)} +
+ {#if !playerStore.isSeekable} +

Seeking unavailable for this stream format

+ {/if} +
+ +
+
+ +
+ +
+ +
+ + + + {#if scrobbleManager.enabled && scrobbleManager.status !== 'idle'} +
+ {#if scrobbleManager.status === 'scrobbled'} + + {:else if scrobbleManager.status === 'error'} + + {:else} + + + Tracking + + {/if} +
+ {/if} + + {#if playerStore.nowPlaying.sourceType === 'youtube'} + + +
+ +
+ {:else if playerStore.nowPlaying.sourceType === 'jellyfin'} + + {:else if playerStore.nowPlaying.sourceType === 'navidrome'} + + {:else if playerStore.nowPlaying.sourceType === 'local'} + + {/if} +
+
+
+ + (queueDrawerOpen = false)} /> + (eqPanelOpen = false)} /> +{/if} diff --git a/frontend/src/lib/components/PlaylistCard.svelte b/frontend/src/lib/components/PlaylistCard.svelte new file mode 100644 index 0000000..b274938 --- /dev/null +++ b/frontend/src/lib/components/PlaylistCard.svelte @@ -0,0 +1,188 @@ + + +
+ +
+
+ +
+
+
+

{playlist.name}

+

{subtitle}

+
+
+ +
+ + + + +
+ +
+
+
diff --git a/frontend/src/lib/components/PlaylistCard.svelte.spec.ts b/frontend/src/lib/components/PlaylistCard.svelte.spec.ts new file mode 100644 index 0000000..d3b2a5a --- /dev/null +++ b/frontend/src/lib/components/PlaylistCard.svelte.spec.ts @@ -0,0 +1,57 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import PlaylistCard from './PlaylistCard.svelte'; +import type { PlaylistSummary } from '$lib/api/playlists'; + +const basePlaylist: PlaylistSummary = { + id: 'pl-1', + name: 'My Playlist', + track_count: 5, + total_duration: 1234, + cover_urls: ['a.jpg', 'b.jpg'], + custom_cover_url: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z' +}; + +function renderCard(playlist: PlaylistSummary = basePlaylist) { + return render(PlaylistCard, { + props: { playlist } + } as Parameters>[1]); +} + +describe('PlaylistCard.svelte', () => { + it('renders playlist name', async () => { + renderCard(); + await expect.element(page.getByText('My Playlist')).toBeInTheDocument(); + }); + + it('renders track count and duration subtitle', async () => { + renderCard(); + await expect.element(page.getByText(/5 tracks/)).toBeInTheDocument(); + await expect.element(page.getByText(/20 min/)).toBeInTheDocument(); + }); + + it('renders singular "track" for 1 track', async () => { + const single: PlaylistSummary = { ...basePlaylist, track_count: 1 }; + renderCard(single); + await expect.element(page.getByText(/1 track(?!s)/)).toBeInTheDocument(); + }); + + it('links to the correct playlist detail page', async () => { + renderCard(); + const link = page.getByRole('link', { name: /Open My Playlist/ }); + await expect.element(link).toBeInTheDocument(); + expect(await link.element()).toHaveAttribute('href', '/playlists/pl-1'); + }); + + it('omits duration from subtitle when total_duration is null', async () => { + const noDuration: PlaylistSummary = { ...basePlaylist, total_duration: null }; + renderCard(noDuration); + const subtitle = page.getByText(/5 tracks/); + await expect.element(subtitle).toBeInTheDocument(); + const el = await subtitle.element(); + expect(el.textContent).not.toContain('·'); + }); +}); diff --git a/frontend/src/lib/components/PlaylistCardSkeleton.svelte b/frontend/src/lib/components/PlaylistCardSkeleton.svelte new file mode 100644 index 0000000..29d3737 --- /dev/null +++ b/frontend/src/lib/components/PlaylistCardSkeleton.svelte @@ -0,0 +1,9 @@ +
+
+
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/PlaylistMosaic.svelte b/frontend/src/lib/components/PlaylistMosaic.svelte new file mode 100644 index 0000000..001fee2 --- /dev/null +++ b/frontend/src/lib/components/PlaylistMosaic.svelte @@ -0,0 +1,115 @@ + + +{#snippet gridFallback()} +
+ +
+{/snippet} + +
+ {#if customCoverUrl} + Playlist cover + {:else if urls.length >= 4} +
+ {#each urls as url, i} + {#if imageErrors[i]} + {@render gridFallback()} + {:else} + handleImageError(i)} + /> + {/if} + {/each} +
+ {:else if urls.length === 3} +
+ {#each urls as url, i} + {#if imageErrors[i]} + {@render gridFallback()} + {:else} + handleImageError(i)} + /> + {/if} + {/each} + {@render gridFallback()} +
+ {:else if urls.length === 2} +
+ {#each urls as url, i} + {#if imageErrors[i]} + {@render gridFallback()} + {:else} + handleImageError(i)} + /> + {/if} + {/each} +
+ {:else if urls.length === 1} + {#if imageErrors[0]} +
+ +
+ {:else} + Playlist cover handleImageError(0)} + /> + {/if} + {:else} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/PlaylistMosaic.svelte.spec.ts b/frontend/src/lib/components/PlaylistMosaic.svelte.spec.ts new file mode 100644 index 0000000..3c0dc6f --- /dev/null +++ b/frontend/src/lib/components/PlaylistMosaic.svelte.spec.ts @@ -0,0 +1,85 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import PlaylistMosaic from './PlaylistMosaic.svelte'; + +function renderMosaic(props: Record = {}) { + return render(PlaylistMosaic, { + props + } as Parameters>[1]); +} + +describe('PlaylistMosaic.svelte', () => { + it('renders single img when customCoverUrl is provided', async () => { + renderMosaic({ customCoverUrl: 'https://example.com/custom.jpg' }); + const img = page.getByAltText('Playlist cover'); + await expect.element(img).toBeInTheDocument(); + expect(await img.element()).toHaveAttribute('src', 'https://example.com/custom.jpg'); + }); + + it('custom cover overrides coverUrls even when URLs are provided', async () => { + renderMosaic({ + customCoverUrl: 'https://example.com/custom.jpg', + coverUrls: ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg'] + }); + const images = page.getByAltText('Playlist cover'); + await expect.element(images).toBeInTheDocument(); + expect(await images.element()).toHaveAttribute('src', 'https://example.com/custom.jpg'); + }); + + it('renders 4 img elements in grid for 4+ URLs', async () => { + const { container } = renderMosaic({ + coverUrls: ['a.jpg', 'b.jpg', 'c.jpg', 'd.jpg', 'e.jpg'] + }); + const elements = container.querySelectorAll('img'); + expect(elements).toHaveLength(4); + }); + + it('renders 3 img elements plus placeholder for 3 URLs', async () => { + const { container } = renderMosaic({ coverUrls: ['a.jpg', 'b.jpg', 'c.jpg'] }); + const elements = container.querySelectorAll('img'); + expect(elements).toHaveLength(3); + }); + + it('renders 2 img elements for 2 URLs', async () => { + const { container } = renderMosaic({ coverUrls: ['a.jpg', 'b.jpg'] }); + const elements = container.querySelectorAll('img'); + expect(elements).toHaveLength(2); + }); + + it('renders Music icon for 0 URLs', async () => { + const { container } = renderMosaic({ coverUrls: [] }); + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + const images = container.querySelectorAll('img'); + expect(images).toHaveLength(0); + }); + + it('applies default size and rounded props', async () => { + const { container } = renderMosaic(); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.className).toContain('w-32'); + expect(wrapper.className).toContain('h-32'); + expect(wrapper.className).toContain('rounded-box'); + expect(wrapper.className).toContain('overflow-hidden'); + }); + + it('applies custom size and rounded props', async () => { + const { container } = renderMosaic({ size: 'w-10 h-10', rounded: 'rounded-md' }); + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.className).toContain('w-10'); + expect(wrapper.className).toContain('h-10'); + expect(wrapper.className).toContain('rounded-md'); + }); + + it('shows fallback placeholder when an image fails to load', async () => { + expect.assertions(2); + const { container } = renderMosaic({ coverUrls: ['bad.jpg'] }); + const img = container.querySelector('img') as HTMLImageElement; + expect(img).toBeTruthy(); + img.dispatchEvent(new Event('error')); + await new Promise((r) => setTimeout(r, 50)); + const svg = container.querySelector('svg'); + expect(svg).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/QueueDrawer.svelte b/frontend/src/lib/components/QueueDrawer.svelte new file mode 100644 index 0000000..fbd6de7 --- /dev/null +++ b/frontend/src/lib/components/QueueDrawer.svelte @@ -0,0 +1,346 @@ + + + + +{#if open} + + +
+
+
+ +

Queue

+ {#if queue.length > 0} + {upcomingCount} + {/if} +
+
+ {#if queue.length > 0} + + + {/if} + +
+
+ +
+ {#if queue.length === 0} +
+ +

Queue is empty

+

+ Add tracks from album pages or your library +

+
+ {:else} +
+ {#each displayOrder as queueIndex, displayPosition (queueIndex)} + {@const item = queue[queueIndex]} + {@const isCurrent = queueIndex === currentIndex} + {@const isPlayed = displayPosition < currentDisplayPosition} + {@const isReorderable = displayPosition > currentDisplayPosition} + {@const coverUrl = getCoverUrl(item.coverUrl, item.albumId)} + {#if displayPosition === currentDisplayPosition + 1} +
+ Up next +
+
+ {/if} +
handleDragStart(e, displayPosition)} + ondragover={(e) => handleDragOver(e, displayPosition)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, displayPosition)} + ondragend={handleDragEnd} + onclick={() => jumpToTrack(queueIndex)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jumpToTrack(queueIndex); } }} + role="button" + tabindex="0" + > + {#if isReorderable} + + {:else} +
+ {/if} + +
+ {#if coverUrl} + {item.albumName} + {:else} +
+ +
+ {/if} +
+ +
+

+ {item.trackName || item.albumName} +

+

{item.artistName}

+
+ + {#if item.duration} + {formatDuration(item.duration)} + {/if} + +
+ {#if item.sourceType === 'jellyfin'} + + + + {:else if item.sourceType === 'navidrome'} + + + + {:else if item.sourceType === 'local'} + + + + {:else if item.sourceType === 'youtube'} + YT + {/if} +
+ +
+ {#if isCurrent && playerStore.isPlaying} + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + {#if queue.length > 0} +
+ {upcomingCount} track{upcomingCount === 1 ? '' : 's'} upcoming +
+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/QueueDrawer.svelte.spec.ts b/frontend/src/lib/components/QueueDrawer.svelte.spec.ts new file mode 100644 index 0000000..2f25ccd --- /dev/null +++ b/frontend/src/lib/components/QueueDrawer.svelte.spec.ts @@ -0,0 +1,139 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import QueueDrawer from './QueueDrawer.svelte'; + +vi.mock('$lib/player/createSource', () => ({ + createPlaybackSource: vi.fn(() => ({ + type: 'local' as const, + load: vi.fn().mockResolvedValue(undefined), + play: vi.fn(), + pause: vi.fn(), + seekTo: vi.fn(), + setVolume: vi.fn(), + getCurrentTime: vi.fn(() => 0), + getDuration: vi.fn(() => 180), + isSeekable: vi.fn(() => true), + destroy: vi.fn(), + onStateChange: vi.fn(), + onReady: vi.fn(), + onError: vi.fn(), + onProgress: vi.fn(), + })), +})); + +import { playerStore } from '$lib/stores/player.svelte'; + +function renderDrawer(open: boolean, onclose: () => void) { + return render(QueueDrawer, { + props: { open, onclose } + } as Parameters>[1]); +} + +describe('QueueDrawer.svelte', () => { + beforeEach(() => { + playerStore.stop(); + }); + + it('shows empty state when queue is empty', async () => { + const onclose = vi.fn(); + renderDrawer(true, onclose); + await expect.element(page.getByText('Queue is empty')).toBeVisible(); + }); + + it('shows upcoming-only queue count when items exist', async () => { + playerStore.playQueue([ + { + trackSourceId: 'v1', trackName: 'Track A', artistName: 'Artist', + trackNumber: 1, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/1.mp3', + }, + { + trackSourceId: 'v2', trackName: 'Track B', artistName: 'Artist', + trackNumber: 2, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/2.mp3', + }, + ]); + const onclose = vi.fn(); + renderDrawer(true, onclose); + await expect.element(page.getByRole('heading', { name: 'Queue' })).toBeVisible(); + await expect.element(page.getByText('Track A')).toBeVisible(); + await expect.element(page.getByText('Track B')).toBeVisible(); + await expect.element(page.getByText('1 track upcoming')).toBeVisible(); + }); + + it('does not render content when closed', async () => { + const onclose = vi.fn(); + renderDrawer(false, onclose); + await expect.element(page.getByText('Queue')).not.toBeInTheDocument(); + }); + + it('clears upcoming tracks on clear click but keeps current track', async () => { + playerStore.playQueue([ + { + trackSourceId: 'v1', trackName: 'Track A', artistName: 'Artist', + trackNumber: 1, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/1.mp3', + }, + { + trackSourceId: 'v2', trackName: 'Track B', artistName: 'Artist', + trackNumber: 2, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/2.mp3', + }, + ]); + const onclose = vi.fn(); + renderDrawer(true, onclose); + const clearBtn = page.getByText('Clear'); + await expect.element(clearBtn).toBeVisible(); + await clearBtn.click(); + await expect.element(page.getByText('Queue')).not.toBeInTheDocument(); + expect(playerStore.queue).toHaveLength(1); + expect(playerStore.queue[0].trackSourceId).toBe('v1'); + expect(onclose).toHaveBeenCalledTimes(1); + }); + + it('has remove buttons for queue items', async () => { + playerStore.playQueue([ + { + trackSourceId: 'v1', trackName: 'Track A', artistName: 'Artist', + trackNumber: 1, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/1.mp3', + }, + { + trackSourceId: 'v2', trackName: 'Track B', artistName: 'Artist', + trackNumber: 2, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/2.mp3', + }, + ]); + const onclose = vi.fn(); + renderDrawer(true, onclose); + const removeButtons = page.getByLabelText('Remove from queue').all(); + expect(removeButtons.length).toBeGreaterThanOrEqual(1); + }); + + it('only allows reordering upcoming tracks', async () => { + playerStore.playQueue([ + { + trackSourceId: 'v1', trackName: 'Track A', artistName: 'Artist', + trackNumber: 1, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/1.mp3', + }, + { + trackSourceId: 'v2', trackName: 'Track B', artistName: 'Artist', + trackNumber: 2, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/2.mp3', + }, + { + trackSourceId: 'v3', trackName: 'Track C', artistName: 'Artist', + trackNumber: 3, albumId: 'a1', albumName: 'Album', + coverUrl: null, sourceType: 'local', streamUrl: 'http://test/3.mp3', + }, + ], 1); + const onclose = vi.fn(); + const view = renderDrawer(true, onclose); + await expect.element(page.getByText('Track C')).toBeVisible(); + + const enabledHandles = view.container.querySelectorAll('button[aria-label="Drag to reorder"]:not([disabled])'); + expect(enabledHandles).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/components/ReleaseList.svelte b/frontend/src/lib/components/ReleaseList.svelte new file mode 100644 index 0000000..0dbf0a3 --- /dev/null +++ b/frontend/src/lib/components/ReleaseList.svelte @@ -0,0 +1,124 @@ + + +
+
+ +
+ {#if !collapsed} +
+
+ {#each releases as rg} +
+ + +
+
{rg.title}
+
+ {#if rg.year}{rg.year}{/if} +
+
+
+
+ {#if libraryStore.isInLibrary(rg.id) || rg.in_library} + handleDeleted(rg, result)} + /> + {:else if !libraryStore.isInLibrary(rg.id) && (rg.requested || libraryStore.isRequested(rg.id))} + handleDeleted(rg, result)} + /> + {:else} + + {/if} +
+
+ {/each} +
+ {#if showLoadingIndicator} +
+ +
+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/RequestCard.svelte b/frontend/src/lib/components/RequestCard.svelte new file mode 100644 index 0000000..bd3d696 --- /dev/null +++ b/frontend/src/lib/components/RequestCard.svelte @@ -0,0 +1,381 @@ + + +
+ + +
+
+ + {#if isActive && activeItem.status === 'pending'} +
+ +
+ {/if} +
+ +
+

+ {item.album_title} +

+ {#if artistMbid} + + {item.artist_name} + + {:else} +

{item.artist_name}

+ {/if} +
+ + {formatRelativeTime(item.requested_at)} + + {#if item.year} + + {item.year} + {/if} + {#if isActive && activeItem.quality} + + {activeItem.quality} + {/if} + {#if isActive && activeItem.protocol} + + {activeItem.protocol} + {/if} +
+
+ +
+ + + {statusConfig.label} + + + {#if hasProgress} +
+
+
+
+
+ + {activeItem.progress?.toFixed(0) ?? 0}% + +
+
+ {#if activeItem.eta} + {formatEta(activeItem.eta)} + {/if} + {#if activeItem.eta && activeItem.size && activeItem.size_remaining != null} + + {/if} + {#if activeItem.size && activeItem.size_remaining != null} + + {formatSize(activeItem.size - (activeItem.size_remaining ?? 0))}/{formatSize(activeItem.size)} + + {/if} +
+
+ {:else if isActive && isFailedState && activeItem.error_message} + {activeItem.error_message} + {:else if !isActive && historyItem.completed_at} + {formatDate(historyItem.completed_at)} + {/if} + +
+ {#if isActive && hasStatusMessages} + + {/if} + {#if isActive} + {#if confirmingCancel} + Cancel? + + + {:else} + + {/if} + {:else} + {#if historyItem.status === 'failed' || historyItem.status === 'cancelled' || historyItem.status === 'incomplete'} + + {/if} + {#if historyItem.status === 'imported' && historyItem.in_library} + + {/if} + + {/if} +
+
+
+ + {#if showStatusDetails && hasStatusMessages} +
+
+ {#each activeItem.status_messages ?? [] as msg} + {#if msg.title} +
{msg.title}
+ {/if} + {#each msg.messages as message} +
• {message}
+ {/each} + {/each} +
+
+ {/if} +
+ +{#if showDeleteModal} + { showDeleteModal = false; }} + /> +{/if} + +{#if showArtistRemovedModal} + { showArtistRemovedModal = false; }} + /> +{/if} + + diff --git a/frontend/src/lib/components/SearchArtistCard.svelte b/frontend/src/lib/components/SearchArtistCard.svelte new file mode 100644 index 0000000..121fbf5 --- /dev/null +++ b/frontend/src/lib/components/SearchArtistCard.svelte @@ -0,0 +1,61 @@ + + + +
+ +
+ +
+

{artist.title}

+ +

+ {#if artist.disambiguation}{artist.disambiguation}{:else} {/if} +

+ +
+ {#if artist.release_group_count != null} + + {artist.release_group_count} release{artist.release_group_count !== 1 ? 's' : ''} + + {/if} + {#if artist.listen_count != null} + {#if enrichmentSource === 'lastfm'} + + Last.fm {formatListenCount(artist.listen_count, true)} + + {:else if enrichmentSource === 'listenbrainz'} + + LB {formatListenCount(artist.listen_count, true)} + + {:else} + + {formatListenCount(artist.listen_count, true)} + + {/if} + {/if} +
+
+
diff --git a/frontend/src/lib/components/SearchArtistCard.svelte.spec.ts b/frontend/src/lib/components/SearchArtistCard.svelte.spec.ts new file mode 100644 index 0000000..f2d82f1 --- /dev/null +++ b/frontend/src/lib/components/SearchArtistCard.svelte.spec.ts @@ -0,0 +1,101 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SearchArtistCard from './SearchArtistCard.svelte'; +import type { Artist, EnrichmentSource } from '$lib/types'; + +const baseArtist: Artist = { + title: 'Radiohead', + musicbrainz_id: 'a74b1b7f-71a5-4011-9441-d0b5e4122711', + in_library: false, + disambiguation: 'English rock band', + release_group_count: 9, + listen_count: 2500000, +}; + +function renderComponent(overrides: Partial<{ artist: Artist; enrichmentSource: EnrichmentSource }> = {}) { + return render(SearchArtistCard, { + props: { + artist: overrides.artist ?? baseArtist, + enrichmentSource: overrides.enrichmentSource ?? 'none', + }, + } as Parameters>[1]); +} + +describe('SearchArtistCard.svelte', () => { + it('should display the artist name', async () => { + renderComponent(); + await expect.element(page.getByText('Radiohead')).toBeInTheDocument(); + }); + + it('should display release count badge', async () => { + renderComponent(); + await expect.element(page.getByText('9 releases')).toBeInTheDocument(); + }); + + it('should show Last.fm branded badge when source is lastfm', async () => { + renderComponent({ enrichmentSource: 'lastfm' }); + + const badge = page.getByTitle('Last.fm listeners'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText(/Last\.fm/)).toBeInTheDocument(); + }); + + it('should show ListenBrainz branded badge when source is listenbrainz', async () => { + renderComponent({ enrichmentSource: 'listenbrainz' }); + + const badge = page.getByTitle('ListenBrainz plays'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText(/LB/)).toBeInTheDocument(); + }); + + it('should show generic badge when source is none', async () => { + renderComponent({ enrichmentSource: 'none' }); + + const badge = page.getByTitle('Plays'); + await expect.element(badge).toBeInTheDocument(); + + await expect.element(page.getByText(/Last\.fm/)).not.toBeInTheDocument(); + await expect.element(page.getByText(/\bLB\b/)).not.toBeInTheDocument(); + }); + + it('should not render listen count badge when listen_count is null', async () => { + renderComponent({ + artist: { ...baseArtist, listen_count: null }, + enrichmentSource: 'lastfm', + }); + + await expect + .element(page.getByTitle('Last.fm listeners')) + .not.toBeInTheDocument(); + }); + + it('should render zero listen count as "0"', async () => { + renderComponent({ + artist: { ...baseArtist, listen_count: 0 }, + enrichmentSource: 'lastfm', + }); + + const badge = page.getByTitle('Last.fm listeners'); + await expect.element(badge).toBeInTheDocument(); + await expect.element(page.getByText('Last.fm 0')).toBeInTheDocument(); + }); + + it('should display formatted count for large numbers', async () => { + renderComponent({ enrichmentSource: 'lastfm' }); + + await expect.element(page.getByText('Last.fm 2.5M')).toBeInTheDocument(); + }); + + it('should display disambiguation when present', async () => { + renderComponent(); + await expect.element(page.getByText('English rock band')).toBeInTheDocument(); + }); + + it('should singular release for count of 1', async () => { + renderComponent({ + artist: { ...baseArtist, release_group_count: 1 }, + }); + await expect.element(page.getByText('1 release')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/SearchSuggestions.svelte b/frontend/src/lib/components/SearchSuggestions.svelte new file mode 100644 index 0000000..16b75ca --- /dev/null +++ b/frontend/src/lib/components/SearchSuggestions.svelte @@ -0,0 +1,281 @@ + + +
+
+ +
+ + {#if showDropdown && (suggestions.length > 0 || loading)} +
    + {#each suggestions as result, i (result.musicbrainz_id)} +
  • handleSelect(result)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') handleSelect(result); + }} + tabindex="-1" + > +
    +
    + {result.title} { + const target = e.currentTarget as HTMLImageElement; + target.style.display = 'none'; + }} + /> +
    +
    +
    +
    {result.title}
    +
    + {#if result.type === 'album' && result.artist} + {result.artist} + {:else if result.type === 'artist'} + Artist + {/if} + {#if result.year} + · {result.year} + {/if} + {#if result.disambiguation} + ({result.disambiguation}) + {/if} +
    +
    +
    + + {result.type === 'artist' ? 'Artist' : 'Album'} + + {#if result.in_library} + In Library + {/if} + {#if result.requested} + Requested + {/if} +
    +
  • + {/each} + + {#if suggestions.length > 0} +
  • + +
  • + {/if} + + {#if loading && suggestions.length === 0} +
  • + +
  • + {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/SearchSuggestions.svelte.spec.ts b/frontend/src/lib/components/SearchSuggestions.svelte.spec.ts new file mode 100644 index 0000000..10e65f9 --- /dev/null +++ b/frontend/src/lib/components/SearchSuggestions.svelte.spec.ts @@ -0,0 +1,303 @@ +import { page, userEvent } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SearchSuggestions from './SearchSuggestions.svelte'; +import type { SuggestResult } from '$lib/types'; + +const mockResults: SuggestResult[] = [ + { + type: 'artist', + title: 'Muse', + artist: null, + year: null, + musicbrainz_id: 'artist-1', + in_library: true, + requested: false, + score: 95 + }, + { + type: 'album', + title: 'Origin of Symmetry', + artist: 'Muse', + year: 2001, + musicbrainz_id: 'album-1', + in_library: false, + requested: true, + score: 90 + } +]; + +function makeResponse(body: unknown, status = 200): Response { + const json = JSON.stringify(body); + return new Response(json, { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +function mockFetchSuccess(results: SuggestResult[] = mockResults) { + return vi.fn().mockImplementation(() => Promise.resolve(makeResponse({ results }))); +} + +function mockFetchError() { + return vi.fn().mockImplementation(() => + Promise.resolve(makeResponse({ error: 'Internal Server Error' }, 500)) + ); +} + +function renderComponent(props: Record = {}) { + const options = { + props: { query: '', onSearch: vi.fn(), onSelect: vi.fn(), ...props } + }; + return render( + SearchSuggestions, + options as unknown as Parameters>[1] + ); +} + +describe('SearchSuggestions.svelte', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + + it('should render the search input', async () => { + renderComponent(); + + const input = page.getByRole('searchbox'); + await expect.element(input).toBeInTheDocument(); + }); + + it('should not show dropdown for short input', async () => { + renderComponent({ query: 'a' }); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it('should show dropdown with suggestions after typing', async () => { + globalThis.fetch = mockFetchSuccess(); + + renderComponent(); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).toBeInTheDocument(); + + const options = page.getByRole('option'); + await expect.element(options.first()).toBeInTheDocument(); + }); + + it('should call onSelect when clicking a suggestion', async () => { + globalThis.fetch = mockFetchSuccess(); + + const onSelect = vi.fn(); + renderComponent({ onSelect }); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const firstOption = page.getByRole('option').first(); + await firstOption.click(); + + expect(onSelect).toHaveBeenCalledWith(mockResults[0]); + }); + + it('should call onSearch on form submit (Enter)', async () => { + const onSearch = vi.fn(); + renderComponent({ query: 'test', onSearch }); + + const input = page.getByRole('searchbox'); + await input.click(); + await userEvent.keyboard('{Enter}'); + + expect(onSearch).toHaveBeenCalled(); + }); + + it('should hide dropdown on Escape', async () => { + globalThis.fetch = mockFetchSuccess(); + + renderComponent(); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).toBeInTheDocument(); + + await input.click(); + await userEvent.keyboard('{Escape}'); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it('should hide dropdown on fetch error', async () => { + globalThis.fetch = mockFetchError(); + + renderComponent(); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).not.toBeInTheDocument(); + }); + + it('should show View all results link', async () => { + globalThis.fetch = mockFetchSuccess(); + + const onSearch = vi.fn(); + renderComponent({ onSearch }); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const viewAll = page.getByText('View all results'); + await expect.element(viewAll).toBeInTheDocument(); + + await viewAll.click(); + expect(onSearch).toHaveBeenCalled(); + }); + + it('should debounce and only fire one fetch for rapid input', async () => { + const fetchSpy = mockFetchSuccess(); + globalThis.fetch = fetchSpy; + + renderComponent(); + + const input = page.getByRole('searchbox'); + await input.fill('m'); + await vi.advanceTimersByTimeAsync(100); + await input.fill('mu'); + await vi.advanceTimersByTimeAsync(100); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('should use custom id for listbox', async () => { + globalThis.fetch = mockFetchSuccess(); + + renderComponent({ id: 'custom-test' }); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).toHaveAttribute('id', 'custom-test-listbox'); + }); + + it('should ignore stale responses when a newer request is pending', async () => { + let callCount = 0; + globalThis.fetch = vi.fn().mockImplementation(() => { + callCount++; + const currentCall = callCount; + if (currentCall === 1) { + return new Promise((resolve) => + setTimeout( + () => + resolve( + makeResponse({ + results: [ + { + type: 'artist' as const, + title: 'StaleResult', + musicbrainz_id: 'stale-1', + in_library: false, + score: 50 + } + ] + }) + ), + 300 + ) + ); + } + return Promise.resolve( + makeResponse({ + results: [ + { + type: 'artist' as const, + title: 'FreshResult', + musicbrainz_id: 'fresh-1', + in_library: false, + score: 80 + } + ] + }) + ); + }); + + renderComponent(); + + const input = page.getByRole('searchbox'); + + await input.fill('ab'); + await vi.advanceTimersByTimeAsync(600); + + await input.fill('abc'); + await vi.advanceTimersByTimeAsync(600); + + await vi.advanceTimersByTimeAsync(700); + + const stale = page.getByText('StaleResult'); + await expect.element(stale).not.toBeInTheDocument(); + + const fresh = page.getByText('FreshResult'); + await expect.element(fresh).toBeInTheDocument(); + }); + + it('should render combobox with correct ARIA attributes', async () => { + globalThis.fetch = mockFetchSuccess(); + + renderComponent({ id: 'aria-test' }); + + const combobox = page.getByRole('combobox'); + await expect.element(combobox).toHaveAttribute('aria-haspopup', 'listbox'); + await expect.element(combobox).toHaveAttribute('aria-expanded', 'false'); + + const input = page.getByRole('searchbox'); + await expect.element(input).toHaveAttribute('aria-autocomplete', 'list'); + await expect.element(input).toHaveAttribute('aria-controls', 'aria-test-listbox'); + + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + await expect.element(combobox).toHaveAttribute('aria-expanded', 'true'); + + const options = page.getByRole('option'); + await expect.element(options.first()).toHaveAttribute('aria-selected', 'false'); + }); + + it('should hide dropdown on click outside', async () => { + globalThis.fetch = mockFetchSuccess(); + + renderComponent(); + + const input = page.getByRole('searchbox'); + await input.fill('mus'); + await vi.advanceTimersByTimeAsync(700); + + const listbox = page.getByRole('listbox'); + await expect.element(listbox).toBeInTheDocument(); + + await document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); + + await expect.element(listbox).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/SearchTopResult.svelte b/frontend/src/lib/components/SearchTopResult.svelte new file mode 100644 index 0000000..d5bf5d2 --- /dev/null +++ b/frontend/src/lib/components/SearchTopResult.svelte @@ -0,0 +1,91 @@ + + + +
+ +
+ +
+ {#if resultType === 'artist' && artist} +
+ +
+ {:else if album} +
+ {#if album.cover_url} + {album.title} + {:else} +
+ + + +
+ {/if} +
+ {/if} + +
+ + Top {resultType} + +

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/SectionDivider.svelte b/frontend/src/lib/components/SectionDivider.svelte new file mode 100644 index 0000000..cf406d1 --- /dev/null +++ b/frontend/src/lib/components/SectionDivider.svelte @@ -0,0 +1,22 @@ + + +
+ {#if icon} + + {@render icon()} + + {/if} + + {label} + +
+
diff --git a/frontend/src/lib/components/ServicePromptCard.svelte b/frontend/src/lib/components/ServicePromptCard.svelte new file mode 100644 index 0000000..56bc758 --- /dev/null +++ b/frontend/src/lib/components/ServicePromptCard.svelte @@ -0,0 +1,101 @@ + + +
+
+ +
+ +
+
+

{prompt.title}

+

+ {prompt.description} +

+
+ {#each prompt.features as feature} + {feature} + {/each} +
+
+ +
+
diff --git a/frontend/src/lib/components/SidebarServiceHint.svelte b/frontend/src/lib/components/SidebarServiceHint.svelte new file mode 100644 index 0000000..c073f04 --- /dev/null +++ b/frontend/src/lib/components/SidebarServiceHint.svelte @@ -0,0 +1,30 @@ + + +
  • + +
    + {@render icon()} + + + +
    + {label} +
    +
  • diff --git a/frontend/src/lib/components/SimilarArtistsCarousel.svelte b/frontend/src/lib/components/SimilarArtistsCarousel.svelte new file mode 100644 index 0000000..f7494ea --- /dev/null +++ b/frontend/src/lib/components/SimilarArtistsCarousel.svelte @@ -0,0 +1,65 @@ + + +
    +

    Similar Artists

    + + {#if loading} + + {:else if !configured} +
    +

    Connect a music service in Settings to see similar artists

    + Configure +
    + {:else if artists.length === 0} +
    +

    No similar artists found

    +
    + {:else} + + {#each artists as artist} + +
    +
    + +
    + {#if artist.in_library} +
    + +
    + {/if} +
    +

    {artist.name}

    +
    + {/each} +
    + {/if} +
    diff --git a/frontend/src/lib/components/SourceAlbumModal.svelte b/frontend/src/lib/components/SourceAlbumModal.svelte new file mode 100644 index 0000000..32239d9 --- /dev/null +++ b/frontend/src/lib/components/SourceAlbumModal.svelte @@ -0,0 +1,556 @@ + + +{#if open && album} + + + + + +{/if} + + diff --git a/frontend/src/lib/components/SourcePickerDropdown.svelte b/frontend/src/lib/components/SourcePickerDropdown.svelte new file mode 100644 index 0000000..ba8a548 --- /dev/null +++ b/frontend/src/lib/components/SourcePickerDropdown.svelte @@ -0,0 +1,94 @@ + + + + +{#snippet sourceIcon(source: string, cls: string)} + {#if source === 'jellyfin'} + + {:else if source === 'navidrome'} + + {:else if source === 'local'} + + {:else if source === 'youtube'} + + {/if} +{/snippet} + +{#if hasMultiple} + +{:else} + + {@render sourceIcon(currentSource, 'h-3 w-3')} + {getSourceLabel(currentSource)} + +{/if} diff --git a/frontend/src/lib/components/SourceSwitcher.svelte b/frontend/src/lib/components/SourceSwitcher.svelte new file mode 100644 index 0000000..17cd957 --- /dev/null +++ b/frontend/src/lib/components/SourceSwitcher.svelte @@ -0,0 +1,58 @@ + + +{#if showSwitcher} +
    + + +
    +{/if} diff --git a/frontend/src/lib/components/SourceSwitcher.svelte.spec.ts b/frontend/src/lib/components/SourceSwitcher.svelte.spec.ts new file mode 100644 index 0000000..e98e083 --- /dev/null +++ b/frontend/src/lib/components/SourceSwitcher.svelte.spec.ts @@ -0,0 +1,115 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SourceSwitcher from './SourceSwitcher.svelte'; +import { integrationStore } from '$lib/stores/integration'; +import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource'; +import { PAGE_SOURCE_KEYS } from '$lib/constants'; + +describe('SourceSwitcher.svelte', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ source: 'listenbrainz' }), + }); + integrationStore.reset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + integrationStore.reset(); + localStorage.removeItem(PAGE_SOURCE_KEYS.home); + }); + + it('renders nothing when only ListenBrainz is enabled', async () => { + integrationStore.setStatus({ listenbrainz: true, lastfm: false }); + const { container } = render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + await vi.waitFor(() => { + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + }); + + it('renders nothing when only Last.fm is enabled', async () => { + integrationStore.setStatus({ listenbrainz: false, lastfm: true }); + const { container } = render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + await vi.waitFor(() => { + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + }); + + it('renders nothing when neither service is enabled', async () => { + integrationStore.setStatus({ listenbrainz: false, lastfm: false }); + const { container } = render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + await vi.waitFor(() => { + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(0); + }); + }); + + it('renders switcher buttons when both services are enabled', async () => { + integrationStore.setStatus({ listenbrainz: true, lastfm: true }); + render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + + const lbBtn = page.getByRole('button', { name: 'ListenBrainz' }); + const lfmBtn = page.getByRole('button', { name: 'Last.fm' }); + + await expect.element(lbBtn).toBeInTheDocument(); + await expect.element(lfmBtn).toBeInTheDocument(); + }); + + it('defaults to ListenBrainz as active source', async () => { + integrationStore.setStatus({ listenbrainz: true, lastfm: true }); + render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + + const lbBtn = page.getByRole('button', { name: 'ListenBrainz' }); + await vi.waitFor(async () => { + const el = lbBtn.element(); + expect(el.className).toContain('btn-primary'); + }); + }); + + it('calls onSourceChange when switching source', async () => { + integrationStore.setStatus({ listenbrainz: true, lastfm: true }); + const onSourceChange = vi.fn<(source: MusicSource) => void>(); + render(SourceSwitcher, { + props: { pageKey: 'home', onSourceChange }, + } as unknown as Parameters>[1]); + + const lfmBtn = page.getByRole('button', { name: 'Last.fm' }); + await lfmBtn.click(); + + await vi.waitFor(() => { + expect(onSourceChange).toHaveBeenCalledWith('lastfm'); + }); + }); + + it('updates page source when switching source', async () => { + integrationStore.setStatus({ listenbrainz: true, lastfm: true }); + + render(SourceSwitcher, { + props: { pageKey: 'home' }, + } as Parameters>[1]); + + const lfmBtn = page.getByRole('button', { name: 'Last.fm' }); + await lfmBtn.click(); + + await vi.waitFor(() => { + expect(musicSourceStore.getPageSource('home')).toBe('lastfm'); + }); + }); +}); diff --git a/frontend/src/lib/components/TimeRangeCard.svelte b/frontend/src/lib/components/TimeRangeCard.svelte new file mode 100644 index 0000000..918d370 --- /dev/null +++ b/frontend/src/lib/components/TimeRangeCard.svelte @@ -0,0 +1,210 @@ + + + + {#if variant === 'featured'} +
    + {#if itemType === 'album'} + + {:else} + + {/if} +
    +
    + #{rank} + Most Popular + {#if item.in_library} +
    + + In Library +
    + {/if} +
    + {#if isAlbum(item) && item.mbid && item.in_library} + + {/if} +
    +

    {item.name}

    + {#if isAlbum(item) && item.artist_name} +

    {item.artist_name}

    + {/if} + {#if item.listen_count !== null && item.listen_count !== undefined} +

    🎧 {formatListenCount(item.listen_count)}

    + {/if} +
    + {#if !item.mbid} + + {/if} +
    + {:else} + {#if itemType === 'album'} +
    + + {#if item.in_library} +
    + +
    + {/if} + {#if item.mbid && item.in_library} + + {/if} +
    + #{rank} +
    + {#if !item.mbid} + + {/if} +
    + {:else} +
    + + {#if item.in_library} +
    + +
    + {/if} +
    + #{rank} +
    + {#if !item.mbid} + + {/if} +
    + {/if} +
    +

    + {item.name} +

    + {#if isAlbum(item) && item.artist_name} +

    {item.artist_name}

    + {/if} + {#if item.listen_count !== null && item.listen_count !== undefined} +

    {formatListenCount(item.listen_count)}

    + {/if} +
    + {/if} +
    diff --git a/frontend/src/lib/components/TimeRangeView.svelte b/frontend/src/lib/components/TimeRangeView.svelte new file mode 100644 index 0000000..8111b4d --- /dev/null +++ b/frontend/src/lib/components/TimeRangeView.svelte @@ -0,0 +1,375 @@ + + +
    +
    + +
    +

    {title}

    +

    {subtitle}

    +
    +
    + + {#if loading} +
    + +
    + {:else if !overviewData} +
    + {#if errorIcon} + + {:else} + + {/if} +

    Unable to load {itemType}s

    +

    Please try again later.

    + +
    + {:else} +
    + {#each timeRanges as range} + {@const featured = getFeaturedForRange(range.key)} + {@const items = getItemsForRange(range.key)} + {@const isExpanded = expandedRange === range.key} + +
    + + + {#if !isExpanded} +
    + {#if featured} + {@const featuredHref = getItemHref(featured)} + + {/if} + +
    + {#each items.slice(0, 8) as item, idx} + {@const rank = idx + 2} + {@const itemHref = getItemHref(item)} + + {/each} +
    +
    + {:else} + {#if loadingMore && !expandedData} +
    + +
    + {:else if expandedData} +
    + {#each expandedData.items as item, idx} + {@const rank = idx + 1} + {@const itemHref = getItemHref(item)} + + {/each} +
    + + {#if expandedData.has_more} +
    + +
    + {#if paginationError} +

    {paginationError}

    + {/if} + {/if} + {/if} + {/if} +
    + {/each} +
    + {/if} +
    diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..fbb34d5 --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,46 @@ + + +{#if show} +
    +
    + {#if type === 'success'} + + {:else if type === 'error'} + + {:else if type === 'info'} + + {:else if type === 'warning'} + + {/if} + {message} +
    +
    +{/if} diff --git a/frontend/src/lib/components/TopAlbumsList.svelte b/frontend/src/lib/components/TopAlbumsList.svelte new file mode 100644 index 0000000..ab9a8ff --- /dev/null +++ b/frontend/src/lib/components/TopAlbumsList.svelte @@ -0,0 +1,185 @@ + + +
    +

    Popular Albums

    + + {#if loading} +
    + {#each Array(10) as _} +
    +
    +
    +
    +
    +
    +
    + {/each} +
    + {:else if !configured} +
    +
    +

    Connect a music service to see popular albums

    + Configure +
    +
    + {:else if albums.length === 0} +
    +

    No album data available

    +
    + {:else} + + {/if} +
    diff --git a/frontend/src/lib/components/TopSongsList.svelte b/frontend/src/lib/components/TopSongsList.svelte new file mode 100644 index 0000000..c82ef0a --- /dev/null +++ b/frontend/src/lib/components/TopSongsList.svelte @@ -0,0 +1,185 @@ + + +
    +

    Popular Songs

    + + {#if loading} +
    + {#each Array(10) as _, i} +
    +
    +
    +
    +
    +
    +
    +
    + {/each} +
    + {:else if !configured} +
    +
    +

    Connect a music service to see popular songs

    + Configure +
    +
    + {:else if songs.length === 0} +
    +

    No song data available

    +
    + {:else} +
    + {#each songs as song, i} + handlePlay(song)} + /> + {/each} +
    + {/if} +
    diff --git a/frontend/src/lib/components/TrackPlayButton.svelte b/frontend/src/lib/components/TrackPlayButton.svelte new file mode 100644 index 0000000..eebfce5 --- /dev/null +++ b/frontend/src/lib/components/TrackPlayButton.svelte @@ -0,0 +1,105 @@ + + +{#if !hasLink && !apiConfigured} +{:else if generating} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/TrackPreviewButton.svelte b/frontend/src/lib/components/TrackPreviewButton.svelte new file mode 100644 index 0000000..5278eda --- /dev/null +++ b/frontend/src/lib/components/TrackPreviewButton.svelte @@ -0,0 +1,154 @@ + + +
    + +
    diff --git a/frontend/src/lib/components/TrackRow.svelte b/frontend/src/lib/components/TrackRow.svelte new file mode 100644 index 0000000..b166455 --- /dev/null +++ b/frontend/src/lib/components/TrackRow.svelte @@ -0,0 +1,112 @@ + + +{#if hasAlbum} +
    + {#if canPlay} + + {:else if previewEnabled} + + {position} + + + {:else} + + {position} + + + {/if} + + +
    + +
    + +
    +

    {song.title}

    +

    {song.release_name || ''}

    +
    +
    +
    +{:else} +
    + {#if canPlay} + + {:else if previewEnabled} + + {position} + + + {:else} + {position} + {/if} + + {#if isLastfmNoAlbum} + + {:else} +
    + +
    + {/if} + +
    +

    {song.title}

    +

    +
    +
    +{/if} diff --git a/frontend/src/lib/components/TrackSourceButton.svelte b/frontend/src/lib/components/TrackSourceButton.svelte new file mode 100644 index 0000000..3dcf08e --- /dev/null +++ b/frontend/src/lib/components/TrackSourceButton.svelte @@ -0,0 +1,33 @@ + + +{#if available} + +{:else} + + — + +{/if} diff --git a/frontend/src/lib/components/ViewMoreAlbumCard.svelte b/frontend/src/lib/components/ViewMoreAlbumCard.svelte new file mode 100644 index 0000000..c476b0f --- /dev/null +++ b/frontend/src/lib/components/ViewMoreAlbumCard.svelte @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/lib/components/ViewMoreArtistCard.svelte b/frontend/src/lib/components/ViewMoreArtistCard.svelte new file mode 100644 index 0000000..1d04114 --- /dev/null +++ b/frontend/src/lib/components/ViewMoreArtistCard.svelte @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/lib/components/WeeklyExploration.svelte b/frontend/src/lib/components/WeeklyExploration.svelte new file mode 100644 index 0000000..1c258b0 --- /dev/null +++ b/frontend/src/lib/components/WeeklyExploration.svelte @@ -0,0 +1,98 @@ + + +
    +
    +
    + +

    Weekly Exploration

    +
    + + {#if formattedDate} + + {formattedDate} + + {/if} + + {#if section.source_url} + + ListenBrainz + + + {/if} +
    + + + {#each section.tracks as track, i} + + {/each} + +
    diff --git a/frontend/src/lib/components/WeeklyExplorationCard.svelte b/frontend/src/lib/components/WeeklyExplorationCard.svelte new file mode 100644 index 0000000..6759fed --- /dev/null +++ b/frontend/src/lib/components/WeeklyExplorationCard.svelte @@ -0,0 +1,163 @@ + + +
    + {#if albumHref} + + {#if track.cover_url && !imgError} + {track.album_name { imgError = true; }} + /> + {:else} +
    + +
    + {/if} + + {#if track.duration_ms} + + {formatDuration(track.duration_ms)} + + {/if} +
    + {:else} +
    + {#if track.cover_url && !imgError} + {track.album_name { imgError = true; }} + /> + {:else} +
    + +
    + {/if} + + {#if track.duration_ms} + + {formatDuration(track.duration_ms)} + + {/if} +
    + {/if} + +
    +

    + {track.title} +

    + {#if artistHref} + + {track.artist_name} + + {:else} +

    {track.artist_name}

    + {/if} + {#if track.album_name} + {#if albumHref} + + + {track.album_name} + + {:else} +

    + + {track.album_name} +

    + {/if} + {/if} +
    + +
    + +
    + + + +
    +
    + + {#if showQuota && quotaInfo} +
    + + {quotaInfo.used}/{quotaInfo.limit} used today + +
    + {:else} +
    + {/if} +
    diff --git a/frontend/src/lib/components/YouTubeDetailModal.svelte b/frontend/src/lib/components/YouTubeDetailModal.svelte new file mode 100644 index 0000000..5730a66 --- /dev/null +++ b/frontend/src/lib/components/YouTubeDetailModal.svelte @@ -0,0 +1,449 @@ + + +{#if open && link} + + + + + +{/if} diff --git a/frontend/src/lib/components/YouTubeIcon.svelte b/frontend/src/lib/components/YouTubeIcon.svelte new file mode 100644 index 0000000..df4d4e2 --- /dev/null +++ b/frontend/src/lib/components/YouTubeIcon.svelte @@ -0,0 +1,14 @@ + + + + + + diff --git a/frontend/src/lib/components/YouTubeLinkModal.svelte b/frontend/src/lib/components/YouTubeLinkModal.svelte new file mode 100644 index 0000000..d4964a5 --- /dev/null +++ b/frontend/src/lib/components/YouTubeLinkModal.svelte @@ -0,0 +1,285 @@ + + +{#if open} + + + + +{/if} diff --git a/frontend/src/lib/components/YouTubePlayer.svelte b/frontend/src/lib/components/YouTubePlayer.svelte new file mode 100644 index 0000000..9b278ca --- /dev/null +++ b/frontend/src/lib/components/YouTubePlayer.svelte @@ -0,0 +1,13 @@ + + +
    +
    +
    diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png new file mode 100644 index 0000000..e4e97aa Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-calls-onBeforeJump-when-provided-and-a-letter-is-clicked-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png new file mode 100644 index 0000000..4f90423 Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-disables-letters-without-content-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png new file mode 100644 index 0000000..f72190d Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/AlphabetJumpNav.svelte.spec.ts/AlphabetJumpNav-svelte-renders-all-27-letter-buttons--A-Z------1.png differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png new file mode 100644 index 0000000..f0c706e Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-appends--medium-suffix-for-lg-size-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png new file mode 100644 index 0000000..855e9ef Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-CDN-URL-with-referrerpolicy-when-remoteUrl-is-set-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png new file mode 100644 index 0000000..37d4fad Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-for-artist-when-remoteUrl-is-null-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png new file mode 100644 index 0000000..37d4fad Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-renders-proxy-URL-when-remoteUrl-is-null-for-artist-1.png differ diff --git a/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png new file mode 100644 index 0000000..f0c706e Binary files /dev/null and b/frontend/src/lib/components/__screenshots__/BaseImage.svelte.spec.ts/BaseImage-svelte---remoteUrl-uses-original-URL-for-full-size-1.png differ diff --git a/frontend/src/lib/components/settings/SettingsAdvanced.svelte b/frontend/src/lib/components/settings/SettingsAdvanced.svelte new file mode 100644 index 0000000..2c09aff --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsAdvanced.svelte @@ -0,0 +1,155 @@ + + +
    +
    +

    Advanced Settings

    +

    Control cache freshness, background work, and image loading.

    +
    + + {#if form.loading} +
    + +
    + {:else if form.data} + {#if form.message} +
    + {#if form.messageType === 'success'} + + {:else} + + {/if} + {form.message} +
    + {/if} + + + + + + + + + + + + + + + + + + + + + +
    + + +
    + {/if} +
    diff --git a/frontend/src/lib/components/settings/SettingsAudioDB.svelte b/frontend/src/lib/components/settings/SettingsAudioDB.svelte new file mode 100644 index 0000000..890e150 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsAudioDB.svelte @@ -0,0 +1,108 @@ + + +
    + AudioDB provides rich artist and album images (fanart, banners, logos, CD art). The + free tier allows 30 requests/minute. A premium API key removes this limit. +
    + +
    +
    + AudioDB Enabled + +
    + +
    + API Key + +

    + Default: 123 (free tier, 30 req/min). Premium keys available at theaudiodb.com +

    +
    + +
    + Name Search Fallback + +
    + +
    + Direct Remote Images + +

    + When enabled, your browser loads images directly from TheAudioDB's CDN (faster). + Disable to route all images through MusicSeerr (more private). +

    +
    + + + + + + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsBackendCache.svelte b/frontend/src/lib/components/settings/SettingsBackendCache.svelte new file mode 100644 index 0000000..a00b141 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsBackendCache.svelte @@ -0,0 +1,127 @@ + + +
    + Server-side TTLs control API/data cache freshness for all clients. Lower values fetch + from upstream services more often; higher values reduce backend/API load. +
    +
    + + + + + + + + + + + + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsCache.svelte b/frontend/src/lib/components/settings/SettingsCache.svelte new file mode 100644 index 0000000..4a4d5cc --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsCache.svelte @@ -0,0 +1,157 @@ + + +
    +
    +

    Cache Management

    +

    + View cache usage and clear stored data. Frequently used items stay in memory, and the rest stay on disk. +

    + + {#if loading} +
    + +
    + {:else if cacheStats} +
    +
    +
    Memory Cache
    +
    {cacheStats.memory_entries}
    +
    {cacheStats.memory_size_mb} MB (hot items)
    +
    + +
    +
    Disk Metadata
    +
    {cacheStats.disk_metadata_count}
    +
    {cacheStats.disk_metadata_albums} albums, {cacheStats.disk_metadata_artists} artists
    +
    + +
    +
    Cover Images
    +
    {cacheStats.disk_cover_count}
    +
    {cacheStats.disk_cover_size_mb} MB
    +
    + +
    +
    Library
    +
    {(cacheStats.library_db_artist_count ?? 0) + (cacheStats.library_db_album_count ?? 0)}
    +
    {cacheStats.library_db_artist_count ?? 0} artists, {cacheStats.library_db_album_count ?? 0} albums
    +
    + +
    +
    AudioDB Cache
    +
    {(cacheStats.disk_audiodb_artist_count ?? 0) + (cacheStats.disk_audiodb_album_count ?? 0)}
    +
    {cacheStats.disk_audiodb_artist_count ?? 0} artists, {cacheStats.disk_audiodb_album_count ?? 0} albums
    +
    +
    + +
    +

    Clear Cache

    +
    + + + + + + +
    +
    + + {#if message} +
    + {message} +
    + {/if} + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsFrontendCache.svelte b/frontend/src/lib/components/settings/SettingsFrontendCache.svelte new file mode 100644 index 0000000..5bdadce --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsFrontendCache.svelte @@ -0,0 +1,86 @@ + + +
    + Choose how long pages stay fresh in your browser. Lower values update sooner. Higher values feel faster when you come back. +
    +
    + + + + + + + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsJellyfin.svelte b/frontend/src/lib/components/settings/SettingsJellyfin.svelte new file mode 100644 index 0000000..f731a51 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsJellyfin.svelte @@ -0,0 +1,180 @@ + + +
    +
    +

    Jellyfin Connection

    +

    + Connect your Jellyfin server for recently played tracks and favorites. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + + +
    + +
    + +
    + + +
    + +
    + + {#if availableUsers.length > 0} +
    + + +
    + {:else} +
    + + + +
    + {/if} + + {#if form.testResult} +
    + {form.testResult.message} +
    + {/if} + +
    + +
    + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsLastFm.svelte b/frontend/src/lib/components/settings/SettingsLastFm.svelte new file mode 100644 index 0000000..936913e --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsLastFm.svelte @@ -0,0 +1,377 @@ + + +
    +
    +

    Last.fm

    +

    + Connect Last.fm to track listens and improve recommendations. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
      +
    • API Credentials
    • +
    • Authorize
    • +
    • Enable
    • +
    + + {#if fullyConnected} +
    + + Connected as {form.data.username} + +
    + {/if} + + {#if showForm} +
    +
    +

    Step 1 — API Credentials

    + + {#if !step1Complete} +
    +

    You will need a Last.fm API key and shared secret:

    +
      +
    1. + + Create a Last.fm API application + + (opens in new tab) + +
    2. +
    3. Copy the API Key and Shared Secret from that page
    4. +
    5. Paste them here, then click Save & Test
    6. +
    +
    + {/if} + +
    + + + {#if step1Complete} +
    + + Get credentials at last.fm/api (opens in new tab) + +
    + {/if} +
    + +
    + +
    + + +
    +
    + + {#if testResult} +
    + {testResult.message} +
    + {/if} + +
    + + +
    +
    + + {#if step1Complete} +
    +
    +

    Step 2 — Authorize

    + + {#if step2Complete && !pendingToken} +
    + + Authorized as {form.data.username} +
    + + {:else if !pendingToken} +

    + Open Last.fm in a new tab, approve access, then come back here. +

    + + {/if} + + {#if pendingToken} +
    +
    +

    + Once you have approved access in Last.fm, finish the connection here. +

    +
    + + +
    +
    +
    + {/if} + + {#if authResult} +
    + {authResult.message} +
    + {/if} +
    + {/if} + + {#if step2Complete || form.data.enabled} +
    +
    +

    Step 3 — Enable

    + +
    + +
    + +
    + +
    +
    + {/if} + + {#if step3Complete} +
    +
    + + Want to scrobble to Last.fm too? + Turn it on in the Scrobbling tab + +
    + {/if} +
    + {/if} + + {#if form.message} +
    + {form.message} +
    + {/if} + {:else if form.message} +
    + {form.message} +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsLastFm.svelte.spec.ts b/frontend/src/lib/components/settings/SettingsLastFm.svelte.spec.ts new file mode 100644 index 0000000..5a5699d --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsLastFm.svelte.spec.ts @@ -0,0 +1,120 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SettingsLastFm from './SettingsLastFm.svelte'; +import type { LastFmConnectionSettingsResponse } from '$lib/types'; + +const defaultResponse: LastFmConnectionSettingsResponse = { + api_key: 'test-key', + shared_secret: '••••••••alue', + session_key: '', + username: '', + enabled: false, +}; + +function mockLoadSuccess(data: LastFmConnectionSettingsResponse = defaultResponse) { + return vi.fn().mockResolvedValue(new Response(JSON.stringify(data), { status: 200 })); +} + +function mockLoadFailure() { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Failed to load Last.fm settings' } }), { status: 500 }) + ); +} + +describe('SettingsLastFm.svelte', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should render the heading', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsLastFm); + + const heading = page.getByRole('heading', { name: 'Last.fm' }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('should show loading spinner initially', async () => { + globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + render(SettingsLastFm); + + const spinner = page.getByText('').all(); + expect(spinner.length).toBeGreaterThanOrEqual(0); + }); + + it('should render API key and shared secret fields after load', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsLastFm); + + const apiKeyInput = page.getByPlaceholder('Your Last.fm API key'); + await expect.element(apiKeyInput).toBeInTheDocument(); + + const secretInput = page.getByPlaceholder('Your Last.fm shared secret'); + await expect.element(secretInput).toBeInTheDocument(); + }); + + it('should show error message when load fails', async () => { + globalThis.fetch = mockLoadFailure(); + render(SettingsLastFm); + + const alert = page.getByText("Couldn't load your settings"); + await expect.element(alert).toBeInTheDocument(); + }); + + it('should hide authorize step when no saved credentials', async () => { + globalThis.fetch = mockLoadSuccess({ + ...defaultResponse, + api_key: '', + shared_secret: '', + }); + render(SettingsLastFm); + + const step2Heading = page.getByText('Step 2'); + await expect.element(step2Heading).not.toBeInTheDocument(); + }); + + it('should enable authorize button when credentials are saved', async () => { + globalThis.fetch = mockLoadSuccess({ + ...defaultResponse, + api_key: 'valid-key', + shared_secret: '••••••••cret', + }); + render(SettingsLastFm); + + const authorizeBtn = page.getByRole('button', { name: 'Open Last.fm' }); + await expect.element(authorizeBtn).not.toBeDisabled(); + }); + + it('should show authorized username when present', async () => { + globalThis.fetch = mockLoadSuccess({ + ...defaultResponse, + username: 'myuser', + session_key: '••••••••skey', + }); + render(SettingsLastFm); + + const info = page.getByText('myuser'); + await expect.element(info).toBeInTheDocument(); + }); + + it('should render save-and-test, test, and authorize buttons', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsLastFm); + + const saveTestBtn = page.getByRole('button', { name: 'Save & Test' }); + await expect.element(saveTestBtn).toBeInTheDocument(); + + const testBtn = page.getByRole('button', { name: 'Test Connection' }); + await expect.element(testBtn).toBeInTheDocument(); + + const authBtn = page.getByRole('button', { name: 'Open Last.fm' }); + await expect.element(authBtn).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/settings/SettingsLibrarySync.svelte b/frontend/src/lib/components/settings/SettingsLibrarySync.svelte new file mode 100644 index 0000000..5f65f7e --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsLibrarySync.svelte @@ -0,0 +1,120 @@ + + +
    +
    +

    Library Sync

    +

    + Choose how often MusicSeerr syncs with your Lidarr library. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    +
    +
    Last Sync
    +
    {formatLastSync(form.data.last_sync)}
    +
    + {#if form.data.last_sync_success === true} + Successful + {:else if form.data.last_sync_success === false} + Failed + {/if} +
    +
    +
    + + + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsLidarrConnection.svelte b/frontend/src/lib/components/settings/SettingsLidarrConnection.svelte new file mode 100644 index 0000000..568e06a --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsLidarrConnection.svelte @@ -0,0 +1,180 @@ + + +
    +
    +

    Lidarr Connection

    +

    + Configure your Lidarr server connection for music library management. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + + +
    + +
    + +
    + + +
    + +
    + + {#if verifyResult?.success && verifyResult.quality_profiles} +
    + + +
    + +
    + + +
    + +
    + + +
    + {/if} + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsListenBrainz.svelte b/frontend/src/lib/components/settings/SettingsListenBrainz.svelte new file mode 100644 index 0000000..d0d9a95 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsListenBrainz.svelte @@ -0,0 +1,244 @@ + + +
    +
    +

    ListenBrainz

    +

    + Connect to ListenBrainz for personalized recommendations and listening stats. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
      +
    • Credentials
    • +
    • Enable
    • +
    + + {#if fullyConnected} +
    + + Connected as {form.data.username} + +
    + {/if} + + {#if showForm} +
    +
    +

    Step 1 — Credentials

    + + {#if !step1Complete} +
    +

    To get started:

    +
      +
    1. + Create a free account at + + listenbrainz.org + + (opens in new tab) + +
    2. +
    3. Enter your username below
    4. +
    5. Optionally add your User Token for private statistics
    6. +
    +
    + {/if} + +
    + + +
    + +
    + +
    + + +
    + +
    + + {#if testResult} +
    + {testResult.message} +
    + {/if} + +
    + + +
    +
    + + {#if step1Complete || form.data.enabled} +
    +
    +

    Step 2 — Enable

    + +
    + +
    + +
    + +
    +
    + {/if} + + {#if step2Complete} +
    +
    + + To scrobble your listening activity to ListenBrainz, + enable it in the Scrobbling tab → + +
    + {/if} +
    + {/if} + + {#if form.message} +
    + {form.message} +
    + {/if} + {:else if form.message} +
    + {form.message} +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsListenBrainz.svelte.spec.ts b/frontend/src/lib/components/settings/SettingsListenBrainz.svelte.spec.ts new file mode 100644 index 0000000..963e99b --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsListenBrainz.svelte.spec.ts @@ -0,0 +1,124 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SettingsListenBrainz from './SettingsListenBrainz.svelte'; +import type { ListenBrainzConnectionSettings } from '$lib/types'; + +const defaultResponse: ListenBrainzConnectionSettings = { + username: 'testuser', + user_token: '', + enabled: false, +}; + +function mockLoadSuccess(data: ListenBrainzConnectionSettings = defaultResponse) { + return vi.fn().mockResolvedValue(new Response(JSON.stringify(data), { status: 200 })); +} + +function mockLoadFailure() { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Failed to load ListenBrainz settings' } }), { status: 500 }) + ); +} + +describe('SettingsListenBrainz.svelte', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should render the heading', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsListenBrainz); + + const heading = page.getByRole('heading', { name: 'ListenBrainz' }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('should show loading spinner initially', async () => { + globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + render(SettingsListenBrainz); + + const spinner = page.getByText('').all(); + expect(spinner.length).toBeGreaterThanOrEqual(0); + }); + + it('should render username and token fields after load', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsListenBrainz); + + const usernameInput = page.getByPlaceholder('Your ListenBrainz username'); + await expect.element(usernameInput).toBeInTheDocument(); + + const tokenInput = page.getByPlaceholder('For private statistics'); + await expect.element(tokenInput).toBeInTheDocument(); + }); + + it('should show error message when load fails', async () => { + globalThis.fetch = mockLoadFailure(); + render(SettingsListenBrainz); + + const alert = page.getByText("Couldn't load your settings"); + await expect.element(alert).toBeInTheDocument(); + }); + + it('should show getting-started guide when no saved credentials', async () => { + globalThis.fetch = mockLoadSuccess({ + username: '', + user_token: '', + enabled: false, + }); + render(SettingsListenBrainz); + + const guide = page.getByText('To get started:'); + await expect.element(guide).toBeInTheDocument(); + }); + + it('should show stepper with both steps', async () => { + globalThis.fetch = mockLoadSuccess(); + render(SettingsListenBrainz); + + const steps = page.getByRole('list'); + await expect.element(steps).toBeInTheDocument(); + + const step1 = page.getByRole('listitem').getByText('Credentials'); + await expect.element(step1).toBeInTheDocument(); + + const step2 = page.getByRole('listitem').getByText('Enable'); + await expect.element(step2).toBeInTheDocument(); + }); + + it('should show connected banner when fully connected', async () => { + globalThis.fetch = mockLoadSuccess({ + username: 'myuser', + user_token: 'token', + enabled: true, + }); + render(SettingsListenBrainz); + + const banner = page.getByText('myuser'); + await expect.element(banner).toBeInTheDocument(); + + const editBtn = page.getByRole('button', { name: 'Edit settings' }); + await expect.element(editBtn).toBeInTheDocument(); + }); + + it('should show scrobbling cross-reference when enabled', async () => { + globalThis.fetch = mockLoadSuccess({ + username: 'myuser', + user_token: 'token', + enabled: true, + }); + render(SettingsListenBrainz); + + const editBtn = page.getByRole('button', { name: 'Edit settings' }); + await editBtn.click(); + + const crossRef = page.getByText('enable it in the Scrobbling tab'); + await expect.element(crossRef).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/settings/SettingsLocalFiles.svelte b/frontend/src/lib/components/settings/SettingsLocalFiles.svelte new file mode 100644 index 0000000..764373b --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsLocalFiles.svelte @@ -0,0 +1,160 @@ + + +
    +
    +

    Local Files

    +

    + Play audio files directly from your music library on disk. Requires a Docker volume mount + pointing to your music folder. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + + +

    + The path inside the container where music files are mounted (e.g. /music) +

    +
    + +
    + + +

    + The root folder path as configured in Lidarr. Used to map Lidarr file paths to + local mount paths. +

    +
    + + {#if form.testResult} +
    + {form.testResult.message} +
    + {/if} + +
    + +
    + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsMusicSource.svelte b/frontend/src/lib/components/settings/SettingsMusicSource.svelte new file mode 100644 index 0000000..8925355 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsMusicSource.svelte @@ -0,0 +1,92 @@ + + +
    +
    +

    Primary Music Source

    +

    + Choose which listening service powers Home and Discover by default. You can still switch sources on each page. +

    + + {#if !bothConnected} +
    + + Connect both + {#if !lbConnected}ListenBrainz{:else}ListenBrainz{/if} + and + {#if !lfmConnected}Last.fm{:else}Last.fm{/if} + before choosing a default source. + Right now MusicSeerr is using {lbConnected ? 'ListenBrainz' : lfmConnected ? 'Last.fm' : 'no service'}. + +
    + {:else} +
    + Default source for discovery data + +

    + Shared sections like trending and recommendations use this source by default. +

    +
    + + {#if saving} +
    + + Saving… +
    + {/if} + + {#if message} +
    + + {message} + +
    + {/if} + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsNavidrome.svelte b/frontend/src/lib/components/settings/SettingsNavidrome.svelte new file mode 100644 index 0000000..bb80ed9 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsNavidrome.svelte @@ -0,0 +1,147 @@ + + +
    +
    +

    Navidrome Connection

    +

    + Connect your Navidrome server for music streaming, recently played tracks, and favorites. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    +
    + + {#if form.testResult} +
    + {form.testResult.message} +
    + {/if} + +
    + +
    + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsNetworkBatch.svelte b/frontend/src/lib/components/settings/SettingsNetworkBatch.svelte new file mode 100644 index 0000000..0624d7f --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsNetworkBatch.svelte @@ -0,0 +1,103 @@ + + +

    Network & HTTP

    +
    + + + +
    +
    +

    Batch Processing

    +
    + + + + + + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsNumberField.svelte b/frontend/src/lib/components/settings/SettingsNumberField.svelte new file mode 100644 index 0000000..e5b6f12 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsNumberField.svelte @@ -0,0 +1,30 @@ + + +
    + {label} + +

    {description}

    +
    diff --git a/frontend/src/lib/components/settings/SettingsPreferences.svelte b/frontend/src/lib/components/settings/SettingsPreferences.svelte new file mode 100644 index 0000000..2ec503a --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsPreferences.svelte @@ -0,0 +1,398 @@ + + +{#snippet typeTable( + types: ReleaseTypeOption[], + category: 'primary_types' | 'secondary_types' | 'release_statuses' +)} +
    + + + + + {#if lidarrConfigured && lidarrPrefs} + + {/if} + + + + + + {#each types as type} + {@const msEnabled = preferences[category].includes(type.id)} + {@const lrEnabled = isLidarrEnabled(category, type.id)} + {@const mismatch = lrEnabled !== null && msEnabled !== lrEnabled} + + + {#if lidarrConfigured && lidarrPrefs} + + {/if} + + + + {/each} + +
    + MS + + Lidarr + Type
    + toggleType(category, type.id)} + /> + + + + {type.title} + {#if mismatch} + differs + {/if} +
    +
    +{/snippet} + +
    +
    +

    Included Releases

    +

    + Choose which types of releases to show in artist pages and search results. +

    + + {#if lidarrConfigured} +
    + {#if lidarrLoading} + + Loading Lidarr profile… + {:else if lidarrError} + {lidarrError} + + {:else if lidarrPrefs} + + {#if mismatchCount > 0} + + {mismatchCount} difference{mismatchCount !== 1 ? 's' : ''} + + {:else} + In sync + {/if} +
    + + +
    + {/if} + {#if lidarrMessage} +
    + {lidarrMessage} +
    + {/if} +
    + {/if} + +
    +

    Primary Types

    + {@render typeTable(primaryTypes, 'primary_types')} +
    + +
    +

    Secondary Types

    + {@render typeTable(secondaryTypes, 'secondary_types')} +
    + +
    +

    Release Statuses

    + {@render typeTable(releaseStatuses, 'release_statuses')} +
    + +
    + {#if saveMessage} +
    + {saveMessage} +
    + {/if} + +
    +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsScrobbling.svelte b/frontend/src/lib/components/settings/SettingsScrobbling.svelte new file mode 100644 index 0000000..fdd01c5 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsScrobbling.svelte @@ -0,0 +1,118 @@ + + +
    +
    +

    Scrobbling

    +

    + Choose which services receive your listening activity. Tracks are scrobbled after 50% of + playback or 4 minutes, whichever comes first. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    + +
    + +
    + +
    + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + +
    +
    + {:else if form.message} +
    + {form.message} +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsScrobbling.svelte.spec.ts b/frontend/src/lib/components/settings/SettingsScrobbling.svelte.spec.ts new file mode 100644 index 0000000..4b03e90 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsScrobbling.svelte.spec.ts @@ -0,0 +1,146 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import SettingsScrobbling from './SettingsScrobbling.svelte'; +import { integrationStore } from '$lib/stores/integration'; + +function mockScrobbleSettings( + overrides: { scrobble_to_lastfm?: boolean; scrobble_to_listenbrainz?: boolean } = {} +) { + return { + scrobble_to_lastfm: false, + scrobble_to_listenbrainz: false, + ...overrides, + }; +} + +function mockJsonResponse(data: ReturnType) { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify(data), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ); +} + +function mockErrorResponse() { + return vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: { message: 'Failed to load scrobble settings' } }), { + status: 500, + headers: { 'content-type': 'application/json' } + }) + ); +} + +describe('SettingsScrobbling.svelte', () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + integrationStore.reset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + integrationStore.reset(); + }); + + it('renders heading', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + render(SettingsScrobbling); + + const heading = page.getByRole('heading', { name: 'Scrobbling' }); + await expect.element(heading).toBeInTheDocument(); + }); + + it('shows loading spinner initially', async () => { + globalThis.fetch = vi.fn().mockReturnValue(new Promise(() => {})); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + const { container } = render(SettingsScrobbling); + + await vi.waitFor(() => { + const spinners = container.querySelectorAll('.loading'); + expect(spinners.length).toBeGreaterThan(0); + }); + }); + + it('renders both scrobble toggles after load', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + render(SettingsScrobbling); + + const lastfmLabel = page.getByText('Scrobble to Last.fm'); + const lbLabel = page.getByText('Scrobble to ListenBrainz'); + + await expect.element(lastfmLabel).toBeInTheDocument(); + await expect.element(lbLabel).toBeInTheDocument(); + }); + + it('disables Last.fm toggle when Last.fm is not connected', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: false, listenbrainz: true }); + render(SettingsScrobbling); + + await vi.waitFor(async () => { + const toggles = document.querySelectorAll('input[type="checkbox"].toggle'); + const lastfmToggle = toggles[0] as HTMLInputElement; + expect(lastfmToggle.disabled).toBe(true); + }); + }); + + it('disables ListenBrainz toggle when LB is not connected', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: true, listenbrainz: false }); + render(SettingsScrobbling); + + await vi.waitFor(async () => { + const toggles = document.querySelectorAll('input[type="checkbox"].toggle'); + const lbToggle = toggles[1] as HTMLInputElement; + expect(lbToggle.disabled).toBe(true); + }); + }); + + it('enables both toggles when both services are connected', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + render(SettingsScrobbling); + + await vi.waitFor(async () => { + const toggles = document.querySelectorAll('input[type="checkbox"].toggle'); + expect(toggles.length).toBe(2); + expect((toggles[0] as HTMLInputElement).disabled).toBe(false); + expect((toggles[1] as HTMLInputElement).disabled).toBe(false); + }); + }); + + it('renders save button', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + render(SettingsScrobbling); + + const saveBtn = page.getByRole('button', { name: 'Save Settings' }); + await expect.element(saveBtn).toBeInTheDocument(); + }); + + it('shows cross-reference links when services are disconnected', async () => { + globalThis.fetch = mockJsonResponse(mockScrobbleSettings()); + integrationStore.setStatus({ lastfm: false, listenbrainz: false }); + render(SettingsScrobbling); + + const lastfmLink = page.getByText('Connect Last.fm first →'); + await expect.element(lastfmLink).toBeInTheDocument(); + + const lbLink = page.getByText('Connect ListenBrainz first →'); + await expect.element(lbLink).toBeInTheDocument(); + }); + + it('shows error message when load fails', async () => { + globalThis.fetch = mockErrorResponse(); + integrationStore.setStatus({ lastfm: true, listenbrainz: true }); + render(SettingsScrobbling); + + const errorAlert = page.getByText("Couldn't load your settings"); + await expect.element(errorAlert).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/settings/SettingsSectionCollapse.svelte b/frontend/src/lib/components/settings/SettingsSectionCollapse.svelte new file mode 100644 index 0000000..7cecd3e --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsSectionCollapse.svelte @@ -0,0 +1,50 @@ + + +
    + (isOpen = true)} + /> +
    +
    +
    + +
    +
    +

    {title}

    +

    {description}

    +
    +
    +
    +
    + {@render children()} +
    +
    diff --git a/frontend/src/lib/components/settings/SettingsStorageQueue.svelte b/frontend/src/lib/components/settings/SettingsStorageQueue.svelte new file mode 100644 index 0000000..9a77680 --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsStorageQueue.svelte @@ -0,0 +1,170 @@ + + +

    Memory and Disk Storage

    +
    + + + + + + + +
    +
    +

    Discover Queue

    +
    + These settings control how Discover Queue is prepared in the background so it is ready when you open Discover. +
    +

    Background Generation

    +
    + + +
    + Auto-Generate on Visit + +
    +
    + Pre-Build in Warm Cycle + +
    + +
    +
    +

    Queue Tuning

    +
    + + + + + + +
    diff --git a/frontend/src/lib/components/settings/SettingsYouTube.svelte b/frontend/src/lib/components/settings/SettingsYouTube.svelte new file mode 100644 index 0000000..25d0e1d --- /dev/null +++ b/frontend/src/lib/components/settings/SettingsYouTube.svelte @@ -0,0 +1,222 @@ + + +
    +
    +

    YouTube

    +

    + Enable YouTube features across the app — manage album links, search YouTube, and + optionally enable the API for automatic link generation. +

    + + {#if form.loading} +
    + +
    + {:else if form.data} +
    +
    +
    + +
    +
    + +
    + +
    +
    +

    YouTube API

    +
    Optional
    +
    +

    + Enable automatic link generation using the YouTube Data API. Get a free key from the + Google Cloud Console. +

    + +
    + +
    + + +
    + +
    + + {#if form.testResult} +
    + {form.testResult.message} +
    + {/if} + +
    + +
    + +
    + + + +
    + +
    + +
    +
    + + {#if !form.data.enabled} +
    + + Enable YouTube above to configure API settings. +
    + {/if} + + {#if form.message} +
    + {form.message} +
    + {/if} + +
    + +
    +
    + {/if} +
    +
    diff --git a/frontend/src/lib/components/settings/advanced-settings-types.ts b/frontend/src/lib/components/settings/advanced-settings-types.ts new file mode 100644 index 0000000..1455c43 --- /dev/null +++ b/frontend/src/lib/components/settings/advanced-settings-types.ts @@ -0,0 +1,62 @@ +export interface AdvancedSettingsForm { + cache_ttl_album_library: number; + cache_ttl_album_non_library: number; + cache_ttl_artist_library: number; + cache_ttl_artist_non_library: number; + cache_ttl_artist_discovery_library: number; + cache_ttl_artist_discovery_non_library: number; + cache_ttl_search: number; + cache_ttl_local_files_recently_added: number; + cache_ttl_local_files_storage_stats: number; + cache_ttl_jellyfin_recently_played: number; + cache_ttl_jellyfin_favorites: number; + cache_ttl_jellyfin_genres: number; + cache_ttl_jellyfin_library_stats: number; + http_timeout: number; + http_connect_timeout: number; + http_max_connections: number; + batch_artist_images: number; + batch_albums: number; + delay_artist: number; + delay_albums: number; + artist_discovery_warm_interval: number; + artist_discovery_warm_delay: number; + artist_discovery_precache_delay: number; + memory_cache_max_entries: number; + memory_cache_cleanup_interval: number; + cover_memory_cache_max_entries: number; + cover_memory_cache_max_size_mb: number; + disk_cache_cleanup_interval: number; + recent_metadata_max_size_mb: number; + recent_covers_max_size_mb: number; + persistent_metadata_ttl_hours: number; + musicbrainz_concurrent_searches: number; + discover_queue_size: number; + discover_queue_ttl: number; + discover_queue_auto_generate: boolean; + discover_queue_polling_interval: number; + discover_queue_warm_cycle_build: boolean; + discover_queue_seed_artists: number; + discover_queue_wildcard_slots: number; + discover_queue_similar_artists_limit: number; + discover_queue_albums_per_similar: number; + discover_queue_enrich_ttl: number; + discover_queue_lastfm_mbid_max_lookups: number; + frontend_ttl_home: number; + frontend_ttl_discover: number; + frontend_ttl_library: number; + frontend_ttl_recently_added: number; + frontend_ttl_discover_queue: number; + frontend_ttl_search: number; + frontend_ttl_local_files_sidebar: number; + frontend_ttl_jellyfin_sidebar: number; + frontend_ttl_playlist_sources: number; + audiodb_enabled: boolean; + audiodb_api_key: string; + audiodb_name_search_fallback: boolean; + direct_remote_images_enabled: boolean; + cache_ttl_audiodb_found: number; + cache_ttl_audiodb_not_found: number; + cache_ttl_audiodb_library: number; + cache_ttl_recently_viewed_bytes: number; +} diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts new file mode 100644 index 0000000..4d7f200 --- /dev/null +++ b/frontend/src/lib/constants.ts @@ -0,0 +1,300 @@ +export const CACHE_KEY_GROUPS = { + core: { + LIBRARY_MBIDS: 'musicseerr_library_mbids', + RECENTLY_ADDED: 'musicseerr_recently_added', + HOME_CACHE: 'musicseerr_home_cache', + DISCOVER_CACHE: 'musicseerr_discover_cache', + DISCOVER_QUEUE: 'musicseerr_discover_queue', + SEARCH: 'musicseerr_search_cache' + }, + library: { + LOCAL_FILES_SIDEBAR: 'musicseerr_local_files_sidebar', + JELLYFIN_SIDEBAR: 'musicseerr_jellyfin_sidebar', + JELLYFIN_ALBUMS_LIST: 'musicseerr_jellyfin_albums_list', + NAVIDROME_SIDEBAR: 'musicseerr_navidrome_sidebar', + NAVIDROME_ALBUMS_LIST: 'musicseerr_navidrome_albums_list', + LOCAL_FILES_ALBUMS_LIST: 'musicseerr_local_files_albums_list' + }, + detail: { + ALBUM_BASIC_CACHE: 'musicseerr_album_basic_cache', + ALBUM_TRACKS_CACHE: 'musicseerr_album_tracks_cache', + ALBUM_DISCOVERY_CACHE: 'musicseerr_album_discovery_cache', + ALBUM_LASTFM_CACHE: 'musicseerr_album_lastfm_cache', + ALBUM_YOUTUBE_CACHE: 'musicseerr_album_youtube_cache', + ALBUM_SOURCE_MATCH_CACHE: 'musicseerr_album_source_match_cache', + ARTIST_BASIC_CACHE: 'musicseerr_artist_basic_cache', + ARTIST_EXTENDED_CACHE: 'musicseerr_artist_extended_cache', + ARTIST_LASTFM_CACHE: 'musicseerr_artist_lastfm_cache' + }, + charts: { + TIME_RANGE_OVERVIEW_CACHE: 'musicseerr_time_range_overview_cache', + GENRE_DETAIL_CACHE: 'musicseerr_genre_detail_cache' + } +} as const; + +export const CACHE_KEYS = { + ...CACHE_KEY_GROUPS.core, + ...CACHE_KEY_GROUPS.library, + ...CACHE_KEY_GROUPS.detail, + ...CACHE_KEY_GROUPS.charts +} as const; + +export const PAGE_SOURCE_KEYS = { + home: 'musicseerr_source_home', + discover: 'musicseerr_source_discover', + artist: 'musicseerr_source_artist', + trending: 'musicseerr_source_trending', + popular: 'musicseerr_source_popular', + yourTop: 'musicseerr_source_your_top' +} as const; + +export const CACHE_TTL_GROUPS = { + core: { + DEFAULT: 5 * 60 * 1000, + LIBRARY: 5 * 60 * 1000, + RECENTLY_ADDED: 5 * 60 * 1000, + HOME: 5 * 60 * 1000, + DISCOVER: 30 * 60 * 1000, + DISCOVER_QUEUE: 24 * 60 * 60 * 1000, + SEARCH: 5 * 60 * 1000 + }, + library: { + LOCAL_FILES_SIDEBAR: 2 * 60 * 1000, + JELLYFIN_SIDEBAR: 2 * 60 * 1000, + JELLYFIN_ALBUMS_LIST: 2 * 60 * 1000, + NAVIDROME_SIDEBAR: 2 * 60 * 1000, + NAVIDROME_ALBUMS_LIST: 2 * 60 * 1000, + LOCAL_FILES_ALBUMS_LIST: 2 * 60 * 1000, + PLAYLIST_SOURCES: 15 * 60 * 1000 + }, + detail: { + ALBUM_DETAIL_BASIC: 5 * 60 * 1000, + ALBUM_DETAIL_TRACKS: 15 * 60 * 1000, + ALBUM_DETAIL_DISCOVERY: 30 * 60 * 1000, + ALBUM_DETAIL_LASTFM: 30 * 60 * 1000, + ALBUM_DETAIL_YOUTUBE: 60 * 60 * 1000, + ALBUM_DETAIL_SOURCE_MATCH: 5 * 60 * 1000, + ARTIST_DETAIL_BASIC: 5 * 60 * 1000, + ARTIST_DETAIL_EXTENDED: 30 * 60 * 1000, + ARTIST_DETAIL_LASTFM: 30 * 60 * 1000 + }, + charts: { + TIME_RANGE_OVERVIEW: 2 * 60 * 1000, + GENRE_DETAIL: 5 * 60 * 1000 + } +} as const; + +export const CACHE_TTL = { + ...CACHE_TTL_GROUPS.core, + ...CACHE_TTL_GROUPS.library, + ...CACHE_TTL_GROUPS.detail, + ...CACHE_TTL_GROUPS.charts +} as const; + +export const API_SIZES = { + XS: 250, + SM: 250, + MD: 250, + LG: 500, + XL: 500, + HERO: 500, + FULL: 500 +} as const; + +export const BATCH_SIZES = { + RELEASES: 50, + SEARCH_RESULTS: 24, + COVER_PREFETCH: 12 +} as const; + +export const TOAST_DURATION = 2000; + +export const SCROLL_THRESHOLD = 10; + +export const CANVAS_SAMPLE_SIZE = 50; + +export const IMAGE_PIXEL_SAMPLE_STEP = 16; + +export const ALPHA_THRESHOLD = 128; + +export const PLACEHOLDER_COLORS = { + DARK: '#0d120a', + MEDIUM: '#161d12', + LIGHT: '#1F271B' +} as const; + +export const STATUS_COLORS = { + REQUESTED: '#F59E0B' +} as const; + +export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const YOUTUBE_PLAYER_ELEMENT_ID = 'yt-player-embed'; + +export const API = { + artist: { + basic: (id: string) => `/api/v1/artists/${id}`, + extended: (id: string) => `/api/v1/artists/${id}/extended`, + releases: (id: string, offset: number, limit: number) => + `/api/v1/artists/${id}/releases?offset=${offset}&limit=${limit}` + }, + album: { + basic: (id: string) => `/api/v1/albums/${id}`, + tracks: (id: string) => `/api/v1/albums/${id}/tracks` + }, + library: { + mbids: () => '/api/v1/library/mbids', + albums: (limit = 50, offset = 0, sortBy = 'date_added', sortOrder = 'desc', q?: string) => { + let url = `/api/v1/library/albums?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (q) url += `&q=${encodeURIComponent(q)}`; + return url; + }, + artists: (limit = 50, offset = 0, sortBy = 'name', sortOrder = 'asc', q?: string) => { + let url = `/api/v1/library/artists?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (q) url += `&q=${encodeURIComponent(q)}`; + return url; + }, + removeAlbumPreview: (mbid: string) => `/api/v1/library/album/${mbid}/removal-preview`, + removeAlbum: (mbid: string) => `/api/v1/library/album/${mbid}`, + resolveTracks: () => '/api/v1/library/resolve-tracks' + }, + search: { + artists: (query: string) => `/api/v1/search/artists?q=${encodeURIComponent(query)}`, + albums: (query: string) => `/api/v1/search/albums?q=${encodeURIComponent(query)}`, + suggest: (query: string, limit = 5) => + `/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}` + }, + home: () => '/api/v1/home', + homeIntegrationStatus: () => '/api/v1/home/integration-status', + discover: () => '/api/v1/discover', + discoverRefresh: () => '/api/v1/discover/refresh', + discoverQueue: (source?: string) => `/api/v1/discover/queue${source ? `?source=${source}` : ''}`, + discoverQueueStatus: (source?: string) => + `/api/v1/discover/queue/status${source ? `?source=${source}` : ''}`, + discoverQueueGenerate: () => '/api/v1/discover/queue/generate', + discoverQueueEnrich: (mbid: string) => `/api/v1/discover/queue/enrich/${mbid}`, + discoverQueueIgnore: () => '/api/v1/discover/queue/ignore', + discoverQueueIgnored: () => '/api/v1/discover/queue/ignored', + discoverQueueValidate: () => '/api/v1/discover/queue/validate', + discoverQueueYoutubeSearch: (artist: string, album: string) => + `/api/v1/discover/queue/youtube-search?artist=${encodeURIComponent(artist)}&album=${encodeURIComponent(album)}`, + discoverQueueYoutubeTrackSearch: (artist: string, track: string) => + `/api/v1/discover/queue/youtube-track-search?artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(track)}`, + discoverQueueYoutubeQuota: () => '/api/v1/discover/queue/youtube-quota', + discoverQueueYoutubeCacheCheck: () => '/api/v1/discover/queue/youtube-cache-check', + youtube: { + generate: () => '/api/v1/youtube/generate', + link: (albumId: string) => `/api/v1/youtube/link/${albumId}`, + links: () => '/api/v1/youtube/links', + deleteLink: (albumId: string) => `/api/v1/youtube/link/${albumId}`, + updateLink: (albumId: string) => `/api/v1/youtube/link/${albumId}`, + manual: () => '/api/v1/youtube/manual', + generateTrack: () => '/api/v1/youtube/generate-track', + generateTracks: () => '/api/v1/youtube/generate-tracks', + trackLinks: (albumId: string) => `/api/v1/youtube/track-links/${albumId}`, + deleteTrackLink: (albumId: string, discNumber: number, trackNumber: number) => + `/api/v1/youtube/track-link/${albumId}/${discNumber}/${trackNumber}`, + quota: () => '/api/v1/youtube/quota' + }, + queue: () => '/api/v1/queue', + settings: () => '/api/v1/settings', + settingsNavidrome: () => '/api/v1/settings/navidrome', + settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify', + settingsLocalFiles: () => '/api/v1/settings/local-files', + settingsLocalFilesVerify: () => '/api/v1/settings/local-files/verify', + profile: { + get: () => '/api/v1/profile', + update: () => '/api/v1/profile', + avatarUpload: () => '/api/v1/profile/avatar', + avatar: () => '/api/v1/profile/avatar' + }, + playlists: { + list: () => '/api/v1/playlists', + create: () => '/api/v1/playlists', + detail: (id: string) => `/api/v1/playlists/${id}`, + update: (id: string) => `/api/v1/playlists/${id}`, + delete: (id: string) => `/api/v1/playlists/${id}`, + addTracks: (id: string) => `/api/v1/playlists/${id}/tracks`, + removeTracks: (id: string) => `/api/v1/playlists/${id}/tracks/remove`, + removeTrack: (id: string, trackId: string) => `/api/v1/playlists/${id}/tracks/${trackId}`, + updateTrack: (id: string, trackId: string) => `/api/v1/playlists/${id}/tracks/${trackId}`, + reorderTrack: (id: string) => `/api/v1/playlists/${id}/tracks/reorder`, + uploadCover: (id: string) => `/api/v1/playlists/${id}/cover`, + getCover: (id: string) => `/api/v1/playlists/${id}/cover`, + deleteCover: (id: string) => `/api/v1/playlists/${id}/cover`, + checkTracks: () => '/api/v1/playlists/check-tracks', + resolveSources: (id: string) => `/api/v1/playlists/${id}/resolve-sources` + }, + stream: { + jellyfin: (itemId: string) => `/api/v1/stream/jellyfin/${itemId}`, + jellyfinStart: (itemId: string) => `/api/v1/stream/jellyfin/${itemId}/start`, + jellyfinProgress: (itemId: string) => `/api/v1/stream/jellyfin/${itemId}/progress`, + jellyfinStop: (itemId: string) => `/api/v1/stream/jellyfin/${itemId}/stop`, + navidrome: (id: string) => `/api/v1/stream/navidrome/${id}`, + navidromeScrobble: (id: string) => `/api/v1/stream/navidrome/${id}/scrobble`, + navidromeNowPlaying: (id: string) => `/api/v1/stream/navidrome/${id}/now-playing`, + local: (trackId: number | string) => `/api/v1/stream/local/${trackId}` + }, + jellyfinLibrary: { + albumMatch: (mbid: string) => `/api/v1/jellyfin/albums/match/${mbid}`, + albums: ( + limit = 50, + offset = 0, + sortBy = 'SortName', + genre?: string, + sortOrder = 'Ascending' + ) => { + let url = `/api/v1/jellyfin/albums?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (genre) url += `&genre=${encodeURIComponent(genre)}`; + return url; + }, + albumDetail: (id: string) => `/api/v1/jellyfin/albums/${id}`, + albumTracks: (id: string) => `/api/v1/jellyfin/albums/${id}/tracks`, + search: (query: string) => `/api/v1/jellyfin/search?q=${encodeURIComponent(query)}`, + artists: (limit = 50, offset = 0) => `/api/v1/jellyfin/artists?limit=${limit}&offset=${offset}`, + recent: () => '/api/v1/jellyfin/recent', + favorites: () => '/api/v1/jellyfin/favorites', + genres: () => '/api/v1/jellyfin/genres', + stats: () => '/api/v1/jellyfin/stats' + }, + navidromeLibrary: { + albums: () => '/api/v1/navidrome/albums', + albumDetail: (id: string) => `/api/v1/navidrome/albums/${id}`, + artists: () => '/api/v1/navidrome/artists', + artistDetail: (id: string) => `/api/v1/navidrome/artists/${id}`, + search: (q: string) => `/api/v1/navidrome/search?q=${encodeURIComponent(q)}`, + recent: () => '/api/v1/navidrome/recent', + favorites: () => '/api/v1/navidrome/favorites', + genres: () => '/api/v1/navidrome/genres', + stats: () => '/api/v1/navidrome/stats', + albumMatch: (albumId: string) => `/api/v1/navidrome/album-match/${albumId}` + }, + local: { + albumMatch: (mbid: string) => `/api/v1/local/albums/match/${mbid}`, + albums: (limit = 50, offset = 0, sortBy = 'name', q?: string, sortOrder = 'asc') => { + let url = `/api/v1/local/albums?limit=${limit}&offset=${offset}&sort_by=${sortBy}&sort_order=${sortOrder}`; + if (q) url += `&q=${encodeURIComponent(q)}`; + return url; + }, + albumTracks: (id: number | string) => `/api/v1/local/albums/${id}/tracks`, + search: (query: string) => `/api/v1/local/search?q=${encodeURIComponent(query)}`, + recent: () => '/api/v1/local/recent', + stats: () => '/api/v1/local/stats' + } +} as const; + +export const MESSAGES = { + ERRORS: { + LOAD_ALBUM: 'Failed to load album', + LOAD_ARTIST: 'Failed to load artist', + LOAD_TRACKS: "Couldn't load the track list", + LOAD_RELEASES: 'Failed to load releases', + NETWORK: 'Network error occurred', + NOT_FOUND: 'Resource not found', + REQUEST_FAILED: 'Request failed' + }, + SUCCESS: { + ADDED_TO_LIBRARY: 'Added to Library', + REQUESTED: 'Request submitted successfully' + } +} as const; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..0fdec1c --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// Place your shared component/function exports here diff --git a/frontend/src/lib/player/NativeAudioSource.spec.ts b/frontend/src/lib/player/NativeAudioSource.spec.ts new file mode 100644 index 0000000..a82c9fb --- /dev/null +++ b/frontend/src/lib/player/NativeAudioSource.spec.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const listeners = new Map>(); + const audio = { + src: '', + volume: 1, + currentTime: 0, + duration: 180, + ended: false, + error: null as MediaError | null, + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(), + load: vi.fn(), + addEventListener: vi.fn((event: string, handler: EventListener) => { + const set = listeners.get(event) ?? new Set(); + set.add(handler); + listeners.set(event, set); + }), + removeEventListener: vi.fn((event: string, handler: EventListener) => { + listeners.get(event)?.delete(handler); + }), + }; + + const dispatch = (event: string): void => { + for (const handler of listeners.get(event) ?? []) { + handler(new Event(event)); + } + }; + + const reset = (): void => { + listeners.clear(); + audio.src = ''; + audio.volume = 1; + audio.currentTime = 0; + audio.duration = 180; + audio.ended = false; + audio.error = null; + audio.play.mockReset(); + audio.play.mockImplementation(() => Promise.resolve()); + audio.pause.mockReset(); + audio.load.mockReset(); + audio.addEventListener.mockClear(); + audio.removeEventListener.mockClear(); + }; + + return { + audio, + dispatch, + reset, + getAudioElement: vi.fn(() => audio as unknown as HTMLAudioElement), + }; +}); + +vi.mock('./audioElement', () => ({ + getAudioElement: hoisted.getAudioElement, +})); + +import { NativeAudioSource } from './NativeAudioSource'; + +describe('NativeAudioSource', () => { + beforeEach(() => { + hoisted.reset(); + hoisted.getAudioElement.mockImplementation(() => hoisted.audio as unknown as HTMLAudioElement); + vi.useRealTimers(); + }); + + it('loads successfully on canplay', async () => { + const source = new NativeAudioSource('local', { url: '/audio.mp3', seekable: true }); + const loadPromise = source.load(); + + expect(hoisted.audio.src).toBe('/audio.mp3'); + hoisted.dispatch('canplay'); + + await expect(loadPromise).resolves.toBeUndefined(); + }); + + it('fails load when timeout is reached', async () => { + vi.useFakeTimers(); + const source = new NativeAudioSource('local', { url: '/timeout.mp3', seekable: true }); + const loadPromise = source.load(); + + vi.advanceTimersByTime(15_000); + + await expect(loadPromise).rejects.toThrow('load timed out'); + }); + + it('emits network stall error after stalled timeout', async () => { + vi.useFakeTimers(); + const source = new NativeAudioSource('local', { url: '/stall.mp3', seekable: true }); + const onError = vi.fn(); + source.onError(onError); + + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + hoisted.dispatch('stalled'); + vi.advanceTimersByTime(15_000); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'NETWORK_STALL' }) + ); + }); + + it('reports autoplay blocked when play promise rejects', async () => { + const source = new NativeAudioSource('local', { url: '/blocked.mp3', seekable: true }); + const onError = vi.fn(); + source.onError(onError); + + hoisted.audio.play.mockImplementationOnce(() => Promise.reject(new Error('blocked'))); + source.play(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'AUTOPLAY_BLOCKED' }) + ); + }); + + it('seekTo updates currentTime when stream is seekable', () => { + const source = new NativeAudioSource('local', { url: '/seek.mp3', seekable: true }); + + source.seekTo(42); + + expect(hoisted.audio.currentTime).toBe(42); + }); + + it('seekTo is no-op when stream is not seekable', () => { + const source = new NativeAudioSource('jellyfin', { url: '/transcode.opus', seekable: false }); + hoisted.audio.currentTime = 5; + + source.seekTo(60); + + expect(hoisted.audio.currentTime).toBe(5); + }); + + it('destroy clears src and removes listeners', async () => { + const source = new NativeAudioSource('local', { url: '/destroy.mp3', seekable: true }); + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + source.destroy(); + + expect(hoisted.audio.src).toBe(''); + expect(hoisted.audio.removeEventListener).toHaveBeenCalled(); + }); + + it('throws when audio element is unavailable', () => { + hoisted.getAudioElement.mockImplementationOnce(() => { + throw new Error('Audio element not mounted'); + }); + + expect(() => new NativeAudioSource('local', { url: '/missing.mp3', seekable: true })).toThrow( + 'Audio element not mounted' + ); + }); + + it('fires onProgress callback on timeupdate events', async () => { + const source = new NativeAudioSource('local', { url: '/progress.mp3', seekable: true }); + const onProgress = vi.fn(); + source.onProgress(onProgress); + + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + hoisted.audio.currentTime = 42; + hoisted.audio.duration = 180; + hoisted.dispatch('timeupdate'); + + expect(onProgress).toHaveBeenCalledWith(42, 180); + }); + + it('rejects load promise on media error event', async () => { + const source = new NativeAudioSource('local', { url: '/bad.mp3', seekable: true }); + const onError = vi.fn(); + source.onError(onError); + + const loadPromise = source.load(); + + hoisted.audio.error = { code: 4 } as MediaError; + hoisted.dispatch('error'); + + await expect(loadPromise).rejects.toThrow('MEDIA_ERR_SRC_NOT_SUPPORTED'); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ code: 'LOAD_ERROR' }) + ); + }); + + it('transitions from buffering back to playing after seek via playing event', async () => { + const source = new NativeAudioSource('local', { url: '/seek.mp3', seekable: true }); + const states: string[] = []; + source.onStateChange((s) => states.push(s)); + + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + hoisted.dispatch('play'); + expect(states).toContain('playing'); + + hoisted.dispatch('waiting'); + expect(states.at(-1)).toBe('buffering'); + + hoisted.dispatch('playing'); + expect(states.at(-1)).toBe('playing'); + }); + + it('transitions from buffering back to playing via timeupdate fallback', async () => { + const source = new NativeAudioSource('local', { url: '/seek2.mp3', seekable: true }); + const states: string[] = []; + source.onStateChange((s) => states.push(s)); + + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + hoisted.dispatch('play'); + hoisted.dispatch('waiting'); + expect(states.at(-1)).toBe('buffering'); + + hoisted.audio.currentTime = 30; + hoisted.dispatch('timeupdate'); + expect(states.at(-1)).toBe('playing'); + }); + + it('does not emit redundant playing state on timeupdate when already playing', async () => { + const source = new NativeAudioSource('local', { url: '/no-dup.mp3', seekable: true }); + const states: string[] = []; + source.onStateChange((s) => states.push(s)); + + const loadPromise = source.load(); + hoisted.dispatch('canplay'); + await loadPromise; + + hoisted.dispatch('play'); + const countAfterPlay = states.filter((s) => s === 'playing').length; + + hoisted.audio.currentTime = 10; + hoisted.dispatch('timeupdate'); + + expect(states.filter((s) => s === 'playing').length).toBe(countAfterPlay); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/player/NativeAudioSource.ts b/frontend/src/lib/player/NativeAudioSource.ts new file mode 100644 index 0000000..b08f835 --- /dev/null +++ b/frontend/src/lib/player/NativeAudioSource.ts @@ -0,0 +1,258 @@ +import type { PlaybackSource, PlaybackState } from './types'; +import { getAudioElement } from './audioElement'; + +const LOAD_TIMEOUT_MS = 15_000; +const STALL_TIMEOUT_MS = 15_000; + +type NativeSourceType = 'jellyfin' | 'local' | 'navidrome'; + +export class NativeAudioSource implements PlaybackSource { + readonly type: NativeSourceType; + + private readonly audio: HTMLAudioElement; + private readonly url: string; + private readonly seekable: boolean; + + private stateCallbacks: ((state: PlaybackState) => void)[] = []; + private readyCallbacks: (() => void)[] = []; + private errorCallbacks: ((error: { code: string; message: string }) => void)[] = []; + private progressCallbacks: ((currentTime: number, duration: number) => void)[] = []; + + private listeners: Array<{ event: string; handler: EventListener }> = []; + private stallTimeoutHandle: ReturnType | null = null; + private pendingVolume = 75; + private destroyed = false; + private currentState: PlaybackState = 'idle'; + + constructor(type: NativeSourceType, opts: { url: string; seekable: boolean }) { + this.type = type; + this.url = opts.url; + this.seekable = opts.seekable; + this.audio = getAudioElement(); + } + + async load(_info?: unknown): Promise { + this.destroyed = false; + this.cleanupListeners(); + this.clearStallTimeout(); + this.emitStateChange('loading'); + + await new Promise((resolve, reject) => { + let settled = false; + + const finalize = (action: () => void): void => { + if (settled || this.destroyed) return; + settled = true; + action(); + }; + + const onCanPlay = () => { + finalize(() => { + this.readyCallbacks.forEach((cb) => cb()); + resolve(); + }); + }; + + const onPlay = () => { + this.clearStallTimeout(); + this.emitStateChange('playing'); + }; + + const onPlaying = () => { + this.clearStallTimeout(); + if (this.currentState !== 'playing') { + this.emitStateChange('playing'); + } + }; + + const onPause = () => { + if (this.audio.ended) return; + this.emitStateChange('paused'); + }; + + const onEnded = () => { + this.emitStateChange('ended'); + }; + + const onWaiting = () => { + this.emitStateChange('buffering'); + this.startStallTimeout(); + }; + + const onTimeUpdate = () => { + this.clearStallTimeout(); + if (this.currentState === 'buffering') { + this.emitStateChange('playing'); + } + const currentTime = this.getCurrentTime(); + const duration = this.getDuration(); + this.progressCallbacks.forEach((cb) => cb(currentTime, duration)); + }; + + const onError = () => { + const code = this.audio.error?.code ?? 0; + const message = this.getMediaErrorMessage(code); + this.emitStateChange('error'); + this.emitError('LOAD_ERROR', message); + finalize(() => reject(new Error(message))); + }; + + const onStalled = () => { + this.startStallTimeout(); + }; + + const timeoutHandle = setTimeout(() => { + if (settled || this.destroyed) return; + settled = true; + const message = `Native audio source load timed out after ${LOAD_TIMEOUT_MS}ms`; + this.audio.src = ''; + this.cleanupListeners(); + this.clearStallTimeout(); + this.emitError('LOAD_TIMEOUT', message); + reject(new Error(message)); + }, LOAD_TIMEOUT_MS); + + this.registerListener('canplay', () => { + clearTimeout(timeoutHandle); + onCanPlay(); + }); + this.registerListener('play', onPlay); + this.registerListener('playing', onPlaying); + this.registerListener('pause', onPause); + this.registerListener('ended', onEnded); + this.registerListener('waiting', onWaiting); + this.registerListener('timeupdate', onTimeUpdate); + this.registerListener('error', () => { + clearTimeout(timeoutHandle); + onError(); + }); + this.registerListener('stalled', onStalled); + + this.audio.src = this.url; + this.audio.volume = this.pendingVolume / 100; + this.audio.load(); + }); + } + + play(): void { + void this.audio.play().catch(() => { + this.emitError('AUTOPLAY_BLOCKED', 'Playback failed. Browser may be blocking autoplay.'); + this.emitStateChange('error'); + }); + } + + pause(): void { + this.audio.pause(); + } + + seekTo(seconds: number): void { + if (!this.seekable) { + console.warn('[NativeAudio] seekTo ignored: stream is not seekable'); + return; + } + const clamped = Math.max(0, seconds); + const dur = this.getDuration(); + this.audio.currentTime = dur > 0 ? Math.min(clamped, dur) : clamped; + } + + setVolume(level: number): void { + const clamped = Math.max(0, Math.min(100, level)); + this.pendingVolume = clamped; + if (this.audio.src) { + this.audio.volume = clamped / 100; + } + } + + getCurrentTime(): number { + const current = this.audio.currentTime; + return Number.isFinite(current) ? current : 0; + } + + getDuration(): number { + const total = this.audio.duration; + return Number.isFinite(total) ? total : 0; + } + + destroy(): void { + this.destroyed = true; + this.clearStallTimeout(); + this.cleanupListeners(); + this.audio.src = ''; + this.audio.load(); + this.stateCallbacks = []; + this.readyCallbacks = []; + this.errorCallbacks = []; + this.progressCallbacks = []; + } + + onStateChange(callback: (state: PlaybackState) => void): void { + this.stateCallbacks.push(callback); + } + + onReady(callback: () => void): void { + this.readyCallbacks.push(callback); + } + + onError(callback: (error: { code: string; message: string }) => void): void { + this.errorCallbacks.push(callback); + } + + onProgress(callback: (currentTime: number, duration: number) => void): void { + this.progressCallbacks.push(callback); + } + + isSeekable(): boolean { + return this.seekable; + } + + private registerListener(event: string, handler: EventListener): void { + this.audio.addEventListener(event, handler); + this.listeners.push({ event, handler }); + } + + private cleanupListeners(): void { + for (const { event, handler } of this.listeners) { + this.audio.removeEventListener(event, handler); + } + this.listeners = []; + } + + private startStallTimeout(): void { + this.clearStallTimeout(); + this.stallTimeoutHandle = setTimeout(() => { + if (this.destroyed) return; + this.emitError('NETWORK_STALL', `Playback stalled for ${STALL_TIMEOUT_MS}ms`); + this.emitStateChange('error'); + }, STALL_TIMEOUT_MS); + } + + private clearStallTimeout(): void { + if (!this.stallTimeoutHandle) return; + clearTimeout(this.stallTimeoutHandle); + this.stallTimeoutHandle = null; + } + + private emitStateChange(state: PlaybackState): void { + this.currentState = state; + this.stateCallbacks.forEach((cb) => cb(state)); + } + + private emitError(code: string, message: string): void { + this.errorCallbacks.forEach((cb) => cb({ code, message })); + } + + private getMediaErrorMessage(code: number): string { + switch (code) { + case 1: + return 'MEDIA_ERR_ABORTED: Playback was aborted'; + case 2: + return 'MEDIA_ERR_NETWORK: A network error occurred'; + case 3: + return 'MEDIA_ERR_DECODE: Decoding failed due to corruption or unsupported features'; + case 4: + return 'MEDIA_ERR_SRC_NOT_SUPPORTED: Audio source is not supported'; + default: + return 'Unknown media error'; + } + } +} diff --git a/frontend/src/lib/player/YouTubePlaybackSource.ts b/frontend/src/lib/player/YouTubePlaybackSource.ts new file mode 100644 index 0000000..9fa0767 --- /dev/null +++ b/frontend/src/lib/player/YouTubePlaybackSource.ts @@ -0,0 +1,266 @@ +import type { PlaybackSource, PlaybackState } from './types'; + +declare global { + interface Window { + YT: typeof YT; + onYouTubeIframeAPIReady: (() => void) | undefined; + } +} + +declare namespace YT { + class Player { + constructor(elementId: string, options: PlayerOptions); + playVideo(): void; + pauseVideo(): void; + seekTo(seconds: number, allowSeekAhead: boolean): void; + setVolume(volume: number): void; + getCurrentTime(): number; + getDuration(): number; + getPlayerState(): number; + destroy(): void; + } + + interface PlayerOptions { + height?: string | number; + width?: string | number; + videoId?: string; + playerVars?: Record; + events?: { + onReady?: (event: { target: Player }) => void; + onStateChange?: (event: { data: number }) => void; + onError?: (event: { data: number }) => void; + }; + } + + enum PlayerState { + UNSTARTED = -1, + ENDED = 0, + PLAYING = 1, + PAUSED = 2, + BUFFERING = 3, + CUED = 5 + } +} + +let apiLoaded = false; +let apiLoading = false; +const apiReadyQueue: { resolve: () => void; reject: (err: Error) => void }[] = []; + +function flushQueue(error?: Error): void { + const pending = apiReadyQueue.splice(0); + for (const { resolve, reject } of pending) { + error ? reject(error) : resolve(); + } +} + +function loadYouTubeAPI(): Promise { + if (typeof window !== 'undefined' && window.YT?.Player) { + apiLoaded = true; + return Promise.resolve(); + } + + if (apiLoaded) return Promise.resolve(); + + return new Promise((resolve, reject) => { + apiReadyQueue.push({ resolve, reject }); + if (apiLoading) return; + + apiLoading = true; + + const timeout = setTimeout(() => { + apiLoading = false; + flushQueue(new Error('YouTube IFrame API failed to load (timeout)')); + }, 15000); + + const existingCallback = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + clearTimeout(timeout); + existingCallback?.(); + apiLoaded = true; + apiLoading = false; + flushQueue(); + }; + + if (!document.querySelector('script[src="https://www.youtube.com/iframe_api"]')) { + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.onerror = () => { + clearTimeout(timeout); + apiLoading = false; + flushQueue(new Error('Failed to load YouTube IFrame API script')); + }; + document.head.appendChild(script); + } + }); +} + +export class YouTubePlaybackSource implements PlaybackSource { + readonly type = 'youtube' as const; + + private player: YT.Player | null = null; + private elementId: string; + private progressInterval: ReturnType | null = null; + private stateCallbacks: ((state: PlaybackState) => void)[] = []; + private readyCallbacks: (() => void)[] = []; + private errorCallbacks: ((error: { code: string; message: string }) => void)[] = []; + private progressCallbacks: ((currentTime: number, duration: number) => void)[] = []; + private destroyed = false; + private pendingVolume = 75; + + constructor(elementId: string) { + this.elementId = elementId; + } + + async load(info: { trackSourceId?: string; url?: string; token?: string; format?: string }): Promise { + if (!info.trackSourceId) throw new Error('trackSourceId is required for YouTube source'); + + await loadYouTubeAPI(); + if (this.destroyed) return; + if (!document.getElementById(this.elementId)) { + throw new Error(`YouTube player mount target not found: ${this.elementId}`); + } + + return new Promise((resolve, reject) => { + try { + this.player = new window.YT.Player(this.elementId, { + height: '68', + width: '120', + videoId: info.trackSourceId, + playerVars: { + controls: 0, + modestbranding: 1, + rel: 0, + playsinline: 1, + autoplay: 1 + }, + events: { + onReady: () => { + if (this.destroyed) { + resolve(); + return; + } + this.player?.setVolume(this.pendingVolume); + this.readyCallbacks.forEach((cb) => cb()); + this.startProgressPolling(); + resolve(); + }, + onStateChange: (event) => { + const state = this.mapPlayerState(event.data); + this.stateCallbacks.forEach((cb) => cb(state)); + }, + onError: (event) => { + const error = this.mapError(event.data); + this.errorCallbacks.forEach((cb) => cb(error)); + reject(error); + } + } + }); + } catch (e) { + reject(e); + } + }); + } + + play(): void { + this.player?.playVideo(); + } + + pause(): void { + this.player?.pauseVideo(); + } + + seekTo(seconds: number): void { + this.player?.seekTo(seconds, true); + } + + setVolume(level: number): void { + this.pendingVolume = Math.max(0, Math.min(100, level)); + this.player?.setVolume(this.pendingVolume); + } + + getCurrentTime(): number { + return this.player?.getCurrentTime() ?? 0; + } + + getDuration(): number { + return this.player?.getDuration() ?? 0; + } + + destroy(): void { + this.destroyed = true; + this.stopProgressPolling(); + this.player?.destroy(); + this.player = null; + this.stateCallbacks = []; + this.readyCallbacks = []; + this.errorCallbacks = []; + this.progressCallbacks = []; + } + + onStateChange(callback: (state: PlaybackState) => void): void { + this.stateCallbacks.push(callback); + } + + onReady(callback: () => void): void { + this.readyCallbacks.push(callback); + } + + onError(callback: (error: { code: string; message: string }) => void): void { + this.errorCallbacks.push(callback); + } + + onProgress(callback: (currentTime: number, duration: number) => void): void { + this.progressCallbacks.push(callback); + } + + private startProgressPolling(): void { + this.stopProgressPolling(); + this.progressInterval = setInterval(() => { + if (this.player && !this.destroyed) { + const time = this.getCurrentTime(); + const duration = this.getDuration(); + this.progressCallbacks.forEach((cb) => cb(time, duration)); + } + }, 500); + } + + private stopProgressPolling(): void { + if (this.progressInterval) { + clearInterval(this.progressInterval); + this.progressInterval = null; + } + } + + private mapPlayerState(ytState: number): PlaybackState { + switch (ytState) { + case -1: + return 'loading'; + case 0: + return 'ended'; + case 1: + return 'playing'; + case 2: + return 'paused'; + case 3: + return 'buffering'; + case 5: + return 'loading'; + default: + return 'idle'; + } + } + + private mapError(errorCode: number): { code: string; message: string } { + const errors: Record = { + 2: 'Invalid video ID', + 5: 'HTML5 player error', + 100: 'Video not found or removed', + 101: 'Video cannot be embedded', + 150: 'Video cannot be embedded' + }; + return { + code: String(errorCode), + message: errors[errorCode] ?? `YouTube error: ${errorCode}` + }; + } +} diff --git a/frontend/src/lib/player/audioElement.spec.ts b/frontend/src/lib/player/audioElement.spec.ts new file mode 100644 index 0000000..1d9de80 --- /dev/null +++ b/frontend/src/lib/player/audioElement.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./audioEngine', () => { + const MockAudioEngine = vi.fn().mockImplementation(() => ({ + connect: vi.fn(), + destroy: vi.fn(), + isConnected: vi.fn(() => true) + })); + return { AudioEngine: MockAudioEngine }; +}); + +import { + _resetAudioElement, + getAudioElement, + getAudioEngine, + tryGetAudioEngine, + setAudioElement +} from './audioElement'; + +describe('audioElement registry', () => { + beforeEach(() => { + _resetAudioElement(); + vi.clearAllMocks(); + }); + + it('throws when getting audio element before registration', () => { + expect.assertions(1); + expect(() => getAudioElement()).toThrow('Audio element not mounted'); + }); + + it('returns registered audio element', () => { + expect.assertions(1); + const audio = { src: '' } as HTMLAudioElement; + setAudioElement(audio); + expect(getAudioElement()).toBe(audio); + }); + + it('allows replacing the registered audio element', () => { + expect.assertions(1); + const first = { src: '' } as HTMLAudioElement; + const second = { src: '' } as HTMLAudioElement; + setAudioElement(first); + setAudioElement(second); + expect(getAudioElement()).toBe(second); + }); + + it('is idempotent for the same element', () => { + expect.assertions(1); + const audio = { src: '' } as HTMLAudioElement; + setAudioElement(audio); + const engineFirst = tryGetAudioEngine(); + setAudioElement(audio); + const engineSecond = tryGetAudioEngine(); + expect(engineFirst).toBe(engineSecond); + }); + + it('throws getAudioEngine before registration', () => { + expect.assertions(1); + expect(() => getAudioEngine()).toThrow('Audio engine not initialized'); + }); + + it('returns engine after setAudioElement', () => { + expect.assertions(2); + const audio = { src: '' } as HTMLAudioElement; + setAudioElement(audio); + const engine = getAudioEngine(); + expect(engine).toBeDefined(); + expect(engine.connect).toBeDefined(); + }); + + it('tryGetAudioEngine returns null before registration', () => { + expect.assertions(1); + expect(tryGetAudioEngine()).toBeNull(); + }); + + it('tryGetAudioEngine returns engine after registration', () => { + expect.assertions(1); + const audio = { src: '' } as HTMLAudioElement; + setAudioElement(audio); + expect(tryGetAudioEngine()).not.toBeNull(); + }); + + it('_resetAudioElement destroys engine', () => { + expect.assertions(2); + const audio = { src: '' } as HTMLAudioElement; + setAudioElement(audio); + const engine = getAudioEngine(); + _resetAudioElement(); + expect(engine.destroy).toHaveBeenCalled(); + expect(tryGetAudioEngine()).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/player/audioElement.ts b/frontend/src/lib/player/audioElement.ts new file mode 100644 index 0000000..0b45046 --- /dev/null +++ b/frontend/src/lib/player/audioElement.ts @@ -0,0 +1,45 @@ +import { AudioEngine } from './audioEngine'; + +let audioElement: HTMLAudioElement | null = null; +let engine: AudioEngine | null = null; + +export function setAudioElement(el: HTMLAudioElement): void { + if (audioElement === el && engine) return; + if (engine) { + engine.destroy(); + engine = null; + } + audioElement = el; + try { + const newEngine = new AudioEngine(); + newEngine.connect(el); + engine = newEngine; + } catch { + // connect() can throw (InvalidStateError, SecurityError). + // Audio element is still usable without EQ — engine stays null. + } +} + +export function getAudioElement(): HTMLAudioElement { + if (!audioElement) { + throw new Error('Audio element not mounted — setAudioElement() must be called before playback'); + } + return audioElement; +} + +export function getAudioEngine(): AudioEngine { + if (!engine) { + throw new Error('Audio engine not initialized — setAudioElement() must be called first'); + } + return engine; +} + +export function tryGetAudioEngine(): AudioEngine | null { + return engine; +} + +export function _resetAudioElement(): void { + engine?.destroy(); + engine = null; + audioElement = null; +} \ No newline at end of file diff --git a/frontend/src/lib/player/audioEngine.spec.ts b/frontend/src/lib/player/audioEngine.spec.ts new file mode 100644 index 0000000..0304d3f --- /dev/null +++ b/frontend/src/lib/player/audioEngine.spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +function createMockFilter() { + return { + type: '' as string, + frequency: { value: 0 }, + Q: { value: 0 }, + gain: { value: 0 }, + connect: vi.fn(), + disconnect: vi.fn() + }; +} + +function createMockSource() { + return { + connect: vi.fn(), + disconnect: vi.fn() + }; +} + +function createMockContext(mockSource: ReturnType, mockFilterFactory: () => ReturnType) { + return { + state: 'suspended' as string, + destination: {}, + createMediaElementSource: vi.fn(() => mockSource), + createBiquadFilter: vi.fn(mockFilterFactory), + resume: vi.fn(() => Promise.resolve()), + close: vi.fn(() => Promise.resolve()) + }; +} + +vi.stubGlobal('AudioContext', vi.fn()); + +import { AudioEngine } from './audioEngine'; + +describe('AudioEngine', () => { + let engine: AudioEngine; + let mockSource: ReturnType; + let mockFilters: ReturnType[]; + let mockCtx: ReturnType; + const mockAudio = { src: '' } as unknown as HTMLAudioElement; + + beforeEach(() => { + vi.clearAllMocks(); + engine = new AudioEngine(); + mockSource = createMockSource(); + mockFilters = []; + + const filterFactory = () => { + const f = createMockFilter(); + mockFilters.push(f); + return f; + }; + + mockCtx = createMockContext(mockSource, filterFactory); + vi.mocked(AudioContext).mockImplementation(() => mockCtx as unknown as AudioContext); + }); + + describe('connect', () => { + it('creates context, source, and 10 filters wired in chain', () => { + expect.assertions(6); + engine.connect(mockAudio); + + expect(mockCtx.createMediaElementSource).toHaveBeenCalledWith(mockAudio); + expect(mockCtx.createBiquadFilter).toHaveBeenCalledTimes(10); + expect(mockFilters).toHaveLength(10); + expect(mockSource.connect).toHaveBeenCalledWith(mockFilters[0]); + expect(mockFilters[8].connect).toHaveBeenCalledWith(mockFilters[9]); + expect(mockFilters[9].connect).toHaveBeenCalledWith(mockCtx.destination); + }); + + it('sets correct frequencies and Q on filters', () => { + expect.assertions(3); + engine.connect(mockAudio); + + expect(mockFilters[0].frequency.value).toBe(31); + expect(mockFilters[9].frequency.value).toBe(16000); + expect(mockFilters[0].Q.value).toBe(1.4); + }); + + it('is idempotent for same element', () => { + expect.assertions(1); + engine.connect(mockAudio); + engine.connect(mockAudio); + + expect(mockCtx.createMediaElementSource).toHaveBeenCalledTimes(1); + }); + + it('destroys and reconnects for a different element', () => { + expect.assertions(2); + engine.connect(mockAudio); + const otherAudio = { src: 'other' } as unknown as HTMLAudioElement; + + engine.connect(otherAudio); + + expect(mockSource.disconnect).toHaveBeenCalled(); + expect(AudioContext).toHaveBeenCalledTimes(2); + }); + }); + + describe('setBandGain', () => { + it('sets gain on the correct filter', () => { + expect.assertions(1); + engine.connect(mockAudio); + engine.setBandGain(3, 6); + + expect(mockFilters[3].gain.value).toBe(6); + }); + + it('clamps gain to [-12, 12]', () => { + expect.assertions(2); + engine.connect(mockAudio); + engine.setBandGain(0, 20); + expect(mockFilters[0].gain.value).toBe(12); + + engine.setBandGain(0, -20); + expect(mockFilters[0].gain.value).toBe(-12); + }); + + it('ignores out-of-range index', () => { + expect.assertions(1); + engine.connect(mockAudio); + engine.setBandGain(15, 5); + engine.setBandGain(-1, 5); + + expect(mockFilters.every((f) => f.gain.value === 0)).toBe(true); + }); + }); + + describe('setAllGains', () => { + it('sets all 10 filter gains', () => { + expect.assertions(2); + engine.connect(mockAudio); + const gains = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + engine.setAllGains(gains); + + expect(mockFilters[0].gain.value).toBe(1); + expect(mockFilters[9].gain.value).toBe(10); + }); + }); + + describe('setEnabled', () => { + it('zeros all gains when disabled', () => { + expect.assertions(1); + engine.connect(mockAudio); + engine.setAllGains([5, 5, 5, 5, 5, 5, 5, 5, 5, 5]); + engine.setEnabled(false, [5, 5, 5, 5, 5, 5, 5, 5, 5, 5]); + + expect(mockFilters.every((f) => f.gain.value === 0)).toBe(true); + }); + + it('restores stored gains when enabled', () => { + expect.assertions(2); + engine.connect(mockAudio); + const stored = [3, -2, 1, 0, 4, -1, 2, 5, -3, 6]; + engine.setEnabled(true, stored); + + expect(mockFilters[0].gain.value).toBe(3); + expect(mockFilters[9].gain.value).toBe(6); + }); + }); + + describe('resume', () => { + it('calls context.resume when suspended', async () => { + expect.assertions(1); + engine.connect(mockAudio); + mockCtx.state = 'suspended'; + await engine.resume(); + + expect(mockCtx.resume).toHaveBeenCalled(); + }); + + it('does not call resume when already running', async () => { + expect.assertions(1); + engine.connect(mockAudio); + mockCtx.state = 'running'; + await engine.resume(); + + expect(mockCtx.resume).not.toHaveBeenCalled(); + }); + }); + + describe('isConnected', () => { + it('returns false before connect', () => { + expect.assertions(1); + expect(engine.isConnected()).toBe(false); + }); + + it('returns true after connect', () => { + expect.assertions(1); + engine.connect(mockAudio); + expect(engine.isConnected()).toBe(true); + }); + }); + + describe('destroy', () => { + it('disconnects all nodes and closes context', () => { + expect.assertions(4); + engine.connect(mockAudio); + engine.destroy(); + + expect(mockSource.disconnect).toHaveBeenCalled(); + expect(mockFilters[0].disconnect).toHaveBeenCalled(); + expect(mockCtx.close).toHaveBeenCalled(); + expect(engine.isConnected()).toBe(false); + }); + }); + + describe('getFrequencies', () => { + it('returns the 10 standard frequencies', () => { + expect.assertions(2); + const freqs = engine.getFrequencies(); + expect(freqs).toHaveLength(10); + expect(freqs[0]).toBe(31); + }); + }); +}); diff --git a/frontend/src/lib/player/audioEngine.ts b/frontend/src/lib/player/audioEngine.ts new file mode 100644 index 0000000..fa0217c --- /dev/null +++ b/frontend/src/lib/player/audioEngine.ts @@ -0,0 +1,92 @@ +import { EQ_FREQUENCIES, EQ_BAND_COUNT, EQ_MIN_GAIN, EQ_MAX_GAIN } from '../stores/eqPresets'; + +const DEFAULT_Q = 1.4; + +export class AudioEngine { + private context: AudioContext | null = null; + private source: MediaElementAudioSourceNode | null = null; + private filters: BiquadFilterNode[] = []; + private connectedElement: HTMLAudioElement | null = null; + + connect(audio: HTMLAudioElement): void { + if (this.connectedElement === audio) return; + if (this.connectedElement) { + this.destroy(); + } + + this.context = new AudioContext(); + this.source = this.context.createMediaElementSource(audio); + + this.filters = EQ_FREQUENCIES.map((freq) => { + const filter = this.context!.createBiquadFilter(); + filter.type = 'peaking'; + filter.frequency.value = freq; + filter.Q.value = DEFAULT_Q; + filter.gain.value = 0; + return filter; + }); + + let prev: AudioNode = this.source; + for (const filter of this.filters) { + prev.connect(filter); + prev = filter; + } + prev.connect(this.context.destination); + + this.connectedElement = audio; + } + + setBandGain(index: number, dB: number): void { + if (index < 0 || index >= EQ_BAND_COUNT || !this.filters[index]) return; + this.filters[index].gain.value = Math.max(EQ_MIN_GAIN, Math.min(EQ_MAX_GAIN, dB)); + } + + setAllGains(gains: readonly number[]): void { + for (let i = 0; i < EQ_BAND_COUNT; i++) { + if (this.filters[i]) { + this.filters[i].gain.value = Math.max( + EQ_MIN_GAIN, + Math.min(EQ_MAX_GAIN, gains[i] ?? 0) + ); + } + } + } + + setEnabled(enabled: boolean, storedGains: readonly number[]): void { + if (enabled) { + this.setAllGains(storedGains); + } else { + for (const filter of this.filters) { + filter.gain.value = 0; + } + } + } + + getFrequencies(): readonly number[] { + return EQ_FREQUENCIES; + } + + isConnected(): boolean { + return this.connectedElement !== null; + } + + async resume(): Promise { + if (this.context && this.context.state === 'suspended') { + await this.context.resume(); + } + } + + destroy(): void { + for (const filter of this.filters) { + filter.disconnect(); + } + this.source?.disconnect(); + if (this.context && this.context.state !== 'closed') { + void this.context.close(); + } + this.filters = []; + this.source = null; + this.context = null; + this.connectedElement = null; + } +} diff --git a/frontend/src/lib/player/createSource.spec.ts b/frontend/src/lib/player/createSource.spec.ts new file mode 100644 index 0000000..d58aaef --- /dev/null +++ b/frontend/src/lib/player/createSource.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { createPlaybackSource } from './createSource'; +import { YouTubePlaybackSource } from './YouTubePlaybackSource'; +import { NativeAudioSource } from './NativeAudioSource'; + +vi.mock('./audioElement', () => ({ + getAudioElement: vi.fn(() => ({ + src: '', + volume: 1, + currentTime: 0, + duration: 0, + ended: false, + play: vi.fn(() => Promise.resolve()), + pause: vi.fn(), + load: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + error: null, + })), +})); + +describe('createPlaybackSource', () => { + it('returns a YouTubePlaybackSource for "youtube"', () => { + const source = createPlaybackSource('youtube'); + expect(source).toBeInstanceOf(YouTubePlaybackSource); + }); + + it('returns a NativeAudioSource for "jellyfin"', () => { + const source = createPlaybackSource('jellyfin', { url: 'https://example.test/audio', seekable: true }); + expect(source).toBeInstanceOf(NativeAudioSource); + expect(source.type).toBe('jellyfin'); + }); + + it('returns a NativeAudioSource for "local"', () => { + const source = createPlaybackSource('local', { url: '/api/v1/stream/local/1', seekable: true }); + expect(source).toBeInstanceOf(NativeAudioSource); + expect(source.type).toBe('local'); + }); + + it('throws if jellyfin source options are missing', () => { + expect(() => createPlaybackSource('jellyfin')).toThrow('requires url and seekable options'); + }); + + it('throws if local source options are missing', () => { + expect(() => createPlaybackSource('local')).toThrow('requires url and seekable options'); + }); + + it('throws for unknown source type at runtime', () => { + expect(() => createPlaybackSource('unknown' as never)).toThrow('Unknown source type'); + }); +}); diff --git a/frontend/src/lib/player/createSource.ts b/frontend/src/lib/player/createSource.ts new file mode 100644 index 0000000..b1e224c --- /dev/null +++ b/frontend/src/lib/player/createSource.ts @@ -0,0 +1,29 @@ +import type { PlaybackSource, SourceType } from './types'; +import { YouTubePlaybackSource } from './YouTubePlaybackSource'; +import { NativeAudioSource } from './NativeAudioSource'; +import { YOUTUBE_PLAYER_ELEMENT_ID } from '$lib/constants'; + +export type NativeSourceOptions = { + url: string; + seekable: boolean; +}; + +export function createPlaybackSource(type: SourceType, opts?: NativeSourceOptions): PlaybackSource { + switch (type) { + case 'youtube': + return new YouTubePlaybackSource(YOUTUBE_PLAYER_ELEMENT_ID); + case 'jellyfin': + if (!opts) throw new Error('Jellyfin playback source requires url and seekable options'); + return new NativeAudioSource('jellyfin', opts); + case 'local': + if (!opts) throw new Error('Local playback source requires url and seekable options'); + return new NativeAudioSource('local', opts); + case 'navidrome': + if (!opts) throw new Error('Navidrome playback source requires url and seekable options'); + return new NativeAudioSource('navidrome', opts); + default: { + const _exhaustive: never = type; + throw new Error(`Unknown source type: ${_exhaustive}`); + } + } +} diff --git a/frontend/src/lib/player/jellyfinPlaybackApi.spec.ts b/frontend/src/lib/player/jellyfinPlaybackApi.spec.ts new file mode 100644 index 0000000..ce2f09f --- /dev/null +++ b/frontend/src/lib/player/jellyfinPlaybackApi.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockPost = vi.fn(); + +vi.mock('$lib/api/client', () => { + class _ApiError extends Error { + status: number; + code: string; + details: unknown; + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } + } + return { + api: { + global: { + post: (...args: unknown[]) => mockPost(...args), + }, + }, + ApiError: _ApiError, + }; +}); + +import * as api from './jellyfinPlaybackApi'; + +describe('jellyfinPlaybackApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('startSession', () => { + it('sends POST to start endpoint and returns play_session_id', async () => { + mockPost.mockResolvedValueOnce({ play_session_id: 'sess-123', item_id: 'item-456' }); + + const result = await api.startSession('item-456'); + + expect(result).toBe('sess-123'); + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/stream/jellyfin/item-456/start', + undefined + ); + }); + + it('sends existing play_session_id when provided', async () => { + mockPost.mockResolvedValueOnce({ play_session_id: 'sess-123', item_id: 'item-456' }); + + await api.startSession('item-456', 'sess-existing'); + + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/stream/jellyfin/item-456/start', + { play_session_id: 'sess-existing' } + ); + }); + + it('throws on non-ok response', async () => { + const { ApiError } = await import('$lib/api/client'); + mockPost.mockRejectedValueOnce(new ApiError(403, 'forbidden', 'Not allowed')); + + await expect(api.startSession('item-789')).rejects.toThrow( + 'Failed to start Jellyfin playback session' + ); + }); + }); + + describe('reportProgress', () => { + it('sends POST with correct body and returns true on success', async () => { + mockPost.mockResolvedValueOnce(undefined); + + const ok = await api.reportProgress('item-1', 'sess-1', 42.5, false); + + expect(ok).toBe(true); + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/stream/jellyfin/item-1/progress', + { + play_session_id: 'sess-1', + position_seconds: 42.5, + is_paused: false, + } + ); + }); + + it('warns on network errors without throwing', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockPost.mockRejectedValueOnce(new Error('Network down')); + + await expect(api.reportProgress('item-1', 'sess-1', 10, false)).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('network error')); + warnSpy.mockRestore(); + }); + + it('warns on non-ok response without throwing', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { ApiError } = await import('$lib/api/client'); + mockPost.mockRejectedValueOnce(new ApiError(500, 'server_error', 'Internal error')); + + await expect(api.reportProgress('item-1', 'sess-1', 10, false)).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('500')); + warnSpy.mockRestore(); + }); + }); + + describe('reportStop', () => { + it('sends POST with correct body and returns true on success', async () => { + mockPost.mockResolvedValueOnce(undefined); + + const ok = await api.reportStop('item-1', 'sess-1', 120.0); + + expect(ok).toBe(true); + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/stream/jellyfin/item-1/stop', + { + play_session_id: 'sess-1', + position_seconds: 120.0, + } + ); + }); + + it('swallows errors silently', async () => { + mockPost.mockRejectedValueOnce(new Error('Network down')); + + await expect(api.reportStop('item-1', 'sess-1', 60)).resolves.toBe(false); + }); + + it('warns on non-ok response without throwing', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { ApiError } = await import('$lib/api/client'); + mockPost.mockRejectedValueOnce(new ApiError(502, 'bad_gateway', 'Bad gateway')); + + await expect(api.reportStop('item-1', 'sess-1', 60)).resolves.toBe(false); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('502')); + warnSpy.mockRestore(); + }); + }); +}); diff --git a/frontend/src/lib/player/jellyfinPlaybackApi.ts b/frontend/src/lib/player/jellyfinPlaybackApi.ts new file mode 100644 index 0000000..729a9b9 --- /dev/null +++ b/frontend/src/lib/player/jellyfinPlaybackApi.ts @@ -0,0 +1,68 @@ +import { API } from '$lib/constants'; +import { api, ApiError } from '$lib/api/client'; + +type PlaybackSessionResult = { + play_session_id: string; + item_id: string; +}; + +type StartSessionPayload = { + play_session_id?: string; +}; + +export async function startSession(itemId: string, playSessionId?: string): Promise { + const payload: StartSessionPayload | undefined = playSessionId + ? { play_session_id: playSessionId } + : undefined; + + try { + const data = await api.global.post( + API.stream.jellyfinStart(itemId), + payload + ); + return data.play_session_id; + } catch (e) { + if (e instanceof ApiError) { + throw new Error(`Failed to start Jellyfin playback session: ${e.status} ${e.message}`); + } + throw e; + } +} + +export async function reportProgress( + itemId: string, + playSessionId: string, + positionSeconds: number, + isPaused: boolean +): Promise { + try { + await api.global.post(API.stream.jellyfinProgress(itemId), { + play_session_id: playSessionId, + position_seconds: positionSeconds, + is_paused: isPaused + }); + return true; + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Jellyfin] progress report failed: ${detail}`); + return false; + } +} + +export async function reportStop( + itemId: string, + playSessionId: string, + positionSeconds: number +): Promise { + try { + await api.global.post(API.stream.jellyfinStop(itemId), { + play_session_id: playSessionId, + position_seconds: positionSeconds + }); + return true; + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Jellyfin] stop report failed: ${detail}`); + return false; + } +} diff --git a/frontend/src/lib/player/launchJellyfinPlayback.ts b/frontend/src/lib/player/launchJellyfinPlayback.ts new file mode 100644 index 0000000..de93cfa --- /dev/null +++ b/frontend/src/lib/player/launchJellyfinPlayback.ts @@ -0,0 +1,35 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import { API } from '$lib/constants'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; +import type { JellyfinTrackInfo } from '$lib/types'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import { normalizeCodec } from '$lib/player/queueHelpers'; + +export function launchJellyfinPlayback( + tracks: JellyfinTrackInfo[], + startIndex: number = 0, + shuffle: boolean = false, + meta: PlaybackMeta +): void { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + + const items: QueueItem[] = tracks.map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.jellyfin_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: t.disc_number ?? 1, + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'jellyfin' as const, + artistId: meta.artistId, + streamUrl: API.stream.jellyfin(t.jellyfin_id), + format + }; + }); + + playerStore.playQueue(items, startIndex, shuffle); +} diff --git a/frontend/src/lib/player/launchLocalPlayback.ts b/frontend/src/lib/player/launchLocalPlayback.ts new file mode 100644 index 0000000..6ee6ee7 --- /dev/null +++ b/frontend/src/lib/player/launchLocalPlayback.ts @@ -0,0 +1,31 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import { API } from '$lib/constants'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; +import type { LocalTrackInfo } from '$lib/types'; +import { getCoverUrl } from '$lib/utils/errorHandling'; + +export function launchLocalPlayback( + tracks: LocalTrackInfo[], + startIndex: number = 0, + shuffle: boolean = false, + meta: PlaybackMeta +): void { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + + const items: QueueItem[] = tracks.map((t) => ({ + trackSourceId: String(t.track_file_id), + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: t.disc_number ?? 1, + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'local', + artistId: meta.artistId, + streamUrl: API.stream.local(t.track_file_id), + format: t.format.toLowerCase() + })); + + playerStore.playQueue(items, startIndex, shuffle); +} diff --git a/frontend/src/lib/player/launchNavidromePlayback.ts b/frontend/src/lib/player/launchNavidromePlayback.ts new file mode 100644 index 0000000..bf2dabe --- /dev/null +++ b/frontend/src/lib/player/launchNavidromePlayback.ts @@ -0,0 +1,35 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import { API } from '$lib/constants'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; +import type { NavidromeTrackInfo } from '$lib/types'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import { normalizeCodec } from '$lib/player/queueHelpers'; + +export function launchNavidromePlayback( + tracks: NavidromeTrackInfo[], + startIndex: number = 0, + shuffle: boolean = false, + meta: PlaybackMeta +): void { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + + const items: QueueItem[] = tracks.map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.navidrome_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: t.disc_number ?? 1, + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'navidrome' as const, + artistId: meta.artistId, + streamUrl: API.stream.navidrome(t.navidrome_id), + format + }; + }); + + playerStore.playQueue(items, startIndex, shuffle); +} diff --git a/frontend/src/lib/player/launchPlayback.spec.ts b/frontend/src/lib/player/launchPlayback.spec.ts new file mode 100644 index 0000000..e96788f --- /dev/null +++ b/frontend/src/lib/player/launchPlayback.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { LocalTrackInfo, JellyfinTrackInfo } from '$lib/types'; +import type { PlaybackMeta, QueueItem } from '$lib/player/types'; + +vi.mock('$lib/stores/player.svelte', () => ({ + playerStore: { playQueue: vi.fn() } +})); + +vi.mock('$lib/constants', () => ({ + API: { + stream: { + local: (id: number | string) => `/api/v1/stream/local/${id}`, + jellyfin: (id: string) => `/api/v1/stream/jellyfin/${id}` + } + } +})); + +import { playerStore } from '$lib/stores/player.svelte'; +import { launchLocalPlayback } from './launchLocalPlayback'; +import { launchJellyfinPlayback } from './launchJellyfinPlayback'; + +const meta: PlaybackMeta = { + albumId: 'album-1', + albumName: 'Test Album', + artistName: 'Test Artist', + coverUrl: '/cover.jpg', + artistId: 'artist-1' +}; + +describe('launchLocalPlayback', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps LocalTrackInfo[] to QueueItem[] with sourceType local', () => { + const tracks: LocalTrackInfo[] = [ + { + track_file_id: 42, + title: 'Song A', + track_number: 1, + format: 'FLAC', + size_bytes: 30_000_000 + } + ]; + + launchLocalPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const items: QueueItem[] = call[0]; + + expect(items).toHaveLength(1); + expect(items[0]).toEqual( + expect.objectContaining({ + trackSourceId: '42', + trackName: 'Song A', + sourceType: 'local', + streamUrl: '/api/v1/stream/local/42', + format: 'flac' + }) + ); + }); + + it('always sets a non-null coverUrl on queue items', () => { + const tracks: LocalTrackInfo[] = [ + { track_file_id: 1, title: 'A', track_number: 1, format: 'flac', size_bytes: 1000 } + ]; + const metaWithNullCover: PlaybackMeta = { ...meta, coverUrl: null }; + + launchLocalPlayback(tracks, 0, false, metaWithNullCover); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].coverUrl).toBeTruthy(); + expect(typeof items[0].coverUrl).toBe('string'); + }); + + it('passes startIndex and shuffle through to playerStore', () => { + const tracks: LocalTrackInfo[] = [ + { + track_file_id: 1, + title: 'A', + track_number: 1, + format: 'mp3', + size_bytes: 5_000_000 + }, + { + track_file_id: 2, + title: 'B', + track_number: 2, + format: 'mp3', + size_bytes: 5_000_000 + } + ]; + + launchLocalPlayback(tracks, 1, true, meta); + + expect(playerStore.playQueue).toHaveBeenCalledWith(expect.any(Array), 1, true); + }); +}); + +describe('launchJellyfinPlayback', () => { + beforeEach(() => vi.clearAllMocks()); + + it('maps JellyfinTrackInfo[] to QueueItem[] with sourceType jellyfin', () => { + const tracks: JellyfinTrackInfo[] = [ + { + jellyfin_id: 'jf-abc', + title: 'Jelly Song', + track_number: 3, + duration_seconds: 240, + album_name: 'Test Album', + artist_name: 'Test Artist', + codec: 'FLAC' + } + ]; + + launchJellyfinPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const items: QueueItem[] = call[0]; + + expect(items).toHaveLength(1); + expect(items[0]).toEqual( + expect.objectContaining({ + trackSourceId: 'jf-abc', + trackName: 'Jelly Song', + sourceType: 'jellyfin', + format: 'flac' + }) + ); + }); + + it('always sets a non-null coverUrl on queue items', () => { + const tracks: JellyfinTrackInfo[] = [ + { + jellyfin_id: 'jf-abc', + title: 'Song', + track_number: 1, + duration_seconds: 120, + album_name: 'Album', + artist_name: 'Artist', + codec: 'mp3' + } + ]; + const metaWithNullCover: PlaybackMeta = { ...meta, coverUrl: null }; + + launchJellyfinPlayback(tracks, 0, false, metaWithNullCover); + + const items: QueueItem[] = vi.mocked(playerStore.playQueue).mock.calls[0][0]; + expect(items[0].coverUrl).toBeTruthy(); + expect(typeof items[0].coverUrl).toBe('string'); + }); + + it('aligns streamUrl format parameter with QueueItem format', () => { + const tracks: JellyfinTrackInfo[] = [ + { + jellyfin_id: 'jf-abc', + title: 'Jelly Song', + track_number: 3, + duration_seconds: 240, + album_name: 'Test Album', + artist_name: 'Test Artist', + codec: 'FLAC' + } + ]; + + launchJellyfinPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const item: QueueItem = call[0][0]; + + expect(item.streamUrl).toBe('/api/v1/stream/jellyfin/jf-abc'); + expect(item.format).toBe('flac'); + }); + + it('falls back to aac when codec is null', () => { + const tracks: JellyfinTrackInfo[] = [ + { + jellyfin_id: 'jf-xyz', + title: 'No Codec', + track_number: 1, + duration_seconds: 180, + album_name: 'Test Album', + artist_name: 'Test Artist', + codec: null + } + ]; + + launchJellyfinPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const items: QueueItem[] = call[0]; + + expect(items[0].format).toBe('aac'); + }); + + it('falls back to aac when codec is undefined', () => { + const tracks: JellyfinTrackInfo[] = [ + { + jellyfin_id: 'jf-xyz', + title: 'No Codec', + track_number: 1, + duration_seconds: 180, + album_name: 'Test Album', + artist_name: 'Test Artist' + } + ]; + + launchJellyfinPlayback(tracks, 0, false, meta); + + const call = vi.mocked(playerStore.playQueue).mock.calls[0]; + const items: QueueItem[] = call[0]; + + expect(items[0].format).toBe('aac'); + }); +}); diff --git a/frontend/src/lib/player/launchTrackPlayback.ts b/frontend/src/lib/player/launchTrackPlayback.ts new file mode 100644 index 0000000..6978656 --- /dev/null +++ b/frontend/src/lib/player/launchTrackPlayback.ts @@ -0,0 +1,30 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import { buildQueueItemsFromYouTube, type TrackMeta } from '$lib/player/queueHelpers'; +import type { YouTubeTrackLink } from '$lib/types'; +import { getCoverUrl } from '$lib/utils/errorHandling'; + +export type TrackQueueOptions = { + albumId: string; + albumName: string; + artistName: string; + coverUrl: string | null; + artistId?: string; +}; + +export function launchTrackPlayback( + trackLinks: YouTubeTrackLink[], + startIndex: number = 0, + shuffle: boolean = false, + options: TrackQueueOptions +): void { + const meta: TrackMeta = { + albumId: options.albumId, + albumName: options.albumName, + artistName: options.artistName, + coverUrl: getCoverUrl(options.coverUrl, options.albumId), + artistId: options.artistId + }; + + const items = buildQueueItemsFromYouTube(trackLinks, meta); + playerStore.playQueue(items, startIndex, shuffle); +} diff --git a/frontend/src/lib/player/launchYouTubePlayback.ts b/frontend/src/lib/player/launchYouTubePlayback.ts new file mode 100644 index 0000000..4d87b7f --- /dev/null +++ b/frontend/src/lib/player/launchYouTubePlayback.ts @@ -0,0 +1,51 @@ +import { tick } from 'svelte'; +import { playerStore } from '$lib/stores/player.svelte'; +import { createPlaybackSource } from '$lib/player/createSource'; +import { getCoverUrl } from '$lib/utils/errorHandling'; + +export type YouTubePlaybackPayload = { + albumId: string; + albumName: string; + artistName: string; + videoId: string; + coverUrl?: string | null; + embedUrl?: string; + artistId?: string; +}; + +type LaunchYouTubePlaybackOptions = { + onLoadError?: (error: unknown) => void; + stopOnError?: boolean; +}; + +export async function launchYouTubePlayback( + payload: YouTubePlaybackPayload, + options: LaunchYouTubePlaybackOptions = {} +): Promise { + const { stopOnError = true, onLoadError } = options; + const normalizedCoverUrl = getCoverUrl(payload.coverUrl ?? null, payload.albumId); + + const source = createPlaybackSource('youtube'); + playerStore.playAlbum(source, { + albumId: payload.albumId, + albumName: payload.albumName, + artistName: payload.artistName, + coverUrl: normalizedCoverUrl, + sourceType: 'youtube', + trackSourceId: payload.videoId, + embedUrl: payload.embedUrl ?? `https://www.youtube.com/embed/${payload.videoId}`, + artistId: payload.artistId + }); + + await tick(); + + try { + await source.load({ trackSourceId: payload.videoId }); + } catch (error) { + if (stopOnError) { + playerStore.stop(); + } + onLoadError?.(error); + throw error; + } +} diff --git a/frontend/src/lib/player/navidromePlaybackApi.ts b/frontend/src/lib/player/navidromePlaybackApi.ts new file mode 100644 index 0000000..2e015f8 --- /dev/null +++ b/frontend/src/lib/player/navidromePlaybackApi.ts @@ -0,0 +1,25 @@ +import { API } from '$lib/constants'; +import { api, ApiError } from '$lib/api/client'; + +export async function reportNavidromeScrobble(itemId: string): Promise { + try { + const body = await api.global.post<{ status: string }>( + API.stream.navidromeScrobble(itemId) + ); + if (body.status !== 'ok') { + console.warn('[Navidrome] scrobble reported error'); + } + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Navidrome] scrobble failed: ${detail}`); + } +} + +export async function reportNavidromeNowPlaying(itemId: string): Promise { + try { + await api.global.post(API.stream.navidromeNowPlaying(itemId)); + } catch (e) { + const detail = e instanceof ApiError ? String(e.status) : 'network error'; + console.warn(`[Navidrome] now-playing failed: ${detail}`); + } +} diff --git a/frontend/src/lib/player/queueHelpers.spec.ts b/frontend/src/lib/player/queueHelpers.spec.ts new file mode 100644 index 0000000..8f6056a --- /dev/null +++ b/frontend/src/lib/player/queueHelpers.spec.ts @@ -0,0 +1,416 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('$lib/constants', () => ({ + API: { + stream: { + local: (id: number | string) => `/api/v1/stream/local/${id}`, + jellyfin: (id: string) => `/api/v1/stream/jellyfin/${id}` + } + } +})); + +vi.mock('$lib/utils/errorHandling', () => ({ + getCoverUrl: (url: string | null, albumId: string) => url ?? `/cover/${albumId}` +})); + +import type { JellyfinTrackInfo, LocalTrackInfo } from '$lib/types'; +import type { PlaylistTrack } from '$lib/api/playlists'; +import type { TrackMeta, TrackSourceData } from './queueHelpers'; +import { + selectBestSource, + getAvailableSources, + buildQueueItem, + buildQueueItemsFromJellyfin, + buildQueueItemsFromLocal, + buildQueueItemFromYouTube, + compareDiscTrack, + getDiscTrackKey, + playlistTrackToQueueItem +} from './queueHelpers'; + +const baseMeta: TrackMeta = { + albumId: 'album-1', + albumName: 'Test Album', + artistName: 'Artist A', + coverUrl: '/cover.jpg', + artistId: 'artist-1' +}; + +const localTrack: LocalTrackInfo = { + track_file_id: 42, + title: 'Local Song', + track_number: 1, + format: 'FLAC', + size_bytes: 30_000_000, + duration_seconds: 240 +}; + +const jellyfinTrack: JellyfinTrackInfo = { + jellyfin_id: 'jf-123', + title: 'JF Song', + track_number: 2, + duration_seconds: 180, + album_name: 'Test Album', + artist_name: 'Artist A', + codec: 'opus' +}; + +describe('selectBestSource', () => { + it('returns local source when localTrack is available', () => { + expect.assertions(3); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Track', + localTrack, + jellyfinTrack + }; + const result = selectBestSource(data); + expect(result).not.toBeNull(); + expect(result!.sourceType).toBe('local'); + expect(result!.streamUrl).toBe('/api/v1/stream/local/42'); + }); + + it('returns jellyfin source when only jellyfinTrack is available', () => { + expect.assertions(3); + const data: TrackSourceData = { + trackPosition: 2, + trackTitle: 'Track', + jellyfinTrack + }; + const result = selectBestSource(data); + expect(result).not.toBeNull(); + expect(result!.sourceType).toBe('jellyfin'); + expect(result!.trackSourceId).toBe('jf-123'); + }); + + it('returns null when no source is available', () => { + expect.assertions(1); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Track' + }; + expect(selectBestSource(data)).toBeNull(); + }); + + it('prefers local over jellyfin (Local > Jellyfin priority)', () => { + expect.assertions(1); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Track', + localTrack, + jellyfinTrack + }; + expect(selectBestSource(data)!.sourceType).toBe('local'); + }); +}); + +describe('getAvailableSources', () => { + it('returns both sources when both are available', () => { + expect.assertions(2); + const sources = getAvailableSources({ + trackPosition: 1, + trackTitle: 'Track', + localTrack, + jellyfinTrack + }); + expect(sources).toContain('local'); + expect(sources).toContain('jellyfin'); + }); + + it('returns only local when only local is available', () => { + expect.assertions(1); + const sources = getAvailableSources({ + trackPosition: 1, + trackTitle: 'Track', + localTrack + }); + expect(sources).toEqual(['local']); + }); + + it('returns only jellyfin when only jellyfin is available', () => { + expect.assertions(1); + const sources = getAvailableSources({ + trackPosition: 1, + trackTitle: 'Track', + jellyfinTrack + }); + expect(sources).toEqual(['jellyfin']); + }); + + it('returns empty array when no sources are available', () => { + expect.assertions(1); + const sources = getAvailableSources({ + trackPosition: 1, + trackTitle: 'Track' + }); + expect(sources).toEqual([]); + }); +}); + +describe('buildQueueItem', () => { + it('builds a queue item from local track data', () => { + expect.assertions(6); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Local Song', + trackLength: 240, + localTrack + }; + const item = buildQueueItem(baseMeta, data); + expect(item).not.toBeNull(); + expect(item!.trackName).toBe('Local Song'); + expect(item!.sourceType).toBe('local'); + expect(item!.albumId).toBe('album-1'); + expect(item!.availableSources).toEqual(['local']); + expect(item!.duration).toBe(240); + }); + + it('returns null when no source is available', () => { + expect.assertions(1); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'No Source' + }; + expect(buildQueueItem(baseMeta, data)).toBeNull(); + }); + + it('populates availableSources with both when both exist', () => { + expect.assertions(2); + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Dual Source', + localTrack, + jellyfinTrack + }; + const item = buildQueueItem(baseMeta, data); + expect(item!.availableSources).toContain('local'); + expect(item!.availableSources).toContain('jellyfin'); + }); + + it('uses getCoverUrl to normalize cover URL', () => { + expect.assertions(1); + const meta: TrackMeta = { ...baseMeta, coverUrl: null }; + const data: TrackSourceData = { + trackPosition: 1, + trackTitle: 'Track', + localTrack + }; + const item = buildQueueItem(meta, data); + expect(item!.coverUrl).toBe('/cover/album-1'); + }); + + it('preserves disc number on queue items', () => { + expect.assertions(1); + const item = buildQueueItem(baseMeta, { + trackPosition: 1, + discNumber: 2, + trackTitle: 'Disc Two Song', + localTrack + }); + expect(item!.discNumber).toBe(2); + }); +}); + +describe('disc-aware track helpers', () => { + it('builds a stable composite key from disc and track number', () => { + expect.assertions(2); + expect(getDiscTrackKey({ disc_number: 2, position: 5 })).toBe('2:5'); + expect(getDiscTrackKey({ track_number: 3 })).toBe('1:3'); + }); + + it('sorts tracks by disc before track number', () => { + expect.assertions(1); + const sorted = [ + { disc_number: 2, track_number: 1 }, + { disc_number: 1, track_number: 3 }, + { disc_number: 1, track_number: 1 } + ].sort(compareDiscTrack); + expect(sorted.map((track) => getDiscTrackKey(track))).toEqual(['1:1', '1:3', '2:1']); + }); + + it('carries disc number through youtube queue items', () => { + expect.assertions(1); + const item = buildQueueItemFromYouTube( + { + album_id: 'album-1', + album_name: 'Test Album', + artist_name: 'Artist A', + track_name: 'Disc Two Song', + track_number: 1, + disc_number: 2, + video_id: 'video-1', + embed_url: 'https://example.com/embed/video-1', + created_at: '2024-01-01T00:00:00Z' + }, + baseMeta + ); + expect(item.discNumber).toBe(2); + }); +}); + +describe('buildQueueItemsFromJellyfin', () => { + it('maps JellyfinTrackInfo array to QueueItem array', () => { + expect.assertions(5); + const tracks: JellyfinTrackInfo[] = [jellyfinTrack]; + const items = buildQueueItemsFromJellyfin(tracks, baseMeta); + expect(items).toHaveLength(1); + expect(items[0].sourceType).toBe('jellyfin'); + expect(items[0].trackName).toBe('JF Song'); + expect(items[0].availableSources).toEqual(['jellyfin']); + expect(items[0].duration).toBe(180); + }); + + it('normalizes codec for stream URL', () => { + expect.assertions(1); + const track: JellyfinTrackInfo = { ...jellyfinTrack, codec: 'ALAC' }; + const items = buildQueueItemsFromJellyfin([track], baseMeta); + expect(items[0].streamUrl).toBe('/api/v1/stream/jellyfin/jf-123'); + }); + + it('defaults to aac for unknown codecs', () => { + expect.assertions(1); + const track: JellyfinTrackInfo = { ...jellyfinTrack, codec: 'unknown_codec' }; + const items = buildQueueItemsFromJellyfin([track], baseMeta); + expect(items[0].streamUrl).toBe('/api/v1/stream/jellyfin/jf-123'); + }); + + it('defaults to aac for null codec', () => { + expect.assertions(1); + const track: JellyfinTrackInfo = { ...jellyfinTrack, codec: null }; + const items = buildQueueItemsFromJellyfin([track], baseMeta); + expect(items[0].streamUrl).toBe('/api/v1/stream/jellyfin/jf-123'); + }); +}); + +describe('buildQueueItemsFromLocal', () => { + it('maps LocalTrackInfo array to QueueItem array', () => { + expect.assertions(5); + const items = buildQueueItemsFromLocal([localTrack], baseMeta); + expect(items).toHaveLength(1); + expect(items[0].sourceType).toBe('local'); + expect(items[0].trackName).toBe('Local Song'); + expect(items[0].availableSources).toEqual(['local']); + expect(items[0].streamUrl).toBe('/api/v1/stream/local/42'); + }); + + it('lowercases format', () => { + expect.assertions(1); + const items = buildQueueItemsFromLocal([localTrack], baseMeta); + expect(items[0].format).toBe('flac'); + }); + + it('handles undefined duration_seconds', () => { + expect.assertions(1); + const track: LocalTrackInfo = { ...localTrack, duration_seconds: undefined }; + const items = buildQueueItemsFromLocal([track], baseMeta); + expect(items[0].duration).toBeUndefined(); + }); + + it('handles null duration_seconds', () => { + expect.assertions(1); + const track: LocalTrackInfo = { ...localTrack, duration_seconds: null }; + const items = buildQueueItemsFromLocal([track], baseMeta); + expect(items[0].duration).toBeUndefined(); + }); +}); + +describe('playlistTrackToQueueItem', () => { + const basePlaylistTrack: PlaylistTrack = { + id: 'pt-1', + position: 0, + track_name: 'Test Track', + artist_name: 'Test Artist', + album_name: 'Test Album', + album_id: 'album-1', + artist_id: 'artist-1', + track_source_id: '42', + cover_url: '/cover.jpg', + source_type: 'local', + available_sources: ['local', 'jellyfin'], + format: 'flac', + track_number: 1, + disc_number: 2, + duration: 240, + created_at: '2026-01-01T00:00:00Z' + }; + + it('maps local track to QueueItem with correct streamUrl', () => { + expect.assertions(4); + const item = playlistTrackToQueueItem(basePlaylistTrack)!; + expect(item).not.toBeNull(); + expect(item.sourceType).toBe('local'); + expect(item.streamUrl).toBe('/api/v1/stream/local/42'); + expect(item.trackName).toBe('Test Track'); + }); + + it('maps jellyfin track to QueueItem with correct streamUrl', () => { + expect.assertions(3); + const track: PlaylistTrack = { ...basePlaylistTrack, source_type: 'jellyfin', track_source_id: 'jf-123', format: 'opus' }; + const item = playlistTrackToQueueItem(track)!; + expect(item.sourceType).toBe('jellyfin'); + expect(item.streamUrl).toBe('/api/v1/stream/jellyfin/jf-123'); + expect(item.format).toBe('opus'); + }); + + it('maps youtube track with undefined streamUrl', () => { + expect.assertions(2); + const track: PlaylistTrack = { ...basePlaylistTrack, source_type: 'youtube', track_source_id: 'yt-abc' }; + const item = playlistTrackToQueueItem(track)!; + expect(item.sourceType).toBe('youtube'); + expect(item.streamUrl).toBeUndefined(); + }); + + it('returns null for tracks with null track_source_id', () => { + expect.assertions(1); + const track: PlaylistTrack = { ...basePlaylistTrack, track_source_id: null }; + expect(playlistTrackToQueueItem(track)).toBeNull(); + }); + + it('defaults available_sources to [sourceType] when null', () => { + expect.assertions(1); + const track: PlaylistTrack = { ...basePlaylistTrack, available_sources: null }; + const item = playlistTrackToQueueItem(track)!; + expect(item.availableSources).toEqual(['local']); + }); + + it('maps all fields correctly', () => { + expect.assertions(9); + const item = playlistTrackToQueueItem(basePlaylistTrack)!; + expect(item.trackSourceId).toBe('42'); + expect(item.artistName).toBe('Test Artist'); + expect(item.trackNumber).toBe(1); + expect(item.discNumber).toBe(2); + expect(item.albumId).toBe('album-1'); + expect(item.albumName).toBe('Test Album'); + expect(item.coverUrl).toBe('/cover.jpg'); + expect(item.artistId).toBe('artist-1'); + expect(item.availableSources).toEqual(['local', 'jellyfin']); + }); + + it('handles null album_id by defaulting to empty string', () => { + expect.assertions(1); + const track: PlaylistTrack = { ...basePlaylistTrack, album_id: null }; + const item = playlistTrackToQueueItem(track)!; + expect(item.albumId).toBe(''); + }); + + it('falls back to position when track_number is null', () => { + expect.assertions(1); + const track: PlaylistTrack = { ...basePlaylistTrack, track_number: null, position: 5 }; + const item = playlistTrackToQueueItem(track)!; + expect(item.trackNumber).toBe(5); + }); + + it('uses aac as default format for jellyfin when format is null', () => { + expect.assertions(1); + const track: PlaylistTrack = { ...basePlaylistTrack, source_type: 'jellyfin', track_source_id: 'jf-1', format: null }; + const item = playlistTrackToQueueItem(track)!; + expect(item.streamUrl).toBe('/api/v1/stream/jellyfin/jf-1'); + }); + + it('populates playlistTrackId from playlist track id', () => { + expect.assertions(1); + const item = playlistTrackToQueueItem(basePlaylistTrack)!; + expect(item.playlistTrackId).toBe('pt-1'); + }); +}); diff --git a/frontend/src/lib/player/queueHelpers.ts b/frontend/src/lib/player/queueHelpers.ts new file mode 100644 index 0000000..392e2f9 --- /dev/null +++ b/frontend/src/lib/player/queueHelpers.ts @@ -0,0 +1,267 @@ +import type { QueueItem, SourceType } from '$lib/player/types'; +import type { JellyfinTrackInfo, LocalTrackInfo, NavidromeTrackInfo, YouTubeTrackLink } from '$lib/types'; +import type { PlaylistTrack } from '$lib/api/playlists'; +import { API } from '$lib/constants'; +import { getCoverUrl } from '$lib/utils/errorHandling'; + +const SUPPORTED_CODECS = new Set(['aac', 'mp3', 'opus', 'flac', 'wav', 'wma', 'vorbis', 'alac']); +const CODEC_ALIASES: Record = { alac: 'flac', wma: 'aac' }; + +export function normalizeCodec(codec: string | undefined | null): string { + const raw = codec?.toLowerCase() ?? 'aac'; + if (CODEC_ALIASES[raw]) return CODEC_ALIASES[raw]; + if (SUPPORTED_CODECS.has(raw)) return raw; + return 'aac'; +} + +export interface TrackMeta { + albumId: string; + albumName: string; + artistName: string; + coverUrl: string | null; + artistId?: string; +} + +export interface TrackSourceData { + trackPosition: number; + discNumber?: number; + trackTitle: string; + trackLength?: number; + jellyfinTrack?: JellyfinTrackInfo | null; + navidromeTrack?: NavidromeTrackInfo | null; + localTrack?: LocalTrackInfo | null; +} + +export function normalizeDiscNumber(discNumber: number | null | undefined): number { + return Number.isFinite(Number(discNumber)) && Number(discNumber) > 0 ? Number(discNumber) : 1; +} + +export function getDiscTrackKey(track: { + disc_number?: number | null; + track_number?: number | null; + position?: number | null; +}): string { + const discNumber = normalizeDiscNumber(track.disc_number); + const trackNumber = Number(track.track_number ?? track.position ?? 0); + return `${discNumber}:${trackNumber}`; +} + +export function compareDiscTrack( + a: { disc_number?: number | null; track_number?: number | null; position?: number | null }, + b: { disc_number?: number | null; track_number?: number | null; position?: number | null } +): number { + const discDiff = normalizeDiscNumber(a.disc_number) - normalizeDiscNumber(b.disc_number); + if (discDiff !== 0) return discDiff; + return Number(a.track_number ?? a.position ?? 0) - Number(b.track_number ?? b.position ?? 0); +} + +export function selectBestSource( + data: TrackSourceData +): { sourceType: SourceType; trackSourceId: string; streamUrl: string; format?: string } | null { + if (data.localTrack) { + const format = data.localTrack.format.toLowerCase(); + return { + sourceType: 'local', + trackSourceId: String(data.localTrack.track_file_id), + streamUrl: API.stream.local(data.localTrack.track_file_id), + format + }; + } + if (data.navidromeTrack) { + const format = normalizeCodec(data.navidromeTrack.codec); + return { + sourceType: 'navidrome', + trackSourceId: data.navidromeTrack.navidrome_id, + streamUrl: API.stream.navidrome(data.navidromeTrack.navidrome_id), + format + }; + } + if (data.jellyfinTrack) { + const format = normalizeCodec(data.jellyfinTrack.codec); + return { + sourceType: 'jellyfin', + trackSourceId: data.jellyfinTrack.jellyfin_id, + streamUrl: API.stream.jellyfin(data.jellyfinTrack.jellyfin_id), + format + }; + } + return null; +} + +export function getAvailableSources(data: TrackSourceData): SourceType[] { + const sources: SourceType[] = []; + if (data.localTrack) sources.push('local'); + if (data.navidromeTrack) sources.push('navidrome'); + if (data.jellyfinTrack) sources.push('jellyfin'); + return sources; +} + +export function buildQueueItem(meta: TrackMeta, data: TrackSourceData): QueueItem | null { + const best = selectBestSource(data); + if (!best) return null; + + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + + const sourceIds: Partial> = {}; + if (data.localTrack) sourceIds.local = String(data.localTrack.track_file_id); + if (data.navidromeTrack) sourceIds.navidrome = data.navidromeTrack.navidrome_id; + if (data.jellyfinTrack) sourceIds.jellyfin = data.jellyfinTrack.jellyfin_id; + + return { + trackSourceId: best.trackSourceId, + trackName: data.trackTitle, + artistName: meta.artistName, + trackNumber: data.trackPosition, + discNumber: data.discNumber ?? 1, + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: best.sourceType, + artistId: meta.artistId, + streamUrl: best.streamUrl, + format: best.format, + availableSources: getAvailableSources(data), + sourceIds, + duration: data.trackLength + }; +} + +export function buildQueueItemsFromJellyfin( + tracks: JellyfinTrackInfo[], + meta: TrackMeta +): QueueItem[] { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + return tracks.map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.jellyfin_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'jellyfin' as const, + artistId: meta.artistId, + streamUrl: API.stream.jellyfin(t.jellyfin_id), + format, + availableSources: ['jellyfin'] as SourceType[], + duration: t.duration_seconds + }; + }); +} + +export function buildQueueItemsFromNavidrome( + tracks: NavidromeTrackInfo[], + meta: TrackMeta +): QueueItem[] { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + return tracks.map((t) => { + const format = normalizeCodec(t.codec); + return { + trackSourceId: t.navidrome_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'navidrome' as const, + artistId: meta.artistId, + streamUrl: API.stream.navidrome(t.navidrome_id), + format, + availableSources: ['navidrome'] as SourceType[], + duration: t.duration_seconds + }; + }); +} + +export function buildQueueItemsFromLocal( + tracks: LocalTrackInfo[], + meta: TrackMeta +): QueueItem[] { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + return tracks.map((t) => ({ + trackSourceId: String(t.track_file_id), + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + discNumber: normalizeDiscNumber(t.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'local' as const, + artistId: meta.artistId, + streamUrl: API.stream.local(t.track_file_id), + format: t.format.toLowerCase(), + availableSources: ['local'] as SourceType[], + duration: t.duration_seconds ?? undefined + })); +} + +export function buildQueueItemFromYouTube( + track: YouTubeTrackLink, + meta: TrackMeta +): QueueItem { + const normalizedCoverUrl = getCoverUrl(meta.coverUrl, meta.albumId); + return { + trackSourceId: track.video_id, + trackName: track.track_name, + artistName: meta.artistName, + trackNumber: track.track_number, + discNumber: normalizeDiscNumber(track.disc_number), + albumId: meta.albumId, + albumName: meta.albumName, + coverUrl: normalizedCoverUrl, + sourceType: 'youtube' as const, + artistId: meta.artistId, + availableSources: ['youtube'] as SourceType[] + }; +} + +export function buildQueueItemsFromYouTube( + tracks: YouTubeTrackLink[], + meta: TrackMeta +): QueueItem[] { + return tracks.map((t) => buildQueueItemFromYouTube(t, meta)); +} + +function resolveStreamUrl( + sourceType: string, + trackSourceId: string, + format?: string | null +): string | undefined { + if (sourceType === 'local') return API.stream.local(trackSourceId); + if (sourceType === 'navidrome') return API.stream.navidrome(trackSourceId); + if (sourceType === 'jellyfin') return API.stream.jellyfin(trackSourceId); + return undefined; +} + +export function playlistTrackToQueueItem(track: PlaylistTrack): QueueItem | null { + if (!track.track_source_id) return null; + + const sourceType = track.source_type as SourceType; + const availableSources: SourceType[] = track.available_sources + ? (track.available_sources as SourceType[]) + : [sourceType]; + + return { + trackSourceId: track.track_source_id, + trackName: track.track_name, + artistName: track.artist_name, + trackNumber: track.track_number ?? track.position, + discNumber: track.disc_number ?? 1, + albumId: track.album_id ?? '', + albumName: track.album_name, + coverUrl: track.cover_url, + sourceType, + artistId: track.artist_id ?? undefined, + streamUrl: resolveStreamUrl(sourceType, track.track_source_id, track.format), + format: track.format ?? undefined, + availableSources, + duration: track.duration ?? undefined, + playlistTrackId: track.id + }; +} diff --git a/frontend/src/lib/player/types.ts b/frontend/src/lib/player/types.ts new file mode 100644 index 0000000..3507ce0 --- /dev/null +++ b/frontend/src/lib/player/types.ts @@ -0,0 +1,84 @@ +export type PlaybackState = + | 'idle' + | 'loading' + | 'playing' + | 'paused' + | 'ended' + | 'buffering' + | 'error'; + +export type SourceType = 'youtube' | 'local' | 'jellyfin' | 'navidrome'; + +export type QueueOrigin = 'context' | 'manual'; + +export interface PlaybackSource { + readonly type: SourceType; + + load(info: { + trackSourceId?: string; + url?: string; + token?: string; + format?: string; + duration?: number; + }): Promise; + play(): void; + pause(): void; + seekTo(seconds: number): void; + setVolume(level: number): void; + getCurrentTime(): number; + getDuration(): number; + isSeekable?(): boolean; + destroy(): void; + + onStateChange(callback: (state: PlaybackState) => void): void; + onReady(callback: () => void): void; + onError(callback: (error: { code: string; message: string }) => void): void; + onProgress(callback: (currentTime: number, duration: number) => void): void; +} + +export interface NowPlaying { + albumId: string; + albumName: string; + artistName: string; + coverUrl: string | null; + sourceType: SourceType; + discNumber?: number; + trackSourceId?: string; + embedUrl?: string; + trackName?: string; + artistId?: string; + streamUrl?: string; + format?: string; + playlistTrackId?: string; +} + +export type PlaybackMeta = { + albumId: string; + albumName: string; + artistName: string; + coverUrl: string | null; + artistId?: string; +}; + +export interface QueueItem { + /** Source-specific item identifier (Jellyfin item ID / local file ID / YouTube video ID) */ + trackSourceId: string; + trackName: string; + artistName: string; + trackNumber: number; + discNumber?: number; + albumId: string; + albumName: string; + coverUrl: string | null; + sourceType: SourceType; + artistId?: string; + streamUrl?: string; + format?: string; + availableSources?: SourceType[]; + sourceIds?: Partial>; + duration?: number; + playSessionId?: string; + queueOrigin?: QueueOrigin; + /** Stable playlist-level track identifier — survives source changes */ + playlistTrackId?: string; +} diff --git a/frontend/src/lib/stores/cacheTtl.ts b/frontend/src/lib/stores/cacheTtl.ts new file mode 100644 index 0000000..62378da --- /dev/null +++ b/frontend/src/lib/stores/cacheTtl.ts @@ -0,0 +1,90 @@ +import { browser } from '$app/environment'; +import { CACHE_TTL } from '$lib/constants'; +import { api } from '$lib/api/client'; +import { updateHomeCacheTTL } from '$lib/utils/homeCache'; +import { updateDiscoverCacheTTL } from '$lib/utils/discoverCache'; +import { updateDiscoveryCacheTTL } from '$lib/stores/discoveryCache'; +import { updateDiscoverQueueCacheTTL } from '$lib/utils/discoverQueueCache'; +import { updateSearchCacheTTL } from '$lib/stores/search'; +import { updateJellyfinSidebarCacheTTL } from '$lib/utils/jellyfinLibraryCache'; +import { updateLocalFilesSidebarCacheTTL } from '$lib/utils/localFilesCache'; +import { libraryStore } from '$lib/stores/library'; +import { recentlyAddedStore } from '$lib/stores/recentlyAdded'; + +export interface CacheTTLs { + home: number; + discover: number; + library: number; + recentlyAdded: number; + discoverQueue: number; + search: number; + localFilesSidebar: number; + jellyfinSidebar: number; + playlistSources: number; + discoverQueuePollingInterval: number; + discoverQueueAutoGenerate: boolean; +} + +const DEFAULTS: CacheTTLs = { + home: CACHE_TTL.HOME, + discover: CACHE_TTL.DISCOVER, + library: CACHE_TTL.LIBRARY, + recentlyAdded: CACHE_TTL.RECENTLY_ADDED, + discoverQueue: CACHE_TTL.DISCOVER_QUEUE, + search: CACHE_TTL.SEARCH, + localFilesSidebar: CACHE_TTL.LOCAL_FILES_SIDEBAR, + jellyfinSidebar: CACHE_TTL.JELLYFIN_SIDEBAR, + playlistSources: CACHE_TTL.PLAYLIST_SOURCES, + discoverQueuePollingInterval: 4000, + discoverQueueAutoGenerate: true +}; + +let resolved: CacheTTLs = { ...DEFAULTS }; +let initialized = false; + +function applyTTLs(ttls: CacheTTLs): void { + updateHomeCacheTTL(ttls.home); + updateDiscoverCacheTTL(ttls.discover); + libraryStore.updateCacheTTL(ttls.library); + recentlyAddedStore.updateCacheTTL(ttls.recentlyAdded); + updateDiscoveryCacheTTL(ttls.discover); + updateDiscoverQueueCacheTTL(ttls.discoverQueue); + updateSearchCacheTTL(ttls.search); + updateLocalFilesSidebarCacheTTL(ttls.localFilesSidebar); + updateJellyfinSidebarCacheTTL(ttls.jellyfinSidebar); +} + +export async function initCacheTTLs(): Promise { + if (!browser || initialized) return; + initialized = true; + + try { + const data = await api.global.get>('/api/v1/settings/cache-ttls'); + resolved = { + home: (data.home as number) ?? DEFAULTS.home, + discover: (data.discover as number) ?? DEFAULTS.discover, + library: (data.library as number) ?? DEFAULTS.library, + recentlyAdded: (data.recently_added as number) ?? DEFAULTS.recentlyAdded, + discoverQueue: (data.discover_queue as number) ?? DEFAULTS.discoverQueue, + search: (data.search as number) ?? DEFAULTS.search, + localFilesSidebar: (data.local_files_sidebar as number) ?? DEFAULTS.localFilesSidebar, + jellyfinSidebar: (data.jellyfin_sidebar as number) ?? DEFAULTS.jellyfinSidebar, + playlistSources: (data.playlist_sources as number) ?? DEFAULTS.playlistSources, + discoverQueuePollingInterval: + (data.discover_queue_polling_interval as number) ?? DEFAULTS.discoverQueuePollingInterval, + discoverQueueAutoGenerate: + (data.discover_queue_auto_generate as boolean) ?? DEFAULTS.discoverQueueAutoGenerate + }; + applyTTLs(resolved); + } catch (e) { + console.warn('[cacheTtl] Failed to load cache TTL settings, using defaults', e); + } +} + +export function getCacheTTLs(): CacheTTLs { + return resolved; +} + +export function getCacheTTL(key: K): CacheTTLs[K] { + return resolved[key]; +} diff --git a/frontend/src/lib/stores/discoverQueueStatus.ts b/frontend/src/lib/stores/discoverQueueStatus.ts new file mode 100644 index 0000000..f251145 --- /dev/null +++ b/frontend/src/lib/stores/discoverQueueStatus.ts @@ -0,0 +1,156 @@ +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { API } from '$lib/constants'; +import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource'; +import { getCacheTTLs } from '$lib/stores/cacheTtl'; +import { api, ApiError } from '$lib/api/client'; + +export type QueueBuildStatus = 'idle' | 'building' | 'ready' | 'error' | 'unknown'; + +interface DiscoverQueueStatusState { + status: QueueBuildStatus; + source: string; + queueId?: string; + itemCount?: number; + error?: string; + lastChecked: number; +} + +type QueueStatusPayload = { + status: QueueBuildStatus; + source: string; + queue_id?: string; + item_count?: number; + error?: string; +}; + +const INITIAL: DiscoverQueueStatusState = { + status: 'unknown', + source: 'listenbrainz', + lastChecked: 0, +}; + +function createDiscoverQueueStatusStore() { + const { subscribe, set, update } = writable({ ...INITIAL }); + + let pollTimer: ReturnType | null = null; + let isPolling = false; + + function getPollingInterval(): number { + return getCacheTTLs().discoverQueuePollingInterval; + } + + function isAutoGenerateEnabled(): boolean { + return getCacheTTLs().discoverQueueAutoGenerate; + } + + function applyStatusData( + data: QueueStatusPayload + ): void { + set({ + status: data.status, + source: data.source, + queueId: data.queue_id, + itemCount: data.item_count, + error: data.error, + lastChecked: Date.now(), + }); + } + + function resolveSource(source?: MusicSource): MusicSource { + return source ?? musicSourceStore.getPageSource('discover'); + } + + async function fetchStatus(source?: MusicSource): Promise { + if (!browser) return null; + try { + const activeSource = resolveSource(source); + const data = await api.global.get(API.discoverQueueStatus(activeSource)); + applyStatusData(data); + return data; + } catch { + return null; + } + } + + async function triggerGenerate(force = false, source?: MusicSource): Promise { + if (!browser) return; + try { + const activeSource = resolveSource(source); + update((s) => ({ ...s, status: 'building', source: activeSource })); + const data = await api.global.post(API.discoverQueueGenerate(), { source: activeSource, force }); + applyStatusData(data); + if (data.status === 'building') { + startPolling(activeSource); + } + } catch (e) { + if (e instanceof ApiError) { + update((s) => ({ + ...s, + status: 'error', + error: `Server responded with ${e.status}`, + })); + } else { + update((s) => ({ ...s, status: 'error', error: 'Failed to trigger generation' })); + } + } + } + + function startPolling(source?: MusicSource): void { + if (pollTimer || !browser) return; + isPolling = true; + const interval = getPollingInterval(); + pollTimer = setInterval(async () => { + const result = await fetchStatus(source); + if (result && result.status !== 'building') { + stopPolling(); + } + }, interval); + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + isPolling = false; + } + + async function init(source?: MusicSource): Promise { + if (!browser) return; + const activeSource = resolveSource(source); + const result = await fetchStatus(activeSource); + if (!result) return; + + if (result.status === 'building') { + startPolling(activeSource); + } else if (result.status === 'idle' && isAutoGenerateEnabled()) { + await triggerGenerate(false, activeSource); + } + } + + function reset(): void { + stopPolling(); + set({ ...INITIAL }); + } + + function markConsumed(): void { + update((s) => ({ ...s, status: 'idle', queueId: undefined, itemCount: undefined })); + } + + return { + subscribe, + fetchStatus, + triggerGenerate, + startPolling, + stopPolling, + init, + reset, + markConsumed, + get isPolling() { + return isPolling; + }, + }; +} + +export const discoverQueueStatusStore = createDiscoverQueueStatusStore(); diff --git a/frontend/src/lib/stores/discoveryCache.ts b/frontend/src/lib/stores/discoveryCache.ts new file mode 100644 index 0000000..94e104e --- /dev/null +++ b/frontend/src/lib/stores/discoveryCache.ts @@ -0,0 +1,47 @@ +import type { SimilarArtistsResponse, TopSongsResponse, TopAlbumsResponse, SimilarAlbumsResponse, MoreByArtistResponse } from '$lib/types'; + +interface ArtistDiscoveryCache { + similarArtists: SimilarArtistsResponse | null; + topSongs: TopSongsResponse | null; + topAlbums: TopAlbumsResponse | null; + timestamp: number; +} + +interface AlbumDiscoveryCache { + similarAlbums: SimilarAlbumsResponse | null; + moreByArtist: MoreByArtistResponse | null; + timestamp: number; +} + +let CACHE_TTL_MS = 5 * 60 * 1000; + +const artistCache = new Map(); +const albumCache = new Map(); + +export function updateDiscoveryCacheTTL(ttlMs: number): void { + CACHE_TTL_MS = ttlMs; +} + +export function getArtistDiscoveryCache(artistId: string): ArtistDiscoveryCache | null { + const cached = artistCache.get(artistId); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached; + } + return null; +} + +export function setArtistDiscoveryCache(artistId: string, data: Omit) { + artistCache.set(artistId, { ...data, timestamp: Date.now() }); +} + +export function getAlbumDiscoveryCache(albumId: string): AlbumDiscoveryCache | null { + const cached = albumCache.get(albumId); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached; + } + return null; +} + +export function setAlbumDiscoveryCache(albumId: string, data: Omit) { + albumCache.set(albumId, { ...data, timestamp: Date.now() }); +} diff --git a/frontend/src/lib/stores/eq.spec.ts b/frontend/src/lib/stores/eq.spec.ts new file mode 100644 index 0000000..c2d7ab9 --- /dev/null +++ b/frontend/src/lib/stores/eq.spec.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockEngine = { + setAllGains: vi.fn(), + setEnabled: vi.fn(), + isConnected: vi.fn(() => true) +}; + +vi.mock('$lib/player/audioElement', () => ({ + tryGetAudioEngine: vi.fn(() => mockEngine) +})); + +const STORAGE_KEY = 'musicseerr_eq_settings'; + +const storage = new Map(); +const mockLocalStorage = { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => storage.set(key, value)), + removeItem: vi.fn((key: string) => storage.delete(key)), + clear: vi.fn(() => storage.clear()), + get length() { + return storage.size; + }, + key: vi.fn((_i: number) => null) +}; + +vi.stubGlobal('localStorage', mockLocalStorage); +vi.stubGlobal('window', globalThis); + +describe('eqStore', () => { + let eqStore: typeof import('./eq.svelte')['eqStore']; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.clearAllMocks(); + storage.clear(); + vi.resetModules(); + const mod = await import('./eq.svelte'); + eqStore = mod.eqStore; + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + storage.clear(); + }); + + it('initializes with defaults when no localStorage data', () => { + expect.assertions(3); + expect(eqStore.enabled).toBe(false); + expect(eqStore.gains).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(eqStore.activePreset).toBe('Flat'); + }); + + it('restores valid settings from localStorage', async () => { + expect.assertions(3); + const stored = { enabled: true, gains: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], activePreset: 'Rock' }; + storage.set(STORAGE_KEY, JSON.stringify(stored)); + vi.resetModules(); + const mod = await import('./eq.svelte'); + const store = mod.eqStore; + + expect(store.enabled).toBe(true); + expect(store.gains).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(store.activePreset).toBe('Rock'); + }); + + it('falls back to defaults on malformed localStorage data', async () => { + expect.assertions(2); + storage.set(STORAGE_KEY, '{"enabled":"notabool"}'); + vi.resetModules(); + const mod = await import('./eq.svelte'); + const store = mod.eqStore; + + expect(store.enabled).toBe(false); + expect(store.gains).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + }); + + it('clamps out-of-range gains from localStorage', async () => { + expect.assertions(2); + const stored = { enabled: false, gains: [20, -20, 0, 0, 0, 0, 0, 0, 0, 0], activePreset: null }; + storage.set(STORAGE_KEY, JSON.stringify(stored)); + vi.resetModules(); + const mod = await import('./eq.svelte'); + const store = mod.eqStore; + + expect(store.gains[0]).toBe(12); + expect(store.gains[1]).toBe(-12); + }); + + it('toggleEq flips enabled and syncs to engine', () => { + expect.assertions(2); + eqStore.toggleEq(); + expect(eqStore.enabled).toBe(true); + expect(mockEngine.setAllGains).toHaveBeenCalled(); + }); + + it('setBandGain updates a single band and clamps', () => { + expect.assertions(2); + eqStore.setBandGain(3, 15); + expect(eqStore.gains[3]).toBe(12); + eqStore.setBandGain(3, -15); + expect(eqStore.gains[3]).toBe(-12); + }); + + it('setBandGain ignores out-of-range index', () => { + expect.assertions(1); + eqStore.setBandGain(15, 5); + expect(eqStore.gains).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + }); + + it('applyPreset sets all gains to preset values', () => { + expect.assertions(2); + eqStore.applyPreset('Rock'); + expect(eqStore.gains).toEqual([5, 4, 3, 1, -1, 1, 3, 4, 5, 5]); + expect(eqStore.activePreset).toBe('Rock'); + }); + + it('resetToFlat applies the Flat preset', () => { + expect.assertions(2); + eqStore.applyPreset('Rock'); + eqStore.resetToFlat(); + expect(eqStore.gains).toEqual([0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(eqStore.activePreset).toBe('Flat'); + }); + + it('detects custom when gains do not match any preset', () => { + expect.assertions(1); + eqStore.setBandGain(0, 7); + expect(eqStore.activePreset).toBeNull(); + }); + + it('detects matching preset after manual band changes', () => { + expect.assertions(1); + const rock = [5, 4, 3, 1, -1, 1, 3, 4, 5, 5]; + for (let i = 0; i < rock.length; i++) { + eqStore.setBandGain(i, rock[i]); + } + expect(eqStore.activePreset).toBe('Rock'); + }); + + it('persists to localStorage with debounce', () => { + expect.assertions(1); + eqStore.toggleEq(); + vi.advanceTimersByTime(200); + const stored = storage.get(STORAGE_KEY) ?? null; + expect(stored).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/stores/eq.svelte.ts b/frontend/src/lib/stores/eq.svelte.ts new file mode 100644 index 0000000..93cd2d6 --- /dev/null +++ b/frontend/src/lib/stores/eq.svelte.ts @@ -0,0 +1,135 @@ +import { EQ_BAND_COUNT, EQ_MIN_GAIN, EQ_MAX_GAIN, EQ_PRESETS, type EqPresetName } from '$lib/stores/eqPresets'; +import { tryGetAudioEngine } from '$lib/player/audioElement'; + +const STORAGE_KEY = 'musicseerr_eq_settings'; +const PERSIST_DEBOUNCE_MS = 150; + +interface StoredEqSettings { + enabled: boolean; + gains: number[]; + activePreset: string | null; +} + +function clampGain(v: number): number { + return Math.max(EQ_MIN_GAIN, Math.min(EQ_MAX_GAIN, v)); +} + +function loadFromStorage(): StoredEqSettings | null { + if (typeof window === 'undefined') return null; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if ( + typeof parsed !== 'object' || + parsed === null || + typeof parsed.enabled !== 'boolean' || + !Array.isArray(parsed.gains) || + parsed.gains.length !== EQ_BAND_COUNT || + !parsed.gains.every((v: unknown) => typeof v === 'number' && Number.isFinite(v)) + ) { + return null; + } + return { + enabled: parsed.enabled, + gains: parsed.gains.map(clampGain), + activePreset: typeof parsed.activePreset === 'string' ? parsed.activePreset : null + }; + } catch { + return null; + } +} + +function createEqStore() { + const stored = loadFromStorage(); + + let enabled = $state(stored?.enabled ?? false); + let gains = $state(stored?.gains ?? Array.from({ length: EQ_BAND_COUNT }, () => 0)); + let activePreset = $state(stored?.activePreset ?? 'Flat'); + let persistTimer: ReturnType | null = null; + + function syncToEngine(): void { + const engine = tryGetAudioEngine(); + if (!engine) return; + if (enabled) { + engine.setAllGains(gains); + } else { + engine.setEnabled(false, gains); + } + } + + function schedulePersist(): void { + if (typeof window === 'undefined') return; + if (persistTimer) clearTimeout(persistTimer); + persistTimer = setTimeout(() => { + persistTimer = null; + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ enabled, gains, activePreset }) + ); + }, PERSIST_DEBOUNCE_MS); + } + + function detectPreset(): void { + for (const [name, preset] of Object.entries(EQ_PRESETS)) { + if (preset.every((v, i) => v === gains[i])) { + activePreset = name; + return; + } + } + activePreset = null; + } + + function toggleEq(): void { + enabled = !enabled; + syncToEngine(); + schedulePersist(); + } + + function setBandGain(index: number, dB: number): void { + if (index < 0 || index >= EQ_BAND_COUNT) return; + gains[index] = clampGain(dB); + detectPreset(); + syncToEngine(); + schedulePersist(); + } + + function applyPreset(name: EqPresetName): void { + const preset = EQ_PRESETS[name]; + if (!preset) return; + gains = [...preset]; + activePreset = name; + syncToEngine(); + schedulePersist(); + } + + function resetToFlat(): void { + applyPreset('Flat'); + } + + function replayToEngine(): void { + syncToEngine(); + } + + // Apply initial state to engine if already connected + syncToEngine(); + + return { + get enabled() { + return enabled; + }, + get gains(): readonly number[] { + return gains; + }, + get activePreset() { + return activePreset; + }, + toggleEq, + setBandGain, + applyPreset, + resetToFlat, + replayToEngine + }; +} + +export const eqStore = createEqStore(); diff --git a/frontend/src/lib/stores/eqPresets.ts b/frontend/src/lib/stores/eqPresets.ts new file mode 100644 index 0000000..666c068 --- /dev/null +++ b/frontend/src/lib/stores/eqPresets.ts @@ -0,0 +1,36 @@ +export const EQ_FREQUENCIES = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] as const; + +export const EQ_BAND_COUNT = EQ_FREQUENCIES.length; + +export const EQ_MIN_GAIN = -12; +export const EQ_MAX_GAIN = 12; + +export const EQ_FREQUENCY_LABELS: readonly string[] = [ + '31', + '62', + '125', + '250', + '500', + '1k', + '2k', + '4k', + '8k', + '16k' +]; + +export const EQ_PRESETS = { + Flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + Rock: [5, 4, 3, 1, -1, 1, 3, 4, 5, 5], + Pop: [-1, 1, 3, 4, 3, 0, -1, -1, 2, 3], + Jazz: [3, 2, 1, 2, -1, -1, 0, 1, 3, 4], + Classical: [4, 3, 2, 1, -1, -1, 0, 2, 3, 4], + 'Bass Boost': [8, 6, 4, 2, 0, 0, 0, 0, 0, 0], + 'Treble Boost': [0, 0, 0, 0, 0, 0, 2, 4, 6, 8], + Vocal: [-2, -1, 0, 2, 4, 4, 3, 1, 0, -1], + Electronic: [5, 4, 2, 0, -2, 1, 3, 4, 5, 4], + Acoustic: [3, 2, 1, 1, 0, 0, 1, 2, 3, 2] +} as const satisfies Record; + +export type EqPresetName = keyof typeof EQ_PRESETS; + +export const EQ_PRESET_NAMES = Object.keys(EQ_PRESETS) as EqPresetName[]; diff --git a/frontend/src/lib/stores/errorModal.ts b/frontend/src/lib/stores/errorModal.ts new file mode 100644 index 0000000..aa41e24 --- /dev/null +++ b/frontend/src/lib/stores/errorModal.ts @@ -0,0 +1,32 @@ +import { writable } from 'svelte/store'; + +export interface ErrorModalState { + show: boolean; + title: string; + message: string; + details: string; +} + +function createErrorModalStore() { + const { subscribe, set, update } = writable({ + show: false, + title: '', + message: '', + details: '' + }); + + return { + subscribe, + show: (title: string, message: string, details: string = '') => { + set({ show: true, title, message, details }); + }, + hide: () => { + update(state => ({ ...state, show: false })); + }, + reset: () => { + set({ show: false, title: '', message: '', details: '' }); + } + }; +} + +export const errorModal = createErrorModalStore(); diff --git a/frontend/src/lib/stores/imageSettings.ts b/frontend/src/lib/stores/imageSettings.ts new file mode 100644 index 0000000..97ea7dd --- /dev/null +++ b/frontend/src/lib/stores/imageSettings.ts @@ -0,0 +1,35 @@ +import { writable } from 'svelte/store'; +import { api } from '$lib/api/client'; + +interface ImageSettings { + directRemoteImagesEnabled: boolean; +} + +const defaultSettings: ImageSettings = { + directRemoteImagesEnabled: true +}; + +const { subscribe, set } = writable(defaultSettings); + +let lastFetch = 0; +const CACHE_MS = 60_000; + +async function load(): Promise { + const now = Date.now(); + if (now - lastFetch < CACHE_MS) return; + + try { + const data = await api.global.get<{ direct_remote_images_enabled?: boolean }>('/api/v1/settings/advanced'); + set({ + directRemoteImagesEnabled: data.direct_remote_images_enabled ?? true + }); + lastFetch = now; + } catch (e) { + console.warn('Failed to load image settings:', e); + } +} + +export const imageSettingsStore = { + subscribe, + load +}; diff --git a/frontend/src/lib/stores/integration.ts b/frontend/src/lib/stores/integration.ts new file mode 100644 index 0000000..20ca6e4 --- /dev/null +++ b/frontend/src/lib/stores/integration.ts @@ -0,0 +1,74 @@ +import { get, writable } from 'svelte/store'; +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; + +interface IntegrationStatus { + lidarr: boolean; + jellyfin: boolean; + navidrome: boolean; + listenbrainz: boolean; + youtube: boolean; + youtube_api: boolean; + localfiles: boolean; + lastfm: boolean; + loaded: boolean; +} + +function createIntegrationStore() { + const { subscribe, set, update } = writable({ + lidarr: false, + jellyfin: false, + navidrome: false, + listenbrainz: false, + youtube: false, + youtube_api: false, + localfiles: false, + lastfm: false, + loaded: false + }); + let loadPromise: Promise | null = null; + + return { + subscribe, + setStatus: (status: Partial) => { + update((current) => ({ ...current, ...status, loaded: true })); + }, + setLidarrConfigured: (configured: boolean) => { + update((current) => ({ ...current, lidarr: configured })); + }, + reset: () => { + set({ + lidarr: false, + jellyfin: false, + navidrome: false, + listenbrainz: false, + youtube: false, + youtube_api: false, + localfiles: false, + lastfm: false, + loaded: false + }); + }, + ensureLoaded: async () => { + const current = get({ subscribe }); + if (current.loaded) return; + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + try { + const status = await api.global.get>(API.homeIntegrationStatus()); + update((state) => ({ ...state, ...status, loaded: true })); + return; + } catch {} + + update((state) => ({ ...state, loaded: true })); + })().finally(() => { + loadPromise = null; + }); + + return loadPromise; + } + }; +} + +export const integrationStore = createIntegrationStore(); diff --git a/frontend/src/lib/stores/library.ts b/frontend/src/lib/stores/library.ts new file mode 100644 index 0000000..aae8948 --- /dev/null +++ b/frontend/src/lib/stores/library.ts @@ -0,0 +1,185 @@ +import { writable, get } from 'svelte/store'; +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; +import { api } from '$lib/api/client'; + +export interface LibraryState { + mbidSet: Set; + requestedSet: Set; + loading: boolean; + lastUpdated: number | null; + initialized: boolean; +} + +const initialState: LibraryState = { + mbidSet: new Set(), + requestedSet: new Set(), + loading: false, + lastUpdated: null, + initialized: false +}; + +type LibraryCacheData = { + mbids: string[]; + requested: string[]; +}; + +function createLibraryStore() { + const { subscribe, update } = writable(initialState); + const cache = createLocalStorageCache( + CACHE_KEYS.LIBRARY_MBIDS, + CACHE_TTL.LIBRARY + ); + + function normalizeCachedData(data: LibraryCacheData | string[]): LibraryCacheData { + if (Array.isArray(data)) { + return { mbids: data, requested: [] }; + } + return { + mbids: data.mbids ?? [], + requested: data.requested ?? [] + }; + } + + function persistState(mbidSet: Set, requestedSet: Set) { + cache.set({ + mbids: [...mbidSet], + requested: [...requestedSet] + }); + } + + async function initialize() { + const state = get({ subscribe }); + if (state.initialized || state.loading) return; + + const cached = cache.get(); + if (cached) { + const normalized = normalizeCachedData(cached.data); + const mbids = normalized.mbids.map((m) => m.toLowerCase()); + const requested = normalized.requested.map((m) => m.toLowerCase()); + + if (mbids.length === 0 && requested.length === 0) { + await fetchLibraryMbids(false); + return; + } + + update((s) => ({ + ...s, + mbidSet: new Set(mbids), + requestedSet: new Set(requested), + lastUpdated: cached.timestamp, + initialized: true + })); + + const BACKGROUND_REFRESH_TTL = 30_000; + if (Date.now() - cached.timestamp > BACKGROUND_REFRESH_TTL) { + fetchLibraryMbids(true); + } + } else { + await fetchLibraryMbids(false); + } + } + + async function fetchLibraryMbids(background = false) { + if (!background) { + update((s) => ({ ...s, loading: true })); + } + + try { + const data = await api.global.get<{ mbids?: string[]; requested_mbids?: string[] }>('/api/v1/library/mbids'); + const mbids: string[] = (data.mbids || []).map((m: string) => m.toLowerCase()); + const requested: string[] = (data.requested_mbids || []).map((m: string) => m.toLowerCase()); + + update((s) => ({ + ...s, + mbidSet: new Set(mbids), + requestedSet: new Set(requested), + loading: false, + lastUpdated: Date.now(), + initialized: true + })); + + cache.set({ mbids, requested }); + } catch (e) { + console.error('Failed to fetch library MBIDs:', e); + if (!background) { + update((s) => ({ ...s, loading: false, initialized: true })); + } + } + } + + function isInLibrary(mbid: string | null | undefined): boolean { + if (!mbid) return false; + const state = get({ subscribe }); + return state.mbidSet.has(mbid.toLowerCase()); + } + + function addMbid(mbid: string) { + update((s) => { + const newSet = new Set(s.mbidSet); + newSet.add(mbid.toLowerCase()); + const newRequested = new Set(s.requestedSet); + newRequested.delete(mbid.toLowerCase()); + persistState(newSet, newRequested); + return { ...s, mbidSet: newSet, requestedSet: newRequested }; + }); + } + + function removeMbid(mbid: string) { + update((s) => { + const newSet = new Set(s.mbidSet); + newSet.delete(mbid.toLowerCase()); + const newRequested = new Set(s.requestedSet); + newRequested.delete(mbid.toLowerCase()); + persistState(newSet, newRequested); + return { ...s, mbidSet: newSet, requestedSet: newRequested }; + }); + } + + function addRequested(mbid: string) { + update((s) => { + if (s.mbidSet.has(mbid.toLowerCase())) { + return s; + } + const newSet = new Set(s.requestedSet); + newSet.add(mbid.toLowerCase()); + persistState(s.mbidSet, newSet); + return { ...s, requestedSet: newSet }; + }); + } + + function isRequested(mbid: string | null | undefined): boolean { + if (!mbid) return false; + const state = get({ subscribe }); + return state.requestedSet.has(mbid.toLowerCase()) && !state.mbidSet.has(mbid.toLowerCase()); + } + + async function refresh() { + await fetchLibraryMbids(false); + } + + async function refreshIfStale(ttlMs: number) { + const state = get({ subscribe }); + if (!state.initialized) { + await initialize(); + return; + } + if (state.lastUpdated && Date.now() - state.lastUpdated < ttlMs) return; + await fetchLibraryMbids(true); + } + + return { + subscribe, + initialize, + refresh, + refreshIfStale, + isInLibrary, + addMbid, + removeMbid, + isRequested, + addRequested, + updateCacheTTL: cache.updateTTL + }; +} + +export const libraryStore = createLibraryStore(); diff --git a/frontend/src/lib/stores/musicSource.ts b/frontend/src/lib/stores/musicSource.ts new file mode 100644 index 0000000..f3316db --- /dev/null +++ b/frontend/src/lib/stores/musicSource.ts @@ -0,0 +1,129 @@ +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; +import { PAGE_SOURCE_KEYS } from '$lib/constants'; +import { api } from '$lib/api/client'; + +export type MusicSource = 'listenbrainz' | 'lastfm'; +export type MusicSourcePage = keyof typeof PAGE_SOURCE_KEYS; + +const CACHED_SOURCE_KEY = 'musicseerr_primary_source'; +const DEFAULT_SOURCE: MusicSource = 'listenbrainz'; + +interface MusicSourceState { + source: MusicSource; + loaded: boolean; +} + +function isMusicSource(value: unknown): value is MusicSource { + return value === 'listenbrainz' || value === 'lastfm'; +} + +function readCachedSource(): MusicSource { + if (!browser) return DEFAULT_SOURCE; + const stored = localStorage.getItem(CACHED_SOURCE_KEY); + return isMusicSource(stored) ? stored : DEFAULT_SOURCE; +} + +function createMusicSourceStore() { + const { subscribe, set, update } = writable({ + source: readCachedSource(), + loaded: false, + }); + + let loadPromise: Promise | null = null; + let mutationVersion = 0; + + function getPageStorageKey(page: MusicSourcePage): string { + return PAGE_SOURCE_KEYS[page]; + } + + function persistSource(source: MusicSource): void { + if (browser) { + localStorage.setItem(CACHED_SOURCE_KEY, source); + } + } + + function getCachedSource(): MusicSource { + return readCachedSource(); + } + + async function load(): Promise { + const current = get({ subscribe }); + if (current.loaded) return; + if (loadPromise) return loadPromise; + const loadStartedAtVersion = mutationVersion; + + loadPromise = (async () => { + try { + if (browser) { + localStorage.removeItem('home_source'); + localStorage.removeItem('discover_source'); + localStorage.removeItem('artist-discovery_source'); + } + const data = await api.global.get<{ source: unknown }>('/api/v1/settings/primary-source'); + const fetchedSource = isMusicSource(data.source) ? data.source : DEFAULT_SOURCE; + if (mutationVersion === loadStartedAtVersion) { + persistSource(fetchedSource); + set({ source: fetchedSource, loaded: true }); + } else { + update((s) => ({ ...s, loaded: true })); + } + } catch { + update((s) => ({ ...s, loaded: true })); + } finally { + loadPromise = null; + } + })(); + + return loadPromise; + } + + async function save(source: MusicSource): Promise { + const saveVersion = ++mutationVersion; + try { + await api.global.put('/api/v1/settings/primary-source', { source }); + persistSource(source); + if (mutationVersion === saveVersion) { + set({ source, loaded: true }); + } + return true; + } catch { + return false; + } + } + + function setSource(source: MusicSource): void { + mutationVersion += 1; + persistSource(source); + set({ source, loaded: true }); + } + + function getSource(): MusicSource { + return get({ subscribe }).source; + } + + function getPageSource(page: MusicSourcePage): MusicSource { + const fallbackSource = getSource(); + if (!browser) return fallbackSource; + const storedSource = localStorage.getItem(getPageStorageKey(page)); + return isMusicSource(storedSource) ? storedSource : fallbackSource; + } + + function setPageSource(page: MusicSourcePage, source: MusicSource): void { + if (!browser) return; + localStorage.setItem(getPageStorageKey(page), source); + } + + return { + subscribe, + load, + save, + setSource, + getSource, + getCachedSource, + getPageSource, + setPageSource, + }; +} + +export const musicSourceStore = createMusicSourceStore(); diff --git a/frontend/src/lib/stores/playbackToast.spec.ts b/frontend/src/lib/stores/playbackToast.spec.ts new file mode 100644 index 0000000..ebdb9c9 --- /dev/null +++ b/frontend/src/lib/stores/playbackToast.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { playbackToast } from './playbackToast.svelte'; + +describe('playbackToast', () => { + beforeEach(() => { + vi.useFakeTimers(); + playbackToast.dismiss(); + }); + + afterEach(() => { + playbackToast.dismiss(); + vi.useRealTimers(); + }); + + it('starts hidden', () => { + expect(playbackToast.visible).toBe(false); + expect(playbackToast.message).toBe(''); + }); + + it('shows a toast with message and type', () => { + playbackToast.show('Track skipped', 'warning'); + + expect(playbackToast.visible).toBe(true); + expect(playbackToast.message).toBe('Track skipped'); + expect(playbackToast.type).toBe('warning'); + }); + + it('auto-dismisses after 3 seconds', async () => { + playbackToast.show('Error occurred', 'error'); + expect(playbackToast.visible).toBe(true); + + await vi.advanceTimersByTimeAsync(3000); + + expect(playbackToast.visible).toBe(false); + }); + + it('resets timer when showing a new toast before dismiss', async () => { + playbackToast.show('First message', 'info'); + await vi.advanceTimersByTimeAsync(2000); + + playbackToast.show('Second message', 'warning'); + expect(playbackToast.message).toBe('Second message'); + + await vi.advanceTimersByTimeAsync(2000); + expect(playbackToast.visible).toBe(true); + + await vi.advanceTimersByTimeAsync(1000); + expect(playbackToast.visible).toBe(false); + }); + + it('can be manually dismissed', () => { + playbackToast.show('Some message', 'info'); + expect(playbackToast.visible).toBe(true); + + playbackToast.dismiss(); + expect(playbackToast.visible).toBe(false); + }); + + it('defaults type to info when not specified', () => { + playbackToast.show('Info toast'); + expect(playbackToast.type).toBe('info'); + }); +}); diff --git a/frontend/src/lib/stores/playbackToast.svelte.ts b/frontend/src/lib/stores/playbackToast.svelte.ts new file mode 100644 index 0000000..11f165f --- /dev/null +++ b/frontend/src/lib/stores/playbackToast.svelte.ts @@ -0,0 +1,47 @@ +const TOAST_AUTO_DISMISS_MS = 3000; + +type ToastType = 'error' | 'warning' | 'info'; + +function createPlaybackToastStore() { + let visible = $state(false); + let message = $state(''); + let type = $state('info'); + let dismissTimer: ReturnType | null = null; + + function clearTimer(): void { + if (dismissTimer) { + clearTimeout(dismissTimer); + dismissTimer = null; + } + } + + return { + get visible() { + return visible; + }, + get message() { + return message; + }, + get type() { + return type; + }, + + show(msg: string, toastType: ToastType = 'info'): void { + clearTimer(); + message = msg; + type = toastType; + visible = true; + dismissTimer = setTimeout(() => { + visible = false; + dismissTimer = null; + }, TOAST_AUTO_DISMISS_MS); + }, + + dismiss(): void { + clearTimer(); + visible = false; + } + }; +} + +export const playbackToast = createPlaybackToastStore(); diff --git a/frontend/src/lib/stores/player.spec.ts b/frontend/src/lib/stores/player.spec.ts new file mode 100644 index 0000000..9f1f170 --- /dev/null +++ b/frontend/src/lib/stores/player.spec.ts @@ -0,0 +1,883 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { QueueItem, SourceType } from '$lib/player/types'; + +type StateCallback = (state: import('$lib/player/types').PlaybackState) => void; +type ProgressCallback = (currentTime: number, duration: number) => void; +type ErrorCallback = (error: { code: string; message: string }) => void; + +let capturedStateCallbacks: StateCallback[] = []; +let capturedProgressCallbacks: ProgressCallback[] = []; +let capturedErrorCallbacks: ErrorCallback[] = []; + +vi.mock('$lib/player/createSource', () => ({ + createPlaybackSource: vi.fn(() => { + capturedStateCallbacks = []; + capturedProgressCallbacks = []; + capturedErrorCallbacks = []; + return { + type: 'local' as const, + load: vi.fn().mockResolvedValue(undefined), + play: vi.fn(), + pause: vi.fn(), + seekTo: vi.fn(), + setVolume: vi.fn(), + getCurrentTime: vi.fn(() => 0), + getDuration: vi.fn(() => 180), + isSeekable: vi.fn(() => true), + destroy: vi.fn(), + onStateChange: vi.fn((cb: StateCallback) => { capturedStateCallbacks.push(cb); }), + onReady: vi.fn(), + onError: vi.fn((cb: ErrorCallback) => { capturedErrorCallbacks.push(cb); }), + onProgress: vi.fn((cb: ProgressCallback) => { capturedProgressCallbacks.push(cb); }), + }; + }), +})); + +vi.mock('$lib/player/jellyfinPlaybackApi', () => ({ + startSession: vi.fn(async (_itemId: string, playSessionId?: string) => playSessionId ?? ''), + reportProgress: vi.fn(async () => true), + reportStop: vi.fn(async () => true), +})); + +const storage = new Map(); +vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => (storage.has(key) ? storage.get(key)! : null)), + setItem: vi.fn((key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn((key: string) => { + storage.delete(key); + }), + clear: vi.fn(() => { + storage.clear(); + }), +}); + +vi.mock('$lib/stores/playbackToast.svelte', () => ({ + playbackToast: { + show: vi.fn(), + dismiss: vi.fn(), + get visible() { return false; }, + get message() { return ''; }, + get type() { return 'info' as const; }, + }, +})); + +const mockApiGet = vi.fn(); +const mockApiHead = vi.fn(); +vi.mock('$lib/api/client', () => ({ + api: { + global: { + get: (...args: unknown[]) => mockApiGet(...args), + head: (...args: unknown[]) => mockApiHead(...args), + }, + }, + ApiError: class extends Error { + status: number; + code: string; + details: unknown; + constructor(s: number, c: string, m: string, d?: unknown) { + super(m); this.status = s; this.code = c; this.details = d; + } + }, +})); + +import { playerStore } from './player.svelte'; +import { playbackToast } from '$lib/stores/playbackToast.svelte'; + +function makeItem(overrides: Partial = {}): QueueItem { + const id = overrides.trackSourceId ?? `vid-${Math.random().toString(36).slice(2, 6)}`; + return { + trackSourceId: id, + trackName: overrides.trackName ?? 'Test Track', + artistName: overrides.artistName ?? 'Test Artist', + trackNumber: overrides.trackNumber ?? 1, + albumId: overrides.albumId ?? 'album-1', + albumName: overrides.albumName ?? 'Test Album', + coverUrl: overrides.coverUrl ?? null, + sourceType: overrides.sourceType ?? 'local', + streamUrl: overrides.streamUrl ?? 'http://localhost/test.mp3', + availableSources: overrides.availableSources ?? ['local', 'jellyfin'], + sourceIds: overrides.sourceIds ?? { local: id, jellyfin: id }, + duration: overrides.duration, + ...overrides, + }; +} + +function makeItems(count: number): QueueItem[] { + return Array.from({ length: count }, (_, i) => + makeItem({ trackSourceId: `vid-${i}`, trackName: `Track ${i + 1}`, trackNumber: i + 1 }) + ); +} + +describe('playerStore queue methods', () => { + beforeEach(() => { + localStorage.clear(); + playerStore.stop(); + vi.clearAllMocks(); + }); + + describe('addToQueue', () => { + it('starts playback when queue is empty', () => { + const item = makeItem(); + playerStore.addToQueue(item); + expect(playerStore.queue).toHaveLength(1); + expect(playerStore.queue[0].trackSourceId).toBe(item.trackSourceId); + expect(playerStore.isPlayerVisible).toBe(true); + }); + + it('appends to end of queue when queue has items', () => { + playerStore.playQueue(makeItems(2)); + const newItem = makeItem({ trackName: 'New Track' }); + playerStore.addToQueue(newItem); + expect(playerStore.queue).toHaveLength(3); + expect(playerStore.queue[2].trackName).toBe('New Track'); + }); + + it('updates shuffle order when shuffle is enabled', () => { + playerStore.playQueue(makeItems(2), 0, true); + const initialShuffleLen = playerStore.shuffleOrder.length; + playerStore.addToQueue(makeItem()); + expect(playerStore.shuffleOrder).toHaveLength(initialShuffleLen + 1); + }); + }); + + describe('playNext', () => { + it('starts playback when queue is empty', () => { + const item = makeItem(); + playerStore.playNext(item); + expect(playerStore.queue).toHaveLength(1); + expect(playerStore.isPlayerVisible).toBe(true); + }); + + it('inserts after current index', () => { + playerStore.playQueue(makeItems(3)); + const newItem = makeItem({ trackName: 'Inserted' }); + playerStore.playNext(newItem); + expect(playerStore.queue[1].trackName).toBe('Inserted'); + expect(playerStore.queue).toHaveLength(4); + }); + }); + + describe('addMultipleToQueue', () => { + it('does nothing for empty array', () => { + playerStore.addMultipleToQueue([]); + expect(playerStore.queue).toHaveLength(0); + }); + + it('starts playback when queue is empty', () => { + const items = makeItems(3); + playerStore.addMultipleToQueue(items); + expect(playerStore.queue).toHaveLength(3); + expect(playerStore.isPlayerVisible).toBe(true); + }); + + it('appends all items to existing queue', () => { + playerStore.playQueue(makeItems(2)); + playerStore.addMultipleToQueue(makeItems(3)); + expect(playerStore.queue).toHaveLength(5); + }); + }); + + describe('playMultipleNext', () => { + it('does nothing for empty array', () => { + playerStore.playMultipleNext([]); + expect(playerStore.queue).toHaveLength(0); + }); + + it('starts playback when queue is empty', () => { + const items = makeItems(2); + playerStore.playMultipleNext(items); + expect(playerStore.queue).toHaveLength(2); + expect(playerStore.isPlayerVisible).toBe(true); + }); + + it('inserts all items after current index', () => { + playerStore.playQueue(makeItems(3)); + const newItems = makeItems(2).map((item, i) => ({ ...item, trackName: `Inserted ${i}` })); + playerStore.playMultipleNext(newItems); + expect(playerStore.queue).toHaveLength(5); + expect(playerStore.queue[1].trackName).toBe('Inserted 0'); + expect(playerStore.queue[2].trackName).toBe('Inserted 1'); + }); + }); + + describe('removeFromQueue', () => { + it('ignores out-of-bounds index', () => { + playerStore.playQueue(makeItems(2)); + playerStore.removeFromQueue(-1); + expect(playerStore.queue).toHaveLength(2); + playerStore.removeFromQueue(10); + expect(playerStore.queue).toHaveLength(2); + }); + + it('stops playback when removing only item', () => { + playerStore.addToQueue(makeItem()); + playerStore.removeFromQueue(0); + expect(playerStore.queue).toHaveLength(0); + expect(playerStore.isPlayerVisible).toBe(false); + }); + + it('decrements currentIndex when removing item before current', () => { + playerStore.playQueue(makeItems(3), 2); + const prevIndex = playerStore.currentIndex; + playerStore.removeFromQueue(0); + expect(playerStore.currentIndex).toBe(prevIndex - 1); + expect(playerStore.queue).toHaveLength(2); + }); + + it('updates shuffle order after removal', () => { + playerStore.playQueue(makeItems(4), 0, true); + const initialLen = playerStore.shuffleOrder.length; + playerStore.removeFromQueue(3); + expect(playerStore.shuffleOrder).toHaveLength(initialLen - 1); + }); + }); + + describe('reorderQueue', () => { + it('does nothing for same index', () => { + playerStore.playQueue(makeItems(3)); + const before = [...playerStore.queue]; + playerStore.reorderQueue(0, 0); + expect(playerStore.queue.map((i) => i.trackSourceId)).toEqual(before.map((i) => i.trackSourceId)); + }); + + it('does nothing for out-of-bounds indices', () => { + playerStore.playQueue(makeItems(3)); + const before = [...playerStore.queue]; + playerStore.reorderQueue(-1, 2); + expect(playerStore.queue.map((i) => i.trackSourceId)).toEqual(before.map((i) => i.trackSourceId)); + }); + + it('moves an item forward', () => { + playerStore.playQueue(makeItems(4)); + const moved = playerStore.queue[0].trackSourceId; + playerStore.reorderQueue(0, 2); + expect(playerStore.queue[2].trackSourceId).toBe(moved); + }); + + it('moves an item backward', () => { + playerStore.playQueue(makeItems(4)); + const moved = playerStore.queue[3].trackSourceId; + playerStore.reorderQueue(3, 1); + expect(playerStore.queue[1].trackSourceId).toBe(moved); + }); + + it('tracks currentIndex when current item is moved', () => { + playerStore.playQueue(makeItems(4)); + expect(playerStore.currentIndex).toBe(0); + playerStore.reorderQueue(0, 3); + expect(playerStore.currentIndex).toBe(3); + }); + + it('adjusts currentIndex when item moves across it', () => { + playerStore.playQueue(makeItems(5), 2); + expect(playerStore.currentIndex).toBe(2); + playerStore.reorderQueue(0, 4); + expect(playerStore.currentIndex).toBe(1); + }); + + it('updates shuffle order after reorder', () => { + playerStore.playQueue(makeItems(4), 0, true); + const initialOrder = [...playerStore.shuffleOrder]; + playerStore.reorderQueue(0, 3); + expect(playerStore.shuffleOrder).toHaveLength(initialOrder.length); + }); + }); + + describe('clearQueue', () => { + it('keeps current track and removes all upcoming tracks', () => { + playerStore.playQueue(makeItems(3), 1); + playerStore.clearQueue(); + expect(playerStore.queue).toHaveLength(1); + expect(playerStore.queue[0].trackSourceId).toBe('vid-1'); + expect(playerStore.isPlayerVisible).toBe(true); + expect(playerStore.currentIndex).toBe(0); + expect(playerStore.upcomingQueueLength).toBe(0); + }); + }); + + describe('upcomingQueueLength', () => { + it('counts tracks after current index for normal queue', () => { + playerStore.playQueue(makeItems(3), 0); + expect(playerStore.upcomingQueueLength).toBe(2); + }); + + it('counts remaining tracks in shuffle order', () => { + playerStore.playQueue(makeItems(4), 0, true); + playerStore.jumpToTrack(2); + const expectedRemaining = Math.max(0, playerStore.shuffleOrder.length - playerStore.shuffleOrder.indexOf(playerStore.currentIndex) - 1); + expect(playerStore.upcomingQueueLength).toBe(expectedRemaining); + }); + }); + + describe('changeTrackSource', () => { + it('ignores out-of-bounds index', () => { + playerStore.playQueue(makeItems(3)); + const before = playerStore.queue[0].sourceType; + playerStore.changeTrackSource(-1, 'jellyfin'); + expect(playerStore.queue[0].sourceType).toBe(before); + }); + + it('is a no-op on current track with toast', () => { + playerStore.playQueue(makeItems(3)); + const currentIdx = playerStore.currentIndex; + const beforeSource = playerStore.queue[currentIdx].sourceType; + playerStore.changeTrackSource(currentIdx, 'jellyfin'); + expect(playerStore.queue[currentIdx].sourceType).toBe(beforeSource); + expect(playbackToast.show).toHaveBeenCalled(); + }); + + it('updates source on non-current item', () => { + playerStore.playQueue(makeItems(3)); + playerStore.changeTrackSource(2, 'jellyfin'); + expect(playerStore.queue[2].sourceType).toBe('jellyfin'); + }); + + it('updates streamUrl for target source', () => { + playerStore.playQueue(makeItems(3)); + playerStore.changeTrackSource(1, 'jellyfin'); + expect(playerStore.queue[1].streamUrl).toBe('/api/v1/stream/jellyfin/vid-1'); + }); + }); + + describe('updateQueueItemByPlaylistTrackId', () => { + it('updates queued item matching playlistTrackId', () => { + expect.assertions(3); + const items = makeItems(3).map((item, i) => ({ ...item, playlistTrackId: `pt-${i}` })); + playerStore.playQueue(items); + playerStore.updateQueueItemByPlaylistTrackId('pt-2', 'jellyfin', 'jf-new', 'opus'); + expect(playerStore.queue[2].sourceType).toBe('jellyfin'); + expect(playerStore.queue[2].trackSourceId).toBe('jf-new'); + expect(playerStore.queue[2].streamUrl).toBe('/api/v1/stream/jellyfin/jf-new'); + }); + + it('is a no-op when playlistTrackId is not found', () => { + expect.assertions(1); + const items = makeItems(3).map((item, i) => ({ ...item, playlistTrackId: `pt-${i}` })); + playerStore.playQueue(items); + const before = playerStore.queue[1].sourceType; + playerStore.updateQueueItemByPlaylistTrackId('pt-nonexistent', 'jellyfin', 'jf-new'); + expect(playerStore.queue[1].sourceType).toBe(before); + }); + + it('skips currently playing track', () => { + expect.assertions(1); + const items = makeItems(3).map((item, i) => ({ ...item, playlistTrackId: `pt-${i}` })); + playerStore.playQueue(items); + const currentIdx = playerStore.currentIndex; + const before = playerStore.queue[currentIdx].sourceType; + playerStore.updateQueueItemByPlaylistTrackId(`pt-${currentIdx}`, 'jellyfin', 'jf-new'); + expect(playerStore.queue[currentIdx].sourceType).toBe(before); + }); + + it('resolves local streamUrl correctly', () => { + expect.assertions(1); + const items = makeItems(3).map((item, i) => ({ ...item, playlistTrackId: `pt-${i}` })); + playerStore.playQueue(items); + playerStore.updateQueueItemByPlaylistTrackId('pt-1', 'local', '999', 'flac'); + expect(playerStore.queue[1].streamUrl).toBe('/api/v1/stream/local/999'); + }); + }); + + describe('session migration', () => { + it('maps legacy howler source type to local during resume', () => { + const legacySession = { + nowPlaying: { + albumId: 'album-1', + albumName: 'Album', + artistName: 'Artist', + coverUrl: null, + sourceType: 'howler', + trackSourceId: '1', + trackName: 'Track', + }, + queue: [ + { + trackSourceId: '1', + trackName: 'Track', + artistName: 'Artist', + trackNumber: 1, + albumId: 'album-1', + albumName: 'Album', + coverUrl: null, + sourceType: 'howler', + streamUrl: '/api/v1/stream/local/1', + availableSources: ['howler', 'jellyfin'], + }, + ], + currentIndex: 0, + progress: 0, + shuffleEnabled: false, + shuffleOrder: [], + }; + + localStorage.setItem('musicseerr_player_session', JSON.stringify(legacySession)); + playerStore.resumeSession(); + + expect(playerStore.queue[0].sourceType).toBe('local'); + expect(playerStore.queue[0].availableSources).toEqual(['local', 'jellyfin']); + }); + }); + + describe('playQueue', () => { + it('does nothing for empty array', () => { + playerStore.playQueue([]); + expect(playerStore.queue).toHaveLength(0); + }); + + it('sets queue and starts playback at specified index', () => { + const items = makeItems(5); + playerStore.playQueue(items, 2); + expect(playerStore.queue).toHaveLength(5); + expect(playerStore.currentIndex).toBe(2); + expect(playerStore.isPlayerVisible).toBe(true); + }); + + it('creates shuffle order when shuffle enabled', () => { + playerStore.playQueue(makeItems(5), 0, true); + expect(playerStore.shuffleEnabled).toBe(true); + expect(playerStore.shuffleOrder).toHaveLength(5); + }); + + it('does not create shuffle order when disabled', () => { + playerStore.playQueue(makeItems(5), 0, false); + expect(playerStore.shuffleEnabled).toBe(false); + }); + }); + + describe('toggleShuffle', () => { + it('enables shuffle and creates order', () => { + playerStore.playQueue(makeItems(4)); + playerStore.toggleShuffle(); + expect(playerStore.shuffleEnabled).toBe(true); + expect(playerStore.shuffleOrder).toHaveLength(4); + expect(playerStore.shuffleOrder[0]).toBe(playerStore.currentIndex); + }); + + it('disables shuffle and clears order', () => { + playerStore.playQueue(makeItems(4), 0, true); + playerStore.toggleShuffle(); + expect(playerStore.shuffleEnabled).toBe(false); + expect(playerStore.shuffleOrder).toHaveLength(0); + }); + + it('only shuffles upcoming tracks, not played ones', () => { + playerStore.playQueue(makeItems(6), 0); + playerStore.jumpToTrack(3); + + playerStore.toggleShuffle(); + + expect(playerStore.shuffleOrder).toHaveLength(6); + expect(playerStore.shuffleOrder.slice(0, 3)).toEqual([0, 1, 2]); + expect(playerStore.shuffleOrder[3]).toBe(3); + const upcomingPart = playerStore.shuffleOrder.slice(4); + expect(upcomingPart.sort()).toEqual([4, 5]); + }); + + it('preserves played order when toggled at end of queue', () => { + playerStore.playQueue(makeItems(4), 0); + playerStore.jumpToTrack(3); + + playerStore.toggleShuffle(); + expect(playerStore.shuffleOrder.slice(0, 3)).toEqual([0, 1, 2]); + expect(playerStore.shuffleOrder[3]).toBe(3); + }); + + it('includes all indices when toggled at start', () => { + playerStore.playQueue(makeItems(5)); + playerStore.toggleShuffle(); + + expect(playerStore.shuffleOrder).toHaveLength(5); + expect(playerStore.shuffleOrder[0]).toBe(0); + expect([...playerStore.shuffleOrder].sort()).toEqual([0, 1, 2, 3, 4]); + }); + }); + + describe('queueOrigin tagging', () => { + it('playQueue stamps items as context', () => { + playerStore.playQueue(makeItems(3)); + expect(playerStore.queue.every((i) => i.queueOrigin === 'context')).toBe(true); + }); + + it('addToQueue stamps item as manual', () => { + playerStore.playQueue(makeItems(2)); + playerStore.addToQueue(makeItem({ trackName: 'Manual' })); + expect(playerStore.queue[2].queueOrigin).toBe('manual'); + }); + + it('addMultipleToQueue stamps items as manual', () => { + playerStore.playQueue(makeItems(2)); + playerStore.addMultipleToQueue(makeItems(2)); + expect(playerStore.queue[2].queueOrigin).toBe('manual'); + expect(playerStore.queue[3].queueOrigin).toBe('manual'); + }); + + it('playNext stamps item as manual', () => { + playerStore.playQueue(makeItems(2)); + playerStore.playNext(makeItem({ trackName: 'Next' })); + expect(playerStore.queue[1].queueOrigin).toBe('manual'); + }); + + it('playMultipleNext stamps items as manual', () => { + playerStore.playQueue(makeItems(2)); + playerStore.playMultipleNext(makeItems(2)); + expect(playerStore.queue[1].queueOrigin).toBe('manual'); + expect(playerStore.queue[2].queueOrigin).toBe('manual'); + }); + + it('does not overwrite existing context origin on playQueue', () => { + const items = makeItems(2); + items[0].queueOrigin = 'manual'; + playerStore.playQueue(items); + expect(playerStore.queue[0].queueOrigin).toBe('context'); + }); + }); + + describe('played track cleanup', () => { + it('removes manual tracks after advancing past them', async () => { + playerStore.playQueue(makeItems(2)); + playerStore.playNext(makeItem({ trackName: 'Manual Next' })); + expect(playerStore.queue).toHaveLength(3); + + playerStore.jumpToTrack(1); + playerStore.nextTrack(); + await vi.waitFor(() => { + expect(playerStore.queue.find((i) => i.trackName === 'Manual Next')).toBeUndefined(); + }); + }); + + it('keeps context tracks up to history cap', async () => { + playerStore.playQueue(makeItems(6)); + + playerStore.nextTrack(); + await vi.waitFor(() => { expect(playerStore.currentIndex).toBeGreaterThan(0); }); + playerStore.nextTrack(); + await vi.waitFor(() => { expect(playerStore.currentIndex).toBeGreaterThan(0); }); + playerStore.nextTrack(); + await vi.waitFor(() => { expect(playerStore.currentIndex).toBeGreaterThan(0); }); + playerStore.nextTrack(); + await vi.waitFor(() => { expect(playerStore.currentIndex).toBeGreaterThan(0); }); + playerStore.nextTrack(); + await vi.waitFor(() => { + const playedCount = playerStore.currentIndex; + expect(playedCount).toBeLessThanOrEqual(3); + }); + }); + + it('trims oldest context tracks beyond history cap', async () => { + playerStore.playQueue(makeItems(8)); + + for (let i = 0; i < 6; i++) { + playerStore.nextTrack(); + await vi.waitFor(() => { expect(playerStore.currentIndex).toBeGreaterThan(0); }); + } + + const historyBehind = playerStore.currentIndex; + expect(historyBehind).toBeLessThanOrEqual(3); + }); + + it('does not remove tracks when at start of queue', async () => { + playerStore.playQueue(makeItems(3)); + playerStore.nextTrack(); + await vi.waitFor(() => { + expect(playerStore.currentIndex).toBeGreaterThanOrEqual(0); + }); + expect(playerStore.queue.length).toBeGreaterThanOrEqual(2); + }); + }); + + describe('session migration with queueOrigin', () => { + it('defaults missing queueOrigin to context during resume', () => { + const session = { + nowPlaying: { + albumId: 'album-1', + albumName: 'Album', + artistName: 'Artist', + coverUrl: null, + sourceType: 'local', + trackSourceId: '1', + trackName: 'Track', + }, + queue: [ + { + trackSourceId: '1', + trackName: 'Track', + artistName: 'Artist', + trackNumber: 1, + albumId: 'album-1', + albumName: 'Album', + coverUrl: null, + sourceType: 'local', + streamUrl: '/api/v1/stream/local/1', + availableSources: ['local'], + }, + ], + currentIndex: 0, + progress: 0, + shuffleEnabled: false, + shuffleOrder: [], + }; + + localStorage.setItem('musicseerr_player_session', JSON.stringify(session)); + playerStore.resumeSession(); + + expect(playerStore.queue[0].queueOrigin).toBe('context'); + }); + + it('preserves existing queueOrigin during resume', () => { + const session = { + nowPlaying: { + albumId: 'album-1', + albumName: 'Album', + artistName: 'Artist', + coverUrl: null, + sourceType: 'local', + trackSourceId: '1', + trackName: 'Track', + }, + queue: [ + { + trackSourceId: '1', + trackName: 'Track', + artistName: 'Artist', + trackNumber: 1, + albumId: 'album-1', + albumName: 'Album', + coverUrl: null, + sourceType: 'local', + streamUrl: '/api/v1/stream/local/1', + availableSources: ['local'], + queueOrigin: 'manual', + }, + ], + currentIndex: 0, + progress: 0, + shuffleEnabled: false, + shuffleOrder: [], + }; + + localStorage.setItem('musicseerr_player_session', JSON.stringify(session)); + playerStore.resumeSession(); + + expect(playerStore.queue[0].queueOrigin).toBe('manual'); + }); + }); + + describe('jumpToTrack', () => { + it('loads the track at the specified index', () => { + playerStore.playQueue(makeItems(3)); + playerStore.jumpToTrack(2); + expect(playerStore.currentIndex).toBe(2); + }); + + it('ignores out-of-bounds index', () => { + playerStore.playQueue(makeItems(3)); + const before = playerStore.currentIndex; + playerStore.jumpToTrack(10); + expect(playerStore.currentIndex).toBe(before); + }); + }); +}); + +describe('Jellyfin session lifecycle', () => { + let jellyfinApi: { startSession: ReturnType; reportProgress: ReturnType; reportStop: ReturnType }; + + beforeEach(async () => { + localStorage.clear(); + playerStore.stop(); + vi.clearAllMocks(); + vi.useFakeTimers(); + + jellyfinApi = await import('$lib/player/jellyfinPlaybackApi') as unknown as typeof jellyfinApi; + + mockApiGet.mockResolvedValue({ url: 'http://jf/Audio/1/stream?static=true', seekable: true, playSessionId: 'ps-123' }); + mockApiHead.mockResolvedValue(new Response(null, { status: 200 })); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => (storage.has(key) ? storage.get(key)! : null)), + setItem: vi.fn((key: string, value: string) => { storage.set(key, value); }), + removeItem: vi.fn((key: string) => { storage.delete(key); }), + clear: vi.fn(() => { storage.clear(); }), + }); + }); + + function makeJellyfinItem(overrides: Partial = {}): QueueItem { + return makeItem({ sourceType: 'jellyfin', trackSourceId: 'jf-1', streamUrl: undefined, ...overrides }); + } + + it('calls startSession when a Jellyfin track is loaded', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + expect(jellyfinApi.startSession).toHaveBeenCalledWith('jf-1', 'ps-123'); + }); + + it('calls reportStop when switching tracks', async () => { + playerStore.playQueue([makeJellyfinItem({ trackSourceId: 'jf-1' }), makeItem({ trackSourceId: 'loc-2' })]); + await vi.advanceTimersByTimeAsync(0); + + capturedStateCallbacks.forEach((cb) => cb('playing')); + capturedProgressCallbacks.forEach((cb) => cb(30, 180)); + await vi.advanceTimersByTimeAsync(0); + + playerStore.nextTrack(); + await vi.advanceTimersByTimeAsync(0); + + expect(jellyfinApi.reportStop).toHaveBeenCalledWith('jf-1', 'ps-123', expect.any(Number)); + }); + + it('calls reportStop when stop() is called', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + playerStore.stop(); + await vi.advanceTimersByTimeAsync(0); + + expect(jellyfinApi.reportStop).toHaveBeenCalledWith('jf-1', 'ps-123', expect.any(Number)); + }); + + it('calls reportProgress during the progress interval', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + capturedStateCallbacks.forEach((cb) => cb('playing')); + capturedProgressCallbacks.forEach((cb) => cb(10, 180)); + await vi.advanceTimersByTimeAsync(0); + + vi.advanceTimersByTime(10_000); + + expect(jellyfinApi.reportProgress).toHaveBeenCalledWith( + 'jf-1', 'ps-123', expect.any(Number), false + ); + }); +}); + +describe('beforeunload beacon', () => { + let addEventListenerSpy: ReturnType; + let removeEventListenerSpy: ReturnType; + let sendBeaconMock: ReturnType; + let jellyfinApi: { startSession: ReturnType; reportProgress: ReturnType; reportStop: ReturnType }; + + beforeEach(async () => { + localStorage.clear(); + playerStore.stop(); + vi.clearAllMocks(); + vi.useFakeTimers(); + + jellyfinApi = await import('$lib/player/jellyfinPlaybackApi') as unknown as typeof jellyfinApi; + + mockApiGet.mockResolvedValue({ url: 'http://jf/Audio/1/stream?static=true', seekable: true, playSessionId: 'ps-beacon' }); + mockApiHead.mockResolvedValue(new Response(null, { status: 200 })); + + const listeners = new Map>(); + const windowStub = { + addEventListener: vi.fn((event: string, handler: Function) => { + const set = listeners.get(event) ?? new Set(); + set.add(handler); + listeners.set(event, set); + }), + removeEventListener: vi.fn((event: string, handler: Function) => { + listeners.get(event)?.delete(handler); + }), + }; + vi.stubGlobal('window', windowStub); + addEventListenerSpy = windowStub.addEventListener; + removeEventListenerSpy = windowStub.removeEventListener; + + sendBeaconMock = vi.fn(() => true); + vi.stubGlobal('navigator', { sendBeacon: sendBeaconMock }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => (storage.has(key) ? storage.get(key)! : null)), + setItem: vi.fn((key: string, value: string) => { storage.set(key, value); }), + removeItem: vi.fn((key: string) => { storage.delete(key); }), + clear: vi.fn(() => { storage.clear(); }), + }); + }); + + function makeJellyfinItem(overrides: Partial = {}): QueueItem { + return makeItem({ sourceType: 'jellyfin', trackSourceId: 'jf-beacon', streamUrl: undefined, ...overrides }); + } + + it('registers beforeunload listener when a Jellyfin track starts', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('sends beacon with correct payload on beforeunload', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + capturedProgressCallbacks.forEach((cb) => cb(45, 180)); + + const beforeUnloadHandler = addEventListenerSpy.mock.calls.find( + (call) => call[0] === 'beforeunload' + )?.[1] as (() => void) | undefined; + + expect(beforeUnloadHandler).toBeDefined(); + beforeUnloadHandler!(); + + expect(sendBeaconMock).toHaveBeenCalledWith( + '/api/v1/stream/jellyfin/jf-beacon/stop', + expect.any(Blob) + ); + + const sentBlob = sendBeaconMock.mock.calls[0][1] as Blob; + expect(sentBlob.type).toBe('application/json'); + const text = await sentBlob.text(); + const parsed = JSON.parse(text); + expect(parsed).toEqual({ play_session_id: 'ps-beacon', position_seconds: 45 }); + }); + + it('removes beforeunload listener on destroy/stop', async () => { + playerStore.playQueue([makeJellyfinItem()]); + await vi.advanceTimersByTimeAsync(0); + + playerStore.stop(); + await vi.advanceTimersByTimeAsync(0); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); +}); + +describe('non-seekable state propagation', () => { + beforeEach(() => { + localStorage.clear(); + playerStore.stop(); + vi.clearAllMocks(); + vi.useFakeTimers(); + + mockApiGet.mockResolvedValue({ url: 'http://jf/Audio/1/universal?transcode', seekable: false, playSessionId: 'ps-ns' }); + mockApiHead.mockResolvedValue(new Response(null, { status: 200 })); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.stubGlobal('localStorage', { + getItem: vi.fn((key: string) => (storage.has(key) ? storage.get(key)! : null)), + setItem: vi.fn((key: string, value: string) => { storage.set(key, value); }), + removeItem: vi.fn((key: string) => { storage.delete(key); }), + clear: vi.fn(() => { storage.clear(); }), + }); + }); + + it('sets isSeekable to false when Jellyfin returns seekable: false', async () => { + const item = makeItem({ sourceType: 'jellyfin', trackSourceId: 'jf-ns', streamUrl: undefined }); + playerStore.playQueue([item]); + await vi.advanceTimersByTimeAsync(0); + + expect(playerStore.isSeekable).toBe(false); + }); +}); diff --git a/frontend/src/lib/stores/player.svelte.ts b/frontend/src/lib/stores/player.svelte.ts new file mode 100644 index 0000000..520f2df --- /dev/null +++ b/frontend/src/lib/stores/player.svelte.ts @@ -0,0 +1,400 @@ +import type { PlaybackSource, PlaybackState, NowPlaying, QueueItem, SourceType } from '$lib/player/types'; +import { createPlaybackSource } from '$lib/player/createSource'; +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; +import { reportProgress as reportJellyfinProgress, reportStop as reportJellyfinStop, startSession as startJellyfinSession } from '$lib/player/jellyfinPlaybackApi'; +import { reportNavidromeScrobble, reportNavidromeNowPlaying } from '$lib/player/navidromePlaybackApi'; +import { playbackToast } from '$lib/stores/playbackToast.svelte'; +import { getStoredVolume, storeVolume, storeSessionData, stampOrigin, stampSingleOrigin, showQueueMutationToast, type StoredSession } from './playerUtils'; +import { createProgressReporter, createBeforeUnloadHandler } from './playerJellyfinReporting'; +import { computeNextIndex, computePreviousIndex, computeUpcomingLength, performCleanup } from './playerQueueOps'; +import { resolveSourceUrl, buildPrefetchUrl, buildNowPlayingMetadata } from './playerSourceResolver'; +import { persistSession as doPersistSession, restoreSessionData, buildResumeState } from './playerSessionManager'; +import { addItemToQueue, addMultipleItems, insertPlayNext, insertMultipleNext, removeAtIndex, performReorder, performShuffleReorder, clearQueueKeepCurrent } from './playerQueueMethods'; +import { buildPlayQueueState, computeToggleShuffle, changeItemSource, updateItemByPlaylistTrackId } from './playerPlaybackMethods'; + +const MAX_CONSECUTIVE_ERRORS = 3; +const ERROR_SKIP_DELAY_MS = 2000; +const MAX_HISTORY_LENGTH = 3; +const SESSION_PERSIST_INTERVAL_MS = 5000; +const JELLYFIN_REPORT_INTERVAL_MS = 10_000; +const MAX_JELLYFIN_REPORT_FAILURES = 3; +type JellyfinPlaybackUrlResponse = { url: string; seekable: boolean; playSessionId: string }; + +function createPlayerStore() { + let currentSource = $state(null); + let nowPlaying = $state(null); + let playbackState = $state('idle'); + let isSeekable = $state(true); + let volume = $state(getStoredVolume()); + let progress = $state(0); + let duration = $state(0); + let isPlayerVisible = $state(false); + let loadGeneration = 0; + let queue = $state([]); + let currentIndex = $state(0); + let shuffleEnabled = $state(false); + let shuffleOrder = $state([]); + let consecutiveErrors = 0; + let errorSkipTimeout: ReturnType | null = null; + let lastPersistTime = 0; + let beforeUnloadRegistered = false; + + const isPlaying = $derived(playbackState === 'playing'); + const isBuffering = $derived(playbackState === 'buffering' || playbackState === 'loading'); + const hasQueue = $derived(queue.length > 0); + const hasNext = $derived.by(() => { + if (queue.length <= 1) return false; + if (shuffleEnabled) { const si = shuffleOrder.indexOf(currentIndex); return si < shuffleOrder.length - 1; } + return currentIndex < queue.length - 1; + }); + const hasPrevious = $derived.by(() => { + if (queue.length <= 1) return false; + if (shuffleEnabled) { const si = shuffleOrder.indexOf(currentIndex); return si > 0; } + return currentIndex > 0; + }); + const currentQueueItem = $derived(queue.length > 0 ? queue[currentIndex] : null); + const queueLength = $derived(queue.length); + const currentTrackNumber = $derived(currentIndex + 1); + + const progressReporter = createProgressReporter(reportJellyfinProgress, JELLYFIN_REPORT_INTERVAL_MS, MAX_JELLYFIN_REPORT_FAILURES); + const handleBeforeUnload = createBeforeUnloadHandler( + () => ({ jellyfinItem: getJellyfinItem(), currentItem: queue[currentIndex] ?? null, progress }), + API.stream.jellyfinStop, API.stream.navidromeScrobble, + ); + + function getNextIndex(): number | null { return computeNextIndex(currentIndex, queue.length, shuffleEnabled, shuffleOrder); } + function getPreviousIndex(): number | null { return computePreviousIndex(currentIndex, queue.length, shuffleEnabled, shuffleOrder); } + function getJellyfinItem(): QueueItem | null { const item = queue[currentIndex]; return item?.sourceType === 'jellyfin' ? item : null; } + function persist(): void { doPersistSession(nowPlaying, queue, currentIndex, progress, shuffleEnabled, shuffleOrder); } + + function registerBeforeUnload(): void { + if (beforeUnloadRegistered || typeof window === 'undefined') return; + window.addEventListener('beforeunload', handleBeforeUnload); beforeUnloadRegistered = true; + } + function unregisterBeforeUnload(): void { + if (!beforeUnloadRegistered || typeof window === 'undefined') return; + window.removeEventListener('beforeunload', handleBeforeUnload); beforeUnloadRegistered = false; + } + async function stopJellyfinSession(item: QueueItem | null, posSeconds: number): Promise { + progressReporter.stop(); + unregisterBeforeUnload(); + if (!item || item.sourceType !== 'jellyfin' || !item.playSessionId) return; + await reportJellyfinStop(item.trackSourceId, item.playSessionId, posSeconds); + } + + function applyResetState(): void { + currentSource?.destroy(); currentSource = null; + nowPlaying = null; playbackState = 'idle'; isSeekable = true; isPlayerVisible = false; + progress = 0; duration = 0; queue = []; currentIndex = 0; + shuffleOrder = []; shuffleEnabled = false; consecutiveErrors = 0; + progressReporter.stop(); unregisterBeforeUnload(); storeSessionData(null); + } + + async function resolveSourceForItem(item: QueueItem, index: number): Promise<{ source: PlaybackSource; loadUrl: string | undefined }> { + const url = resolveSourceUrl(item); + if (item.sourceType === 'youtube') { + isSeekable = true; + return { source: createPlaybackSource('youtube'), loadUrl: url }; + } + if (item.sourceType === 'local') { + isSeekable = true; + return { source: createPlaybackSource('local', { url: url!, seekable: true }), loadUrl: url }; + } + if (item.sourceType === 'navidrome') { + isSeekable = true; + void reportNavidromeNowPlaying(item.trackSourceId); + return { source: createPlaybackSource('navidrome', { url: url!, seekable: true }), loadUrl: url }; + } + const payload = await api.global.get(API.stream.jellyfin(item.trackSourceId)); + const uq = [...queue]; uq[index] = { ...item, playSessionId: payload.playSessionId }; queue = uq; + isSeekable = payload.seekable; + return { source: createPlaybackSource('jellyfin', { url: payload.url, seekable: payload.seekable }), loadUrl: payload.url }; + } + + async function startJellyfinPlayback(index: number): Promise { + const item = queue[index]; + if (!item || item.sourceType !== 'jellyfin') return; + try { + const playSessionId = await startJellyfinSession(item.trackSourceId, item.playSessionId); + const uq = [...queue]; uq[index] = { ...uq[index], playSessionId }; queue = uq; + registerBeforeUnload(); + } catch { + const uq = [...queue]; uq[index] = { ...uq[index], playSessionId: '' }; queue = uq; + } + } + + async function loadQueueItem(index: number): Promise { + const item = queue[index]; + if (!item) return; + if (errorSkipTimeout) { clearTimeout(errorSkipTimeout); errorSkipTimeout = null; } + const prevProgress = progress, prevItem = queue[currentIndex] ?? null; + currentIndex = index; playbackState = 'loading'; progress = 0; duration = 0; + await stopJellyfinSession(prevItem, prevProgress); + currentSource?.destroy(); + const gen = ++loadGeneration; + let source: PlaybackSource, resolvedUrl: string | undefined = item.streamUrl; + try { + const r = await resolveSourceForItem(item, index); + source = r.source; resolvedUrl = r.loadUrl; + } catch { if (gen === loadGeneration) handleTrackError(gen); return; } + currentSource = source; + nowPlaying = buildNowPlayingMetadata(queue[index] ?? item); + persist(); + subscribeToSource(source, gen); + source.setVolume(volume); + try { + if ((queue[index] ?? item).sourceType === 'jellyfin') await startJellyfinPlayback(index); + await source.load({ trackSourceId: (queue[index] ?? item).trackSourceId, url: resolvedUrl, format: (queue[index] ?? item).format }); + if (gen === loadGeneration) source.play(); + } catch { if (gen === loadGeneration) handleTrackError(gen); } + } + + function handleTrackError(gen: number): void { + if (gen !== loadGeneration) return; + consecutiveErrors++; playbackState = 'error'; + const trackName = nowPlaying?.trackName ?? 'Unknown track'; + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + playbackToast.show('Multiple tracks failed — playback stopped', 'error'); + applyResetState(); return; + } + const nextIdx = getNextIndex(); + if (nextIdx !== null) { + playbackToast.show(`"${trackName}" unavailable — skipping…`, 'warning'); + errorSkipTimeout = setTimeout(() => { errorSkipTimeout = null; if (gen === loadGeneration) void loadQueueItem(nextIdx); }, ERROR_SKIP_DELAY_MS); + } else { + playbackToast.show(`"${trackName}" unavailable`, 'error'); + } + } + + function prefetchNext(): void { + const nextIdx = getNextIndex(); + if (nextIdx === null) return; + const nextItem = queue[nextIdx]; + if (!nextItem) return; + const url = buildPrefetchUrl(nextItem); + if (url) void api.global.head(url).catch(() => {}); + } + + function subscribeToSource(source: PlaybackSource, gen: number): void { + source.onStateChange((state) => { + if (gen !== loadGeneration) return; + playbackState = state; + if (state === 'playing') { + consecutiveErrors = 0; + if (getJellyfinItem()) progressReporter.start(() => ({ jellyfinItem: getJellyfinItem(), progress, isPaused: playbackState !== 'playing' })); + prefetchNext(); + } + if (state === 'paused') { + const jf = getJellyfinItem(); + if (jf?.playSessionId) void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true); + } + if (state === 'ended') { + const ci = queue[currentIndex] ?? null; + void stopJellyfinSession(getJellyfinItem(), progress); + if (ci?.sourceType === 'navidrome') void reportNavidromeScrobble(ci.trackSourceId); + const nextIdx = getNextIndex(); + if (nextIdx !== null) { + void loadQueueItem(nextIdx).then(() => { + const c = performCleanup(queue, currentIndex, shuffleEnabled, shuffleOrder, MAX_HISTORY_LENGTH); + queue = c.newQueue; currentIndex = c.newIndex; shuffleOrder = c.newShuffleOrder; persist(); + }); + } else { applyResetState(); } + } + }); + source.onProgress((t, d) => { + if (gen !== loadGeneration) return; + progress = t; duration = d; + const now = Date.now(); + if (now - lastPersistTime >= SESSION_PERSIST_INTERVAL_MS) { lastPersistTime = now; persist(); } + }); + source.onError(() => { if (gen !== loadGeneration) return; handleTrackError(gen); }); + } + + return { + get currentSource() { return currentSource; }, + get nowPlaying() { return nowPlaying; }, + get playbackState() { return playbackState; }, + get isPlaying() { return isPlaying; }, + get isBuffering() { return isBuffering; }, + get isSeekable() { return isSeekable; }, + get volume() { return volume; }, + get progress() { return progress; }, + get duration() { return duration; }, + get isPlayerVisible() { return isPlayerVisible; }, + get hasQueue() { return hasQueue; }, + get hasNext() { return hasNext; }, + get hasPrevious() { return hasPrevious; }, + get shuffleEnabled() { return shuffleEnabled; }, + get queue() { return queue; }, + get currentIndex() { return currentIndex; }, + get currentQueueItem() { return currentQueueItem; }, + get queueLength() { return queueLength; }, + get upcomingQueueLength() { return computeUpcomingLength(queue.length, currentIndex, shuffleEnabled, shuffleOrder); }, + get currentTrackNumber() { return currentTrackNumber; }, + get shuffleOrder() { return shuffleOrder; }, + + playAlbum(source: PlaybackSource, metadata: NowPlaying): void { + void stopJellyfinSession(getJellyfinItem(), progress); + currentSource?.destroy(); + const gen = ++loadGeneration; + currentSource = source; nowPlaying = metadata; + playbackState = 'loading'; isSeekable = true; isPlayerVisible = true; + queue = []; currentIndex = 0; shuffleOrder = []; consecutiveErrors = 0; + subscribeToSource(source, gen); source.setVolume(volume); persist(); + }, + + playQueue(items: QueueItem[], startIndex: number = 0, shuffle: boolean = false): void { + if (items.length === 0) return; + const s = buildPlayQueueState(items, startIndex, shuffle); + queue = s.queue; shuffleEnabled = s.shuffleEnabled; shuffleOrder = s.shuffleOrder; + isPlayerVisible = s.isPlayerVisible; consecutiveErrors = 0; + void loadQueueItem(s.startIndex); + }, + + nextTrack(): void { + const idx = getNextIndex(); + if (idx !== null) void loadQueueItem(idx).then(() => { + const c = performCleanup(queue, currentIndex, shuffleEnabled, shuffleOrder, MAX_HISTORY_LENGTH); + queue = c.newQueue; currentIndex = c.newIndex; shuffleOrder = c.newShuffleOrder; persist(); + }); + }, + + previousTrack(): void { + const idx = getPreviousIndex(); + if (idx !== null) void loadQueueItem(idx); + }, + + toggleShuffle(): void { + const r = computeToggleShuffle(queue.length, currentIndex, shuffleEnabled); + shuffleEnabled = r.shuffleEnabled; shuffleOrder = r.shuffleOrder; + }, + + jumpToTrack(index: number): void { + if (index >= 0 && index < queue.length) void loadQueueItem(index); + }, + + addToQueue(item: QueueItem): void { + if (queue.length === 0) { this.playQueue([stampSingleOrigin(item, 'manual')], 0, false); showQueueMutationToast('queue', 1); return; } + const r = addItemToQueue(queue, item, shuffleEnabled, shuffleOrder); + queue = r.newQueue; shuffleOrder = r.newShuffleOrder; + persist(); showQueueMutationToast('queue', 1); + }, + + addMultipleToQueue(items: QueueItem[]): void { + if (items.length === 0) return; + if (queue.length === 0) { this.playQueue(stampOrigin(items, 'manual'), 0, false); showQueueMutationToast('queue', items.length); return; } + const r = addMultipleItems(queue, items, shuffleEnabled, shuffleOrder); + queue = r.newQueue; shuffleOrder = r.newShuffleOrder; + persist(); showQueueMutationToast('queue', items.length); + }, + + playNext(item: QueueItem): void { + if (queue.length === 0) { this.playQueue([stampSingleOrigin(item, 'manual')], 0, false); showQueueMutationToast('next', 1); return; } + const r = insertPlayNext(queue, item, currentIndex, shuffleEnabled, shuffleOrder); + queue = r.newQueue; shuffleOrder = r.newShuffleOrder; + persist(); showQueueMutationToast('next', 1); + }, + + playMultipleNext(items: QueueItem[]): void { + if (items.length === 0) return; + if (queue.length === 0) { this.playQueue(stampOrigin(items, 'manual'), 0, false); showQueueMutationToast('next', items.length); return; } + const r = insertMultipleNext(queue, items, currentIndex, shuffleEnabled, shuffleOrder); + queue = r.newQueue; shuffleOrder = r.newShuffleOrder; + persist(); showQueueMutationToast('next', items.length); + }, + + removeFromQueue(index: number): void { + if (index < 0 || index >= queue.length) return; + if (queue.length <= 1) { this.stop(); return; } + const r = removeAtIndex(queue, index, currentIndex, shuffleEnabled, shuffleOrder); + queue = r.newQueue; currentIndex = r.newIndex; shuffleOrder = r.newShuffleOrder; + if (r.wasPlaying) { void loadQueueItem(r.newIndex); } + else { persist(); } + }, + + reorderQueue(fromIndex: number, toIndex: number): void { + const r = performReorder(queue, fromIndex, toIndex, currentIndex); + queue = r.newQueue; currentIndex = r.newCurrentIndex; persist(); + }, + + reorderShuffleOrder(fromPos: number, toPos: number): void { + shuffleOrder = performShuffleReorder(shuffleOrder, fromPos, toPos); persist(); + }, + + clearQueue(): void { + if (queue.length === 0 || !queue[currentIndex]) { this.stop(); return; } + const r = clearQueueKeepCurrent(queue, currentIndex); + queue = r.newQueue; currentIndex = r.newIndex; + shuffleEnabled = false; shuffleOrder = []; persist(); + }, + + changeTrackSource(index: number, newSourceType: SourceType): void { + if (index < 0 || index >= queue.length) return; + if (index === currentIndex) { playbackToast.show('Cannot change source for the currently playing track', 'warning'); return; } + const r = changeItemSource(queue, index, newSourceType); + if (r.error) { playbackToast.show(r.error, 'warning'); return; } + queue = r.newQueue; persist(); + }, + + updateQueueItemByPlaylistTrackId(playlistTrackId: string, newSourceType: SourceType, newTrackSourceId: string, newFormat?: string): void { + const r = updateItemByPlaylistTrackId(queue, playlistTrackId, currentIndex, newSourceType, newTrackSourceId, newFormat); + if (r) { queue = r; persist(); } + }, + + play(): void { currentSource?.play(); }, + + pause(): void { + currentSource?.pause(); + const jf = getJellyfinItem(); + if (jf?.playSessionId) void reportJellyfinProgress(jf.trackSourceId, jf.playSessionId, progress, true); + persist(); + }, + + togglePlay(): void { if (isPlaying) currentSource?.pause(); else currentSource?.play(); }, + seekTo(seconds: number): void { currentSource?.seekTo(seconds); progress = seconds; persist(); }, + + setVolume(level: number): void { + const clamped = Math.max(0, Math.min(100, level)); + volume = clamped; currentSource?.setVolume(clamped); storeVolume(clamped); + }, + + stop(): void { + void stopJellyfinSession(getJellyfinItem(), progress); + if (errorSkipTimeout) { clearTimeout(errorSkipTimeout); errorSkipTimeout = null; } + loadGeneration++; + applyResetState(); + }, + + restoreSession(): StoredSession | null { return restoreSessionData(); }, + + resumeSession(): void { + const session = restoreSessionData(); + if (!session) return; + const resume = buildResumeState(session); + if (!resume) return; + + queue = resume.queue; shuffleEnabled = resume.shuffleEnabled; shuffleOrder = resume.shuffleOrder; + isPlayerVisible = true; consecutiveErrors = 0; + void stopJellyfinSession(getJellyfinItem(), progress); + currentSource?.destroy(); + currentIndex = resume.currentIndex; playbackState = 'loading'; isSeekable = true; progress = 0; duration = 0; + const gen = ++loadGeneration; + + void resolveSourceForItem(resume.currentItem, resume.currentIndex).then(async ({ source, loadUrl }) => { + if (gen !== loadGeneration) return; + currentSource = source; nowPlaying = resume.nowPlaying; + subscribeToSource(source, gen); source.setVolume(volume); + if (resume.currentItem.sourceType === 'jellyfin') await startJellyfinPlayback(resume.currentIndex); + await source.load({ trackSourceId: resume.currentItem.trackSourceId, url: loadUrl, format: resume.currentItem.format }); + if (gen !== loadGeneration) return; + playbackState = 'paused'; duration = source.getDuration(); + if (resume.progress > 0) { source.seekTo(resume.progress); progress = resume.progress; } + }).catch(() => { + if (gen === loadGeneration) { playbackState = 'error'; storeSessionData(null); } + }); + }, + }; +} + +export const playerStore = createPlayerStore(); diff --git a/frontend/src/lib/stores/playerJellyfinReporting.ts b/frontend/src/lib/stores/playerJellyfinReporting.ts new file mode 100644 index 0000000..a98aa6a --- /dev/null +++ b/frontend/src/lib/stores/playerJellyfinReporting.ts @@ -0,0 +1,98 @@ +import type { QueueItem } from '$lib/player/types'; + +export interface ProgressReporterState { + jellyfinItem: QueueItem | null; + progress: number; + isPaused: boolean; +} + +type ReportProgressFn = ( + trackSourceId: string, + playSessionId: string, + progress: number, + isPaused: boolean, +) => Promise; + +export function createProgressReporter( + reportProgress: ReportProgressFn, + intervalMs: number, + maxFailures: number, +) { + let interval: ReturnType | null = null; + let consecutiveFailures = 0; + + function start(getState: () => ProgressReporterState): void { + stop(); + const item = getState().jellyfinItem; + if (!item?.playSessionId) return; + + interval = setInterval(async () => { + const { jellyfinItem, progress, isPaused } = getState(); + if (!jellyfinItem?.playSessionId) { + stop(); + return; + } + try { + const ok = await reportProgress( + jellyfinItem.trackSourceId, + jellyfinItem.playSessionId, + progress, + isPaused, + ); + if (ok) { + consecutiveFailures = 0; + return; + } + consecutiveFailures += 1; + if (consecutiveFailures >= maxFailures) stop(); + } catch {} + }, intervalMs); + } + + function stop(): void { + if (interval) { + clearInterval(interval); + interval = null; + } + consecutiveFailures = 0; + } + + return { start, stop }; +} + +export function buildStopSessionPayload( + playSessionId: string, + positionSeconds: number, +): { play_session_id: string; position_seconds: number } { + return { play_session_id: playSessionId, position_seconds: positionSeconds }; +} + +export function createBeforeUnloadHandler( + getState: () => { + jellyfinItem: QueueItem | null; + currentItem: QueueItem | null; + progress: number; + }, + jellyfinStopUrl: (trackSourceId: string) => string, + navidromeScrobbleUrl: (trackSourceId: string) => string, +): () => void { + return () => { + if (typeof navigator === 'undefined' || typeof navigator.sendBeacon !== 'function') return; + const { jellyfinItem, currentItem, progress } = getState(); + + if (jellyfinItem?.playSessionId) { + const payload = new Blob( + [JSON.stringify(buildStopSessionPayload(jellyfinItem.playSessionId, progress))], + { type: 'application/json' }, + ); + navigator.sendBeacon(jellyfinStopUrl(jellyfinItem.trackSourceId), payload); + } + + if (currentItem?.sourceType === 'navidrome' && progress > 30) { + navigator.sendBeacon( + navidromeScrobbleUrl(currentItem.trackSourceId), + new Blob([], { type: 'application/json' }), + ); + } + }; +} diff --git a/frontend/src/lib/stores/playerPlaybackMethods.ts b/frontend/src/lib/stores/playerPlaybackMethods.ts new file mode 100644 index 0000000..36c3cf0 --- /dev/null +++ b/frontend/src/lib/stores/playerPlaybackMethods.ts @@ -0,0 +1,138 @@ +import type { QueueItem, SourceType } from '$lib/player/types'; +import { shuffleArray, stampOrigin } from './playerUtils'; +import { buildStreamUrlForSource } from './playerSourceResolver'; + +export interface PlayQueueResult { + queue: QueueItem[]; + shuffleEnabled: boolean; + shuffleOrder: number[]; + isPlayerVisible: boolean; + startIndex: number; +} + +export function buildPlayQueueState( + items: QueueItem[], + startIndex: number, + shuffle: boolean, +): PlayQueueResult { + const queue = stampOrigin(items, 'context'); + const shuffleOrder = shuffle ? shuffleArray(items.length) : []; + const actualStart = shuffle ? shuffleOrder[0] : startIndex; + return { + queue, + shuffleEnabled: shuffle, + shuffleOrder, + isPlayerVisible: true, + startIndex: actualStart, + }; +} + +export function buildPlayAlbumState(): { + playbackState: 'loading'; + isSeekable: true; + isPlayerVisible: true; + queue: []; + currentIndex: 0; + shuffleOrder: []; +} { + return { + playbackState: 'loading', + isSeekable: true, + isPlayerVisible: true, + queue: [], + currentIndex: 0, + shuffleOrder: [], + }; +} + +export interface ToggleShuffleResult { + shuffleEnabled: boolean; + shuffleOrder: number[]; +} + +export function computeToggleShuffle( + queueLength: number, + currentIndex: number, + currentlyEnabled: boolean, +): ToggleShuffleResult { + if (!currentlyEnabled) { + const allIndices = Array.from({ length: queueLength }, (_, i) => i); + const upcoming = allIndices.filter((i) => i !== currentIndex && i > currentIndex); + const played = allIndices.filter((i) => i < currentIndex); + + for (let i = upcoming.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [upcoming[i], upcoming[j]] = [upcoming[j], upcoming[i]]; + } + + return { + shuffleEnabled: true, + shuffleOrder: [...played, currentIndex, ...upcoming], + }; + } + return { shuffleEnabled: false, shuffleOrder: [] }; +} + +export function buildResetState() { + return { + nowPlaying: null as null, + playbackState: 'idle' as const, + isSeekable: true as const, + isPlayerVisible: false as const, + progress: 0, + duration: 0, + queue: [] as QueueItem[], + currentIndex: 0, + shuffleOrder: [] as number[], + shuffleEnabled: false, + }; +} + +export function changeItemSource( + queue: QueueItem[], + index: number, + newSourceType: SourceType, +): { newQueue: QueueItem[]; error?: string } { + if (index < 0 || index >= queue.length) return { newQueue: queue }; + + const item = queue[index]; + if (!item.availableSources?.includes(newSourceType)) return { newQueue: queue }; + + const resolvedId = item.sourceIds?.[newSourceType]; + if (!resolvedId) return { newQueue: queue, error: 'Source ID unavailable for this track' }; + + const streamUrl = buildStreamUrlForSource(newSourceType, resolvedId); + const newQueue = [...queue]; + newQueue[index] = { + ...item, + sourceType: newSourceType, + trackSourceId: resolvedId, + streamUrl, + playSessionId: undefined, + }; + return { newQueue }; +} + +export function updateItemByPlaylistTrackId( + queue: QueueItem[], + playlistTrackId: string, + currentIndex: number, + newSourceType: SourceType, + newTrackSourceId: string, + newFormat?: string, +): QueueItem[] | null { + const index = queue.findIndex((item) => item.playlistTrackId === playlistTrackId); + if (index < 0 || index === currentIndex) return null; + + const streamUrl = buildStreamUrlForSource(newSourceType, newTrackSourceId); + const newQueue = [...queue]; + newQueue[index] = { + ...newQueue[index], + sourceType: newSourceType, + trackSourceId: newTrackSourceId, + streamUrl, + format: newFormat, + playSessionId: undefined, + }; + return newQueue; +} diff --git a/frontend/src/lib/stores/playerQueueMethods.ts b/frontend/src/lib/stores/playerQueueMethods.ts new file mode 100644 index 0000000..08c5b96 --- /dev/null +++ b/frontend/src/lib/stores/playerQueueMethods.ts @@ -0,0 +1,129 @@ +import type { QueueItem } from '$lib/player/types'; +import { stampOrigin, stampSingleOrigin } from './playerUtils'; +import { reorderItems, reorderShuffleItems } from './playerQueueOps'; + +export function addItemToQueue( + queue: QueueItem[], + item: QueueItem, + shuffleEnabled: boolean, + shuffleOrder: number[], +): { newQueue: QueueItem[]; newShuffleOrder: number[] } { + const newQueue = [...queue, stampSingleOrigin(item, 'manual')]; + const newShuffleOrder = shuffleEnabled + ? [...shuffleOrder, newQueue.length - 1] + : shuffleOrder; + return { newQueue, newShuffleOrder }; +} + +export function addMultipleItems( + queue: QueueItem[], + items: QueueItem[], + shuffleEnabled: boolean, + shuffleOrder: number[], +): { newQueue: QueueItem[]; newShuffleOrder: number[] } { + const stamped = stampOrigin(items, 'manual'); + const startIdx = queue.length; + const newQueue = [...queue, ...stamped]; + const newShuffleOrder = shuffleEnabled + ? [...shuffleOrder, ...items.map((_, i) => startIdx + i)] + : shuffleOrder; + return { newQueue, newShuffleOrder }; +} + +export function insertPlayNext( + queue: QueueItem[], + item: QueueItem, + currentIndex: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): { newQueue: QueueItem[]; newShuffleOrder: number[] } { + const insertAt = currentIndex + 1; + const newQueue = [...queue]; + newQueue.splice(insertAt, 0, stampSingleOrigin(item, 'manual')); + let newShuffleOrder = shuffleOrder; + if (shuffleEnabled) { + const updated = shuffleOrder.map((i) => (i >= insertAt ? i + 1 : i)); + const shuffleIdx = updated.indexOf(currentIndex); + updated.splice(shuffleIdx + 1, 0, insertAt); + newShuffleOrder = updated; + } + return { newQueue, newShuffleOrder }; +} + +export function insertMultipleNext( + queue: QueueItem[], + items: QueueItem[], + currentIndex: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): { newQueue: QueueItem[]; newShuffleOrder: number[] } { + const stamped = stampOrigin(items, 'manual'); + const insertAt = currentIndex + 1; + const newQueue = [...queue]; + newQueue.splice(insertAt, 0, ...stamped); + let newShuffleOrder = shuffleOrder; + if (shuffleEnabled) { + const updated = shuffleOrder.map((i) => (i >= insertAt ? i + items.length : i)); + const shuffleIdx = updated.indexOf(currentIndex); + const newIndices = items.map((_, i) => insertAt + i); + updated.splice(shuffleIdx + 1, 0, ...newIndices); + newShuffleOrder = updated; + } + return { newQueue, newShuffleOrder }; +} + +export function removeAtIndex( + queue: QueueItem[], + index: number, + currentIndex: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): { newQueue: QueueItem[]; newIndex: number; newShuffleOrder: number[]; wasPlaying: boolean } { + const wasPlaying = index === currentIndex; + const newQueue = queue.filter((_, i) => i !== index); + const newShuffleOrder = shuffleEnabled + ? shuffleOrder.filter((i) => i !== index).map((i) => (i > index ? i - 1 : i)) + : shuffleOrder; + const newIndex = wasPlaying + ? Math.min(index, newQueue.length - 1) + : currentIndex > index ? currentIndex - 1 : currentIndex; + return { newQueue, newIndex, newShuffleOrder, wasPlaying }; +} + +export function performReorder( + queue: QueueItem[], + fromIndex: number, + toIndex: number, + currentIndex: number, +): { newQueue: QueueItem[]; newCurrentIndex: number } { + if (fromIndex === toIndex || fromIndex < 0 || fromIndex >= queue.length || toIndex < 0 || toIndex >= queue.length) { + return { newQueue: queue, newCurrentIndex: currentIndex }; + } + const newQueue = reorderItems(queue, fromIndex, toIndex); + let newCurrentIndex = currentIndex; + if (currentIndex === fromIndex) { + newCurrentIndex = toIndex; + } else if (fromIndex < currentIndex && toIndex >= currentIndex) { + newCurrentIndex = currentIndex - 1; + } else if (fromIndex > currentIndex && toIndex <= currentIndex) { + newCurrentIndex = currentIndex + 1; + } + return { newQueue, newCurrentIndex }; +} + +export function performShuffleReorder( + shuffleOrder: number[], + fromPos: number, + toPos: number, +): number[] { + return reorderShuffleItems(shuffleOrder, fromPos, toPos); +} + +export function clearQueueKeepCurrent( + queue: QueueItem[], + currentIndex: number, +): { newQueue: QueueItem[]; newIndex: number } { + const currentItem = queue[currentIndex]; + if (!currentItem) return { newQueue: [], newIndex: 0 }; + return { newQueue: [currentItem], newIndex: 0 }; +} diff --git a/frontend/src/lib/stores/playerQueueOps.ts b/frontend/src/lib/stores/playerQueueOps.ts new file mode 100644 index 0000000..84cd7a5 --- /dev/null +++ b/frontend/src/lib/stores/playerQueueOps.ts @@ -0,0 +1,119 @@ +import type { QueueItem } from '$lib/player/types'; + +export function computeNextIndex( + currentIndex: number, + queueLength: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): number | null { + if (queueLength <= 1) return null; + if (shuffleEnabled) { + const shuffleIdx = shuffleOrder.indexOf(currentIndex); + if (shuffleIdx < shuffleOrder.length - 1) return shuffleOrder[shuffleIdx + 1]; + return null; + } + if (currentIndex < queueLength - 1) return currentIndex + 1; + return null; +} + +export function computePreviousIndex( + currentIndex: number, + queueLength: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): number | null { + if (queueLength <= 1) return null; + if (shuffleEnabled) { + const shuffleIdx = shuffleOrder.indexOf(currentIndex); + if (shuffleIdx > 0) return shuffleOrder[shuffleIdx - 1]; + return null; + } + if (currentIndex > 0) return currentIndex - 1; + return null; +} + +export function computeUpcomingLength( + queueLength: number, + currentIndex: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): number { + if (queueLength === 0) return 0; + if (shuffleEnabled) { + const shuffleIdx = shuffleOrder.indexOf(currentIndex); + if (shuffleIdx < 0) return Math.max(0, queueLength - 1); + return Math.max(0, shuffleOrder.length - shuffleIdx - 1); + } + return Math.max(0, queueLength - currentIndex - 1); +} + +export function performCleanup( + queue: QueueItem[], + currentIndex: number, + shuffleEnabled: boolean, + shuffleOrder: number[], + maxHistory: number, +): { newQueue: QueueItem[]; newIndex: number; newShuffleOrder: number[] } { + if (queue.length <= 1) return { newQueue: queue, newIndex: currentIndex, newShuffleOrder: shuffleOrder }; + + let playedIndices: number[]; + if (shuffleEnabled) { + const currentShufflePos = shuffleOrder.indexOf(currentIndex); + if (currentShufflePos <= 0) return { newQueue: queue, newIndex: currentIndex, newShuffleOrder: shuffleOrder }; + playedIndices = shuffleOrder.slice(0, currentShufflePos); + } else { + if (currentIndex <= 0) return { newQueue: queue, newIndex: currentIndex, newShuffleOrder: shuffleOrder }; + playedIndices = Array.from({ length: currentIndex }, (_, i) => i); + } + + const toRemove = new Set(); + for (const idx of playedIndices) { + if (queue[idx]?.queueOrigin === 'manual') toRemove.add(idx); + } + + const remainingPlayed = playedIndices.filter((idx) => !toRemove.has(idx)); + const excess = remainingPlayed.length - maxHistory; + if (excess > 0) { + for (let i = 0; i < excess; i++) toRemove.add(remainingPlayed[i]); + } + + if (toRemove.size === 0) return { newQueue: queue, newIndex: currentIndex, newShuffleOrder: shuffleOrder }; + + const indexMap = new Map(); + let shift = 0; + for (let i = 0; i < queue.length; i++) { + if (toRemove.has(i)) { + shift++; + } else { + indexMap.set(i, i - shift); + } + } + + const newQueue = queue.filter((_, i) => !toRemove.has(i)); + const newIndex = indexMap.get(currentIndex) ?? 0; + const newShuffleOrder = shuffleEnabled + ? shuffleOrder.filter((i) => !toRemove.has(i)).map((i) => indexMap.get(i)!) + : shuffleOrder; + + return { newQueue, newIndex, newShuffleOrder }; +} + +export function reorderItems(items: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex === toIndex) return items; + const newItems = [...items]; + const [moved] = newItems.splice(fromIndex, 1); + newItems.splice(toIndex, 0, moved); + return newItems; +} + +export function reorderShuffleItems( + shuffleOrder: number[], + fromPos: number, + toPos: number, +): number[] { + if (fromPos === toPos) return shuffleOrder; + const newOrder = [...shuffleOrder]; + const [moved] = newOrder.splice(fromPos, 1); + newOrder.splice(toPos, 0, moved); + return newOrder; +} diff --git a/frontend/src/lib/stores/playerSessionManager.ts b/frontend/src/lib/stores/playerSessionManager.ts new file mode 100644 index 0000000..3d0e260 --- /dev/null +++ b/frontend/src/lib/stores/playerSessionManager.ts @@ -0,0 +1,58 @@ +import type { NowPlaying, QueueItem, SourceType } from '$lib/player/types'; +import { getStoredSession, storeSessionData, normalizeSourceType, migrateLegacyItem } from './playerUtils'; +import type { StoredSession } from './playerUtils'; + +export function persistSession( + nowPlaying: NowPlaying | null, + queue: QueueItem[], + currentIndex: number, + progress: number, + shuffleEnabled: boolean, + shuffleOrder: number[], +): void { + if (!nowPlaying) { + storeSessionData(null); + return; + } + storeSessionData({ nowPlaying, queue, currentIndex, progress, shuffleEnabled, shuffleOrder }); +} + +export function restoreSessionData(): StoredSession | null { + return getStoredSession(); +} + +export interface ResumeState { + nowPlaying: NowPlaying; + queue: QueueItem[]; + currentIndex: number; + progress: number; + shuffleEnabled: boolean; + shuffleOrder: number[]; + currentItem: QueueItem; +} + +export function buildResumeState(session: StoredSession): ResumeState | null { + const migratedNowPlaying: NowPlaying = { + ...session.nowPlaying, + sourceType: normalizeSourceType(session.nowPlaying.sourceType as SourceType | 'howler'), + }; + const migratedQueue = session.queue.map((item) => + migrateLegacyItem(item as QueueItem & { sourceType: SourceType | 'howler' }) + ); + + if (migratedNowPlaying.sourceType === 'youtube') return null; + if (!migratedQueue.length) return null; + + const currentItem = migratedQueue[session.currentIndex]; + if (!currentItem) return null; + + return { + nowPlaying: migratedNowPlaying, + queue: migratedQueue, + currentIndex: session.currentIndex, + progress: session.progress, + shuffleEnabled: session.shuffleEnabled, + shuffleOrder: session.shuffleOrder, + currentItem, + }; +} diff --git a/frontend/src/lib/stores/playerSourceResolver.ts b/frontend/src/lib/stores/playerSourceResolver.ts new file mode 100644 index 0000000..cbf264b --- /dev/null +++ b/frontend/src/lib/stores/playerSourceResolver.ts @@ -0,0 +1,63 @@ +import { API } from '$lib/constants'; +import type { NowPlaying, QueueItem, SourceType } from '$lib/player/types'; + +export function resolveSourceUrl(item: QueueItem): string | undefined { + switch (item.sourceType) { + case 'youtube': + return item.streamUrl; + case 'local': + return item.streamUrl ?? API.stream.local(item.trackSourceId); + case 'navidrome': + return item.streamUrl ?? API.stream.navidrome(item.trackSourceId); + case 'jellyfin': + return undefined; + } +} + +export function buildPrefetchUrl(item: QueueItem): string | null { + switch (item.sourceType) { + case 'youtube': + return null; + case 'jellyfin': + return API.stream.jellyfin(item.trackSourceId); + case 'navidrome': + return API.stream.navidrome(item.trackSourceId); + case 'local': + return API.stream.local(item.trackSourceId); + default: + return item.streamUrl ?? null; + } +} + +export function buildStreamUrlForSource( + sourceType: SourceType, + trackSourceId: string, +): string | undefined { + switch (sourceType) { + case 'local': + return API.stream.local(trackSourceId); + case 'navidrome': + return API.stream.navidrome(trackSourceId); + case 'jellyfin': + return API.stream.jellyfin(trackSourceId); + default: + return undefined; + } +} + +export function buildNowPlayingMetadata(item: QueueItem): NowPlaying { + return { + albumId: item.albumId, + albumName: item.albumName, + artistName: item.artistName, + coverUrl: item.coverUrl, + sourceType: item.sourceType, + discNumber: item.discNumber, + trackSourceId: item.trackSourceId, + trackName: item.trackName, + artistId: item.artistId, + streamUrl: item.streamUrl, + format: item.format, + playlistTrackId: item.playlistTrackId, + }; +} diff --git a/frontend/src/lib/stores/playerUtils.ts b/frontend/src/lib/stores/playerUtils.ts new file mode 100644 index 0000000..856a830 --- /dev/null +++ b/frontend/src/lib/stores/playerUtils.ts @@ -0,0 +1,97 @@ +import type { NowPlaying, QueueItem, QueueOrigin, SourceType } from '$lib/player/types'; +import { playbackToast } from '$lib/stores/playbackToast.svelte'; + +export const VOLUME_STORAGE_KEY = 'musicseerr_player_volume'; +export const SESSION_STORAGE_KEY = 'musicseerr_player_session'; + +export type StoredSession = { + nowPlaying: NowPlaying; + queue: QueueItem[]; + currentIndex: number; + progress: number; + shuffleEnabled: boolean; + shuffleOrder: number[]; +}; + +export function shuffleArray(length: number): number[] { + const arr = Array.from({ length }, (_, i) => i); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +export function stampOrigin(items: QueueItem[], origin: QueueOrigin): QueueItem[] { + return items.map((item) => ({ ...item, queueOrigin: origin })); +} + +export function stampSingleOrigin(item: QueueItem, origin: QueueOrigin): QueueItem { + return { ...item, queueOrigin: origin }; +} + +export function normalizeSourceType(sourceType: SourceType | 'howler'): SourceType { + return sourceType === 'howler' ? 'local' : sourceType; +} + +export function migrateLegacyItem(item: QueueItem & { sourceType: SourceType | 'howler' }): QueueItem { + const sourceType = normalizeSourceType(item.sourceType); + const availableSources = item.availableSources?.map((source) => + normalizeSourceType(source as SourceType | 'howler') + ); + return { + ...item, + sourceType, + availableSources, + queueOrigin: item.queueOrigin ?? 'context', + }; +} + +export function showQueueMutationToast(action: 'queue' | 'next', count: number): void { + const label = count === 1 ? 'track' : 'tracks'; + if (action === 'queue') { + playbackToast.show( + count === 1 ? 'Added track to queue' : `Added ${count} ${label} to queue`, + 'info' + ); + return; + } + playbackToast.show( + count === 1 ? 'Queued track to play next' : `Queued ${count} ${label} to play next`, + 'info' + ); +} + +export function getStoredVolume(): number { + try { + const stored = localStorage.getItem(VOLUME_STORAGE_KEY); + if (stored !== null) return Math.max(0, Math.min(100, Number(stored))); + } catch {} + return 75; +} + +export function storeVolume(volume: number): void { + try { + localStorage.setItem(VOLUME_STORAGE_KEY, String(volume)); + } catch {} +} + +export function getStoredSession(): StoredSession | null { + try { + const stored = localStorage.getItem(SESSION_STORAGE_KEY); + if (!stored) return null; + const parsed = JSON.parse(stored); + if (parsed && parsed.nowPlaying) return parsed as StoredSession; + } catch {} + return null; +} + +export function storeSessionData(data: StoredSession | null): void { + try { + if (data) { + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(data)); + } else { + localStorage.removeItem(SESSION_STORAGE_KEY); + } + } catch {} +} diff --git a/frontend/src/lib/stores/preferences.ts b/frontend/src/lib/stores/preferences.ts new file mode 100644 index 0000000..44190f2 --- /dev/null +++ b/frontend/src/lib/stores/preferences.ts @@ -0,0 +1,40 @@ +import { writable } from 'svelte/store'; +import type { UserPreferences } from '$lib/types'; +import { api } from '$lib/api/client'; + +const API_BASE = '/api/v1'; + +const defaultPreferences: UserPreferences = { + primary_types: ['album', 'ep', 'single'], + secondary_types: ['studio'], + release_statuses: ['official'] +}; + +const { subscribe, set, update } = writable(defaultPreferences); + +async function loadPreferences(): Promise { + try { + const prefs = await api.global.get(`${API_BASE}/settings/preferences`); + set(prefs); + } catch (e) { + console.error('Failed to load preferences:', e); + } +} + +async function savePreferences(prefs: UserPreferences): Promise { + try { + const updated = await api.global.put(`${API_BASE}/settings/preferences`, prefs); + set(updated); + return true; + } catch (e) { + console.error('Failed to save preferences:', e); + return false; + } +} + +export const preferencesStore = { + subscribe, + load: loadPreferences, + save: savePreferences, + update +}; diff --git a/frontend/src/lib/stores/recentlyAdded.ts b/frontend/src/lib/stores/recentlyAdded.ts new file mode 100644 index 0000000..0d3a6c8 --- /dev/null +++ b/frontend/src/lib/stores/recentlyAdded.ts @@ -0,0 +1,126 @@ +import { writable, get } from 'svelte/store'; +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; +import { api } from '$lib/api/client'; + +interface LibraryArtist { + name: string; + mbid: string; + album_count: number; + date_added: string | null; +} + +interface LibraryAlbum { + album: string; + artist: string; + artist_mbid: string | null; + foreignAlbumId: string | null; + year: number | null; + monitored: boolean; + cover_url: string | null; + date_added: number | null; +} + +interface RecentlyAddedData { + artists: LibraryArtist[]; + albums: LibraryAlbum[]; +} + +interface RecentlyAddedState { + data: RecentlyAddedData | null; + loading: boolean; + lastUpdated: number | null; + initialized: boolean; +} + +const cache = createLocalStorageCache( + CACHE_KEYS.RECENTLY_ADDED, + CACHE_TTL.RECENTLY_ADDED +); + +function getInitialState(): RecentlyAddedState { + const cached = cache.get(); + if (cached?.data) { + return { + data: cached.data, + loading: false, + lastUpdated: cached.timestamp, + initialized: true + }; + } + return { + data: null, + loading: false, + lastUpdated: null, + initialized: false + }; +} + +function createRecentlyAddedStore() { + const { subscribe, set, update } = writable(getInitialState()); + + async function initialize() { + const state = get({ subscribe }); + if (state.loading) return; + + if (state.initialized && state.data) { + if (state.lastUpdated && cache.isStale(state.lastUpdated)) { + fetchRecentlyAdded(true); + } + return; + } + + await fetchRecentlyAdded(false); + } + + async function fetchRecentlyAdded(background = false) { + if (!background) { + update((s) => ({ ...s, loading: true })); + } + + try { + const data = await api.global.get('/api/v1/library/recently-added'); + + update((s) => ({ + ...s, + data, + loading: false, + lastUpdated: Date.now(), + initialized: true + })); + + cache.set(data); + } catch (e) { + if (!background) { + update((s) => ({ ...s, loading: false, initialized: true })); + } + } + } + + function isStale(): boolean { + const state = get({ subscribe }); + if (!state.lastUpdated) return true; + return cache.isStale(state.lastUpdated); + } + + async function refresh() { + await fetchRecentlyAdded(false); + } + + async function refreshInBackground() { + await fetchRecentlyAdded(true); + } + + return { + subscribe, + initialize, + refresh, + refreshInBackground, + isStale, + updateCacheTTL: cache.updateTTL + }; +} + +export const recentlyAddedStore = createRecentlyAddedStore(); + +export type { LibraryArtist, LibraryAlbum, RecentlyAddedData, RecentlyAddedState }; diff --git a/frontend/src/lib/stores/requestCountStore.spec.ts b/frontend/src/lib/stores/requestCountStore.spec.ts new file mode 100644 index 0000000..fe54d38 --- /dev/null +++ b/frontend/src/lib/stores/requestCountStore.spec.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$lib/api/client', () => ({ + api: { + global: { + get: vi.fn().mockResolvedValue({ count: 0 }) + } + } +})); + +import { requestCountStore } from './requestCountStore.svelte'; +import { api } from '$lib/api/client'; + +const mockGet = vi.mocked(api.global.get); + +describe('requestCountStore', () => { + beforeEach(() => { + vi.useFakeTimers(); + requestCountStore.stopPolling(); + requestCountStore.notify(0); + mockGet.mockResolvedValue({ count: 0 }); + }); + + afterEach(() => { + requestCountStore.stopPolling(); + vi.useRealTimers(); + }); + + it('starts with count 0', () => { + expect.assertions(1); + expect(requestCountStore.count).toBe(0); + }); + + it('notify with count sets count directly', () => { + expect.assertions(1); + requestCountStore.notify(5); + expect(requestCountStore.count).toBe(5); + }); + + it('notify without count triggers a poll', async () => { + expect.assertions(1); + mockGet.mockResolvedValue({ count: 3 }); + requestCountStore.notify(); + await vi.runAllTimersAsync(); + expect(requestCountStore.count).toBe(3); + }); + + it('startPolling fetches immediately and sets interval', async () => { + expect.assertions(2); + mockGet.mockResolvedValue({ count: 7 }); + requestCountStore.startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(requestCountStore.count).toBe(7); + expect(mockGet).toHaveBeenCalledWith('/api/v1/requests/active/count'); + }); + + it('stopPolling clears the interval', async () => { + expect.assertions(1); + requestCountStore.startPolling(); + await vi.advanceTimersByTimeAsync(0); + mockGet.mockClear(); + requestCountStore.stopPolling(); + await vi.advanceTimersByTimeAsync(20_000); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('setPageActive(true) pauses polling', async () => { + expect.assertions(1); + requestCountStore.startPolling(); + await vi.advanceTimersByTimeAsync(0); + mockGet.mockClear(); + requestCountStore.setPageActive(true); + await vi.advanceTimersByTimeAsync(20_000); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('setPageActive(false) resumes polling', async () => { + expect.assertions(1); + requestCountStore.setPageActive(true); + mockGet.mockClear(); + mockGet.mockResolvedValue({ count: 2 }); + requestCountStore.setPageActive(false); + await vi.advanceTimersByTimeAsync(10_000); + expect(mockGet).toHaveBeenCalled(); + }); + + it('isPageActive reflects current state', () => { + expect.assertions(2); + requestCountStore.setPageActive(true); + expect(requestCountStore.isPageActive).toBe(true); + requestCountStore.setPageActive(false); + expect(requestCountStore.isPageActive).toBe(false); + }); + + it('handles poll errors gracefully', async () => { + expect.assertions(1); + requestCountStore.notify(5); + mockGet.mockRejectedValue(new Error('network')); + requestCountStore.startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(requestCountStore.count).toBe(5); + }); +}); diff --git a/frontend/src/lib/stores/requestCountStore.svelte.ts b/frontend/src/lib/stores/requestCountStore.svelte.ts new file mode 100644 index 0000000..342f5ae --- /dev/null +++ b/frontend/src/lib/stores/requestCountStore.svelte.ts @@ -0,0 +1,69 @@ +import { browser } from '$app/environment'; +import { api } from '$lib/api/client'; + +const POLL_INTERVAL_MS = 10_000; + +function createRequestCountStore() { + let count = $state(0); + let pageActive = $state(false); + let pollInterval: ReturnType | null = null; + + async function poll(): Promise { + try { + const data = await api.global.get<{ count?: number }>('/api/v1/requests/active/count'); + count = data.count ?? 0; + } catch { + // ignore polling errors + } + } + + function clearPoll(): void { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + } + + function startPolling(): void { + if (!browser) return; + void poll(); + clearPoll(); + pollInterval = setInterval(() => void poll(), POLL_INTERVAL_MS); + } + + function stopPolling(): void { + clearPoll(); + } + + function setPageActive(active: boolean): void { + pageActive = active; + if (active) { + clearPoll(); + } else { + startPolling(); + } + } + + function notify(newCount?: number): void { + if (typeof newCount === 'number') { + count = newCount; + return; + } + void poll(); + } + + return { + get count() { + return count; + }, + get isPageActive() { + return pageActive; + }, + startPolling, + stopPolling, + setPageActive, + notify + }; +} + +export const requestCountStore = createRequestCountStore(); diff --git a/frontend/src/lib/stores/scrobble.spec.ts b/frontend/src/lib/stores/scrobble.spec.ts new file mode 100644 index 0000000..0859a66 --- /dev/null +++ b/frontend/src/lib/stores/scrobble.spec.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('$lib/api/client', () => ({ + api: { + global: { + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + }, + }, + ApiError: class extends Error { + status: number; + code: string; + details: unknown; + constructor(s: number, c: string, m: string, d?: unknown) { + super(m); this.status = s; this.code = c; this.details = d; + } + }, +})); + +import { scrobbleManager, formatServiceTooltip } from './scrobble.svelte'; +import { + makeTrackKey, + shouldAccumulate, + isLoopReset, + shouldSendNowPlaying, + shouldScrobble, + getScrobbleThreshold, + SCROBBLE_PERCENT_THRESHOLD, + SCROBBLE_TIME_THRESHOLD_MS, + NOW_PLAYING_DEBOUNCE_MS, + MIN_TRACK_DURATION_MS, + LOOP_RESET_TOLERANCE_S, +} from './scrobbleHelpers'; + +describe('formatServiceTooltip', () => { + it('returns "Tracking" when status is tracking', () => { + expect(formatServiceTooltip('tracking', null)).toBe('Tracking'); + }); + + it('returns "Scrobbled" when scrobbled with no detail', () => { + expect(formatServiceTooltip('scrobbled', null)).toBe('Scrobbled'); + }); + + it('returns "Scrobbled" when scrobbled with empty detail', () => { + expect(formatServiceTooltip('scrobbled', {})).toBe('Scrobbled'); + }); + + it('lists successful services', () => { + const detail = { + lastfm: { success: true }, + listenbrainz: { success: true }, + }; + expect(formatServiceTooltip('scrobbled', detail)).toBe('Scrobbled to Last.fm, ListenBrainz'); + }); + + it('lists failed services', () => { + const detail = { + lastfm: { success: false }, + }; + expect(formatServiceTooltip('error', detail)).toBe('Failed: Last.fm'); + }); + + it('shows mixed results with separator', () => { + const detail = { + lastfm: { success: true }, + listenbrainz: { success: false }, + }; + const result = formatServiceTooltip('scrobbled', detail); + expect(result).toContain('Scrobbled to Last.fm'); + expect(result).toContain('Failed: ListenBrainz'); + expect(result).toContain(' · '); + }); + + it('returns "Scrobble failed" for error with no detail', () => { + expect(formatServiceTooltip('error', null)).toBe('Scrobble failed'); + }); + + it('passes through unknown service names as-is', () => { + const detail = { spotify: { success: true } }; + expect(formatServiceTooltip('scrobbled', detail)).toBe('Scrobbled to spotify'); + }); +}); + +describe('scrobbleManager', () => { + it('starts idle and disabled', () => { + expect(scrobbleManager.status).toBe('idle'); + expect(scrobbleManager.enabled).toBe(false); + }); + + describe('init', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + }); + + it('enables when lastfm scrobbling is on', async () => { + mockGet.mockResolvedValueOnce({ scrobble_to_lastfm: true, scrobble_to_listenbrainz: false }); + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(true); + }); + + it('enables when listenbrainz scrobbling is on', async () => { + mockGet.mockResolvedValueOnce({ scrobble_to_lastfm: false, scrobble_to_listenbrainz: true }); + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(true); + }); + + it('disables when both are off', async () => { + mockGet.mockResolvedValueOnce({ scrobble_to_lastfm: false, scrobble_to_listenbrainz: false }); + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(false); + }); + + it('keeps prior state on fetch failure during init', async () => { + mockGet.mockResolvedValueOnce({ scrobble_to_lastfm: true, scrobble_to_listenbrainz: false }); + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(true); + + mockGet.mockRejectedValueOnce(new Error('network')); + await scrobbleManager.init(); + expect(scrobbleManager.enabled).toBe(true); + }); + }); + + describe('refreshSettings', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + }); + + it('clears cache and re-fetches', async () => { + mockGet + .mockResolvedValueOnce({ scrobble_to_lastfm: true, scrobble_to_listenbrainz: false }) + .mockResolvedValueOnce({ scrobble_to_lastfm: false, scrobble_to_listenbrainz: false }); + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(true); + + await scrobbleManager.refreshSettings(); + expect(scrobbleManager.enabled).toBe(false); + expect(mockGet).toHaveBeenCalledTimes(2); + }); + }); +}); + +describe('makeTrackKey', () => { + it('lowercases and joins artist and track', () => { + expect.assertions(1); + expect(makeTrackKey('Radiohead', 'Creep')).toBe('radiohead::creep'); + }); + + it('produces different keys for different tracks', () => { + expect.assertions(1); + expect(makeTrackKey('Muse', 'Hysteria')).not.toBe(makeTrackKey('Muse', 'Starlight')); + }); +}); + +describe('shouldAccumulate (seek inflation protection)', () => { + it('accumulates for normal 1-second playback delta', () => { + expect.assertions(1); + expect(shouldAccumulate(1)).toBe(true); + }); + + it('accumulates for small positive delta under tolerance', () => { + expect.assertions(1); + expect(shouldAccumulate(2.5)).toBe(true); + }); + + it('rejects zero delta', () => { + expect.assertions(1); + expect(shouldAccumulate(0)).toBe(false); + }); + + it('rejects negative delta (seek backward)', () => { + expect.assertions(1); + expect(shouldAccumulate(-5)).toBe(false); + }); + + it('rejects large forward seek (above tolerance)', () => { + expect.assertions(1); + expect(shouldAccumulate(10)).toBe(false); + }); + + it('rejects delta exactly at tolerance boundary', () => { + expect.assertions(1); + expect(shouldAccumulate(3)).toBe(false); + }); +}); + +describe('isLoopReset', () => { + const DURATION_MS = 180_000; // 3 minutes = 180s + + it('detects loop when progress resets from near end to near start', () => { + expect.assertions(1); + expect(isLoopReset(179, 0.5, DURATION_MS)).toBe(true); + }); + + it('does not trigger when previous progress is mid-track', () => { + expect.assertions(1); + expect(isLoopReset(90, 0.5, DURATION_MS)).toBe(false); + }); + + it('does not trigger when current progress is past tolerance from start', () => { + expect.assertions(1); + expect(isLoopReset(179, 5, DURATION_MS)).toBe(false); + }); + + it('does not trigger when duration is zero', () => { + expect.assertions(1); + expect(isLoopReset(179, 0.5, 0)).toBe(false); + }); + + it('does not trigger when previous progress is zero', () => { + expect.assertions(1); + expect(isLoopReset(0, 0.5, DURATION_MS)).toBe(false); + }); + + it('triggers at exact boundary values', () => { + expect.assertions(1); + const durationS = DURATION_MS / 1000; + const prevAtBoundary = durationS - LOOP_RESET_TOLERANCE_S; + expect(isLoopReset(prevAtBoundary, LOOP_RESET_TOLERANCE_S - 0.01, DURATION_MS)).toBe(true); + }); +}); + +describe('shouldSendNowPlaying (debounce)', () => { + it('returns false when already sent', () => { + expect.assertions(1); + expect(shouldSendNowPlaying(NOW_PLAYING_DEBOUNCE_MS + 1000, true)).toBe(false); + }); + + it('returns false when accumulated time is below debounce', () => { + expect.assertions(1); + expect(shouldSendNowPlaying(NOW_PLAYING_DEBOUNCE_MS - 1, false)).toBe(false); + }); + + it('returns true when accumulated time meets debounce and not yet sent', () => { + expect.assertions(1); + expect(shouldSendNowPlaying(NOW_PLAYING_DEBOUNCE_MS, false)).toBe(true); + }); + + it('returns true when accumulated time exceeds debounce and not yet sent', () => { + expect.assertions(1); + expect(shouldSendNowPlaying(NOW_PLAYING_DEBOUNCE_MS + 5000, false)).toBe(true); + }); +}); + +describe('getScrobbleThreshold (50% / 4min rule)', () => { + it('uses 50% of a 5-minute track (150s < 240s)', () => { + expect.assertions(1); + const durationMs = 300_000; // 5 min + expect(getScrobbleThreshold(durationMs)).toBe(150_000); + }); + + it('uses 4 minutes for a 10-minute track (300s > 240s)', () => { + expect.assertions(1); + const durationMs = 600_000; // 10 min + expect(getScrobbleThreshold(durationMs)).toBe(SCROBBLE_TIME_THRESHOLD_MS); + }); + + it('uses 50% for exactly 8-minute track (240s == 240s)', () => { + expect.assertions(1); + const durationMs = 480_000; // 8 min → 50% = 240s + expect(getScrobbleThreshold(durationMs)).toBe(SCROBBLE_TIME_THRESHOLD_MS); + }); + + it('uses 50% for short 1-minute track', () => { + expect.assertions(1); + const durationMs = 60_000; + expect(getScrobbleThreshold(durationMs)).toBe(30_000); + }); +}); + +describe('shouldScrobble (threshold enforcement)', () => { + it('returns true when 50% threshold met for 5-minute track', () => { + expect.assertions(1); + const durationMs = 300_000; + const threshold = 150_000; // 50% of 5 min + expect(shouldScrobble(threshold, durationMs, false)).toBe(true); + }); + + it('returns false before threshold is met', () => { + expect.assertions(1); + const durationMs = 300_000; + expect(shouldScrobble(149_999, durationMs, false)).toBe(false); + }); + + it('returns false when already scrobbled', () => { + expect.assertions(1); + const durationMs = 300_000; + expect(shouldScrobble(200_000, durationMs, true)).toBe(false); + }); + + it('returns false for tracks shorter than 30 seconds', () => { + expect.assertions(1); + const durationMs = 25_000; + expect(shouldScrobble(25_000, durationMs, false)).toBe(false); + }); + + it('returns false for tracks exactly at minimum duration boundary', () => { + expect.assertions(1); + const durationMs = MIN_TRACK_DURATION_MS - 1; + expect(shouldScrobble(durationMs, durationMs, false)).toBe(false); + }); + + it('uses 4-minute cap for long tracks', () => { + expect.assertions(2); + const durationMs = 600_000; // 10 min + expect(shouldScrobble(SCROBBLE_TIME_THRESHOLD_MS - 1, durationMs, false)).toBe(false); + expect(shouldScrobble(SCROBBLE_TIME_THRESHOLD_MS, durationMs, false)).toBe(true); + }); + + it('scrobbles track at minimum accepted duration (30s)', () => { + expect.assertions(1); + const durationMs = MIN_TRACK_DURATION_MS; // exactly 30s + const threshold = durationMs * SCROBBLE_PERCENT_THRESHOLD; // 15s + expect(shouldScrobble(threshold, durationMs, false)).toBe(true); + }); +}); + +describe('seek inflation protection (integration)', () => { + it('seeking past 50% does not count toward scrobble', () => { + expect.assertions(1); + let accumulated = 0; + // Simulate 10 seconds of normal play + for (let i = 0; i < 10; i++) { + const delta = 1; + if (shouldAccumulate(delta)) accumulated += delta * 1000; + } + // Then seek forward 100 seconds + const seekDelta = 100; + if (shouldAccumulate(seekDelta)) accumulated += seekDelta * 1000; + // Only 10 seconds should be accumulated + expect(accumulated).toBe(10_000); + }); + + it('seeking backward does not add time', () => { + expect.assertions(1); + let accumulated = 0; + for (let i = 0; i < 5; i++) { + if (shouldAccumulate(1)) accumulated += 1000; + } + if (shouldAccumulate(-30)) accumulated += 30_000; // should not execute + expect(accumulated).toBe(5000); + }); +}); + +describe('track change reset', () => { + it('different tracks produce different keys', () => { + expect.assertions(1); + const key1 = makeTrackKey('Artist', 'Track A'); + const key2 = makeTrackKey('Artist', 'Track B'); + expect(key1).not.toBe(key2); + }); + + it('same track by same artist produces the same key', () => { + expect.assertions(1); + expect(makeTrackKey('MUSE', 'Hysteria')).toBe(makeTrackKey('muse', 'hysteria')); + }); +}); + +describe('missing metadata handling', () => { + it('makeTrackKey works with empty strings', () => { + expect.assertions(1); + expect(makeTrackKey('', '')).toBe('::'); + }); +}); diff --git a/frontend/src/lib/stores/scrobble.svelte.ts b/frontend/src/lib/stores/scrobble.svelte.ts new file mode 100644 index 0000000..6a282e2 --- /dev/null +++ b/frontend/src/lib/stores/scrobble.svelte.ts @@ -0,0 +1,255 @@ +import { playerStore } from '$lib/stores/player.svelte'; +import type { + NowPlayingSubmission, + ScrobbleSubmission, + ScrobbleSettings, + ScrobbleResponse, +} from '$lib/types'; +import { api } from '$lib/api/client'; +import { + SCROBBLE_PERCENT_THRESHOLD, + SCROBBLE_TIME_THRESHOLD_MS, + NOW_PLAYING_DEBOUNCE_MS, + SEEK_TOLERANCE_S, + MIN_TRACK_DURATION_MS, + LOOP_RESET_TOLERANCE_S, + makeTrackKey, + shouldAccumulate, + isLoopReset, + shouldSendNowPlaying, + shouldScrobble, + formatServiceTooltip, +} from '$lib/stores/scrobbleHelpers'; + +type ScrobbleStatus = 'idle' | 'tracking' | 'scrobbled' | 'error'; + +interface TrackState { + trackKey: string; + accumulatedMs: number; + lastProgressS: number; + nowPlayingSent: boolean; + scrobbled: boolean; + startedAt: number; + durationMs: number; +} + +function createScrobbleManager() { + let status = $state('idle'); + let enabled = $state(false); + let lastServiceDetail = $state | null>(null); + let track: TrackState | null = null; + let settingsCache: ScrobbleSettings | null = null; + let lastSettingsFetch = 0; + let progressInterval: ReturnType | null = null; + + async function loadSettings(): Promise { + const now = Date.now(); + if (settingsCache && now - lastSettingsFetch < 60_000) return settingsCache; + try { + settingsCache = await api.global.get('/api/v1/settings/scrobble'); + lastSettingsFetch = now; + return settingsCache; + } catch { + return settingsCache; + } + } + + async function init(): Promise { + const settings = await loadSettings(); + enabled = !!(settings?.scrobble_to_lastfm || settings?.scrobble_to_listenbrainz); + } + + async function sendNowPlaying( + artistName: string, + trackName: string, + albumName: string, + durationMs: number + ): Promise { + try { + const body: NowPlayingSubmission = { + track_name: trackName, + artist_name: artistName, + album_name: albumName, + duration_ms: durationMs, + }; + await api.global.post('/api/v1/scrobble/now-playing', body); + } catch { + status = 'error'; + } + } + + async function sendScrobble( + artistName: string, + trackName: string, + albumName: string, + durationMs: number, + timestamp: number + ): Promise { + try { + const body: ScrobbleSubmission = { + track_name: trackName, + artist_name: artistName, + album_name: albumName, + duration_ms: durationMs, + timestamp, + }; + const data = await api.global.post('/api/v1/scrobble/submit', body); + lastServiceDetail = Object.fromEntries( + Object.entries(data.services).map(([k, v]) => [k, { success: v.success }]) + ); + if (data.accepted) { + status = 'scrobbled'; + } else { + status = 'error'; + throw new Error('Scrobble not accepted'); + } + } catch (e) { + status = 'error'; + lastServiceDetail = lastServiceDetail ?? null; + throw e; + } + } + + $effect.root(() => { + $effect(() => { + const np = playerStore.nowPlaying; + const isPlaying = playerStore.isPlaying; + + if (!enabled) { + if (status !== 'idle') status = 'idle'; + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + return; + } + + if (!np || !np.trackName || !np.artistName) { + if (!np || !np.trackName) { + track = null; + if (status !== 'idle') { + status = 'idle'; + lastServiceDetail = null; + } + } + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + return; + } + + if (!isPlaying) { + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + return; + } + + const currentKey = makeTrackKey(np.artistName, np.trackName); + const durationMs = Math.round(playerStore.duration * 1000); + + if (!track || track.trackKey !== currentKey) { + track = { + trackKey: currentKey, + accumulatedMs: 0, + lastProgressS: playerStore.progress, + nowPlayingSent: false, + scrobbled: false, + startedAt: Math.floor(Date.now() / 1000), + durationMs, + }; + status = 'tracking'; + lastServiceDetail = null; + } + + if (progressInterval) { + clearInterval(progressInterval); + } + + progressInterval = setInterval(() => { + if (!track) return; + const progressS = playerStore.progress; + const currentDurationMs = Math.round(playerStore.duration * 1000); + const currentNp = playerStore.nowPlaying; + + if (!currentNp || !currentNp.trackName || !playerStore.isPlaying) return; + + if (currentDurationMs > 0 && track.durationMs === 0) { + track.durationMs = currentDurationMs; + } + + const deltaS = progressS - track.lastProgressS; + const prevProgressS = track.lastProgressS; + track.lastProgressS = progressS; + + if (isLoopReset(prevProgressS, progressS, track.durationMs)) { + track.accumulatedMs = 0; + track.scrobbled = false; + track.nowPlayingSent = false; + track.startedAt = Math.floor(Date.now() / 1000); + status = 'tracking'; + lastServiceDetail = null; + return; + } + + if (shouldAccumulate(deltaS)) { + track.accumulatedMs += deltaS * 1000; + } + + if (shouldSendNowPlaying(track.accumulatedMs, track.nowPlayingSent)) { + track.nowPlayingSent = true; + sendNowPlaying( + currentNp.artistName, + currentNp.trackName, + currentNp.albumName, + track.durationMs + ); + } + + if (shouldScrobble(track.accumulatedMs, track.durationMs, track.scrobbled)) { + track.scrobbled = true; + const t = track; + sendScrobble( + currentNp.artistName, + currentNp.trackName, + currentNp.albumName, + t.durationMs, + t.startedAt + ).catch(() => { + t.scrobbled = false; + }); + } + }, 1000); + + return () => { + if (progressInterval) { + clearInterval(progressInterval); + progressInterval = null; + } + }; + }); + }); + + return { + get status() { + return status; + }, + get enabled() { + return enabled; + }, + get tooltip() { + return formatServiceTooltip(status, lastServiceDetail); + }, + init, + async refreshSettings(): Promise { + settingsCache = null; + await init(); + }, + }; +} + +export const scrobbleManager = createScrobbleManager(); + +export { formatServiceTooltip }; diff --git a/frontend/src/lib/stores/scrobbleHelpers.ts b/frontend/src/lib/stores/scrobbleHelpers.ts new file mode 100644 index 0000000..b9085c7 --- /dev/null +++ b/frontend/src/lib/stores/scrobbleHelpers.ts @@ -0,0 +1,66 @@ +export const SCROBBLE_PERCENT_THRESHOLD = 0.5; +export const SCROBBLE_TIME_THRESHOLD_MS = 240_000; +export const NOW_PLAYING_DEBOUNCE_MS = 3000; +export const SEEK_TOLERANCE_S = 3; +export const MIN_TRACK_DURATION_MS = 30_000; +export const LOOP_RESET_TOLERANCE_S = 3; + +export function makeTrackKey(artistName: string, trackName: string): string { + return `${artistName.toLowerCase()}::${trackName.toLowerCase()}`; +} + +export function shouldAccumulate(deltaS: number): boolean { + return deltaS > 0 && deltaS < SEEK_TOLERANCE_S; +} + +export function isLoopReset( + prevProgressS: number, + currentProgressS: number, + durationMs: number +): boolean { + if (durationMs <= 0 || prevProgressS <= 0) return false; + const durationS = durationMs / 1000; + return ( + prevProgressS >= durationS - LOOP_RESET_TOLERANCE_S && + currentProgressS < LOOP_RESET_TOLERANCE_S + ); +} + +export function shouldSendNowPlaying(accumulatedMs: number, alreadySent: boolean): boolean { + return !alreadySent && accumulatedMs >= NOW_PLAYING_DEBOUNCE_MS; +} + +export function getScrobbleThreshold(durationMs: number): number { + const halfDuration = durationMs * SCROBBLE_PERCENT_THRESHOLD; + return Math.min(halfDuration, SCROBBLE_TIME_THRESHOLD_MS); +} + +export function shouldScrobble( + accumulatedMs: number, + durationMs: number, + alreadyScrobbled: boolean +): boolean { + if (alreadyScrobbled) return false; + if (durationMs < MIN_TRACK_DURATION_MS) return false; + return accumulatedMs >= getScrobbleThreshold(durationMs); +} + +export function formatServiceTooltip( + scrobbleStatus: string, + serviceDetail: Record | null +): string { + if (scrobbleStatus === 'tracking') return 'Tracking'; + if (!serviceDetail || Object.keys(serviceDetail).length === 0) { + return scrobbleStatus === 'scrobbled' ? 'Scrobbled' : 'Scrobble failed'; + } + const succeeded = Object.entries(serviceDetail) + .filter(([, v]) => v.success) + .map(([k]) => (k === 'lastfm' ? 'Last.fm' : k === 'listenbrainz' ? 'ListenBrainz' : k)); + const failed = Object.entries(serviceDetail) + .filter(([, v]) => !v.success) + .map(([k]) => (k === 'lastfm' ? 'Last.fm' : k === 'listenbrainz' ? 'ListenBrainz' : k)); + const parts: string[] = []; + if (succeeded.length) parts.push(`Scrobbled to ${succeeded.join(', ')}`); + if (failed.length) parts.push(`Failed: ${failed.join(', ')}`); + return parts.join(' · ') || 'Scrobbled'; +} diff --git a/frontend/src/lib/stores/search.ts b/frontend/src/lib/stores/search.ts new file mode 100644 index 0000000..606a586 --- /dev/null +++ b/frontend/src/lib/stores/search.ts @@ -0,0 +1,174 @@ +import { writable, get } from 'svelte/store'; +import type { Artist, Album } from '$lib/types'; +import type { EnrichmentSource } from '$lib/types'; +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +interface SearchCache { + query: string; + artists: Artist[]; + albums: Album[]; + topArtist: Artist | null; + topAlbum: Album | null; + timestamp: number; + enrichmentSource: EnrichmentSource; +} + +let searchCacheTTL = 5 * 60 * 1000; + +const persistentSearchCache = createLocalStorageCache>( + CACHE_KEYS.SEARCH, + CACHE_TTL.SEARCH, + { maxEntries: 60 } +); + +function normalizeQuery(query: string): string { + return query.trim().toLowerCase(); +} + +function getCacheSuffix(query: string): string { + return encodeURIComponent(normalizeQuery(query)); +} + +function isCacheStale(timestamp: number): boolean { + return Date.now() - timestamp > searchCacheTTL; +} + +function hydratePersistentCache(query: string): SearchCache | null { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) return null; + + const stored = persistentSearchCache.get(getCacheSuffix(normalizedQuery)); + if (!stored) return null; + + return { + query: stored.data.query, + artists: stored.data.artists, + albums: stored.data.albums, + topArtist: stored.data.topArtist ?? null, + topAlbum: stored.data.topAlbum ?? null, + enrichmentSource: stored.data.enrichmentSource, + timestamp: stored.timestamp + }; +} + +function persistCache(cache: SearchCache): void { + persistentSearchCache.set( + { + query: cache.query, + artists: cache.artists, + albums: cache.albums, + topArtist: cache.topArtist, + topAlbum: cache.topAlbum, + enrichmentSource: cache.enrichmentSource + }, + getCacheSuffix(cache.query) + ); +} + +export function updateSearchCacheTTL(ttlMs: number): void { + searchCacheTTL = ttlMs; + persistentSearchCache.updateTTL(ttlMs); +} + +function createSearchStore() { + const { subscribe, set, update } = writable(null); + + return { + subscribe, + setResults( + query: string, + artists: Artist[], + albums: Album[], + enrichmentSource: EnrichmentSource = 'none', + topArtist: Artist | null = null, + topAlbum: Album | null = null + ) { + const normalizedQuery = normalizeQuery(query); + const cache: SearchCache = { + query: normalizedQuery, + artists, + albums, + topArtist, + topAlbum, + timestamp: Date.now(), + enrichmentSource + }; + set(cache); + persistCache(cache); + }, + updateArtists(artists: Artist[]) { + update((cache) => { + if (cache) { + const updatedCache: SearchCache = { + ...cache, + artists, + timestamp: Date.now() + }; + persistCache(updatedCache); + return updatedCache; + } + return cache; + }); + }, + updateAlbums(albums: Album[]) { + update((cache) => { + if (cache) { + const updatedCache: SearchCache = { + ...cache, + albums, + timestamp: Date.now() + }; + persistCache(updatedCache); + return updatedCache; + } + return cache; + }); + }, + setEnrichmentSource(enrichmentSource: EnrichmentSource) { + update((cache) => { + if (cache) { + const updatedCache: SearchCache = { + ...cache, + enrichmentSource, + timestamp: Date.now() + }; + persistCache(updatedCache); + return updatedCache; + } + return cache; + }); + }, + getCache(query: string, options: { allowStale?: boolean } = {}): SearchCache | null { + const normalizedQuery = normalizeQuery(query); + const allowStale = options.allowStale ?? false; + const cache = get({ subscribe }); + if (cache && cache.query === normalizedQuery) { + if (!allowStale && isCacheStale(cache.timestamp)) { + return null; + } + return cache; + } + + const persistentCache = hydratePersistentCache(normalizedQuery); + if (!persistentCache) { + return null; + } + + if (!allowStale && isCacheStale(persistentCache.timestamp)) { + return null; + } + + set(persistentCache); + return persistentCache; + }, + isStale(timestamp: number): boolean { + return isCacheStale(timestamp); + }, + clear() { + set(null); + } + }; +} + +export const searchStore = createSearchStore(); diff --git a/frontend/src/lib/stores/serviceStatus.spec.ts b/frontend/src/lib/stores/serviceStatus.spec.ts new file mode 100644 index 0000000..8f24681 --- /dev/null +++ b/frontend/src/lib/stores/serviceStatus.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { get } from 'svelte/store'; + +vi.stubGlobal('window', globalThis); + +describe('serviceStatusStore', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('starts empty', async () => { + expect.assertions(1); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + expect(get(serviceStatusStore)).toEqual({}); + }); + + it('records degradation from response body', async () => { + expect.assertions(1); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.clear(); + + serviceStatusStore.recordFromResponse({ musicbrainz: 'error', audiodb: 'degraded' }); + expect(get(serviceStatusStore)).toEqual({ musicbrainz: 'error', audiodb: 'degraded' }); + }); + + it('records degradation from header string', async () => { + expect.assertions(1); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.clear(); + + serviceStatusStore.recordFromHeader('jellyfin, musicbrainz'); + expect(get(serviceStatusStore)).toEqual({ jellyfin: 'error', musicbrainz: 'error' }); + }); + + it('ignores null/empty header', async () => { + expect.assertions(2); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.clear(); + + serviceStatusStore.recordFromHeader(null); + expect(get(serviceStatusStore)).toEqual({}); + + serviceStatusStore.recordFromHeader(''); + expect(get(serviceStatusStore)).toEqual({}); + }); + + it('ignores null/empty response', async () => { + expect.assertions(2); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.clear(); + + serviceStatusStore.recordFromResponse(null); + expect(get(serviceStatusStore)).toEqual({}); + + serviceStatusStore.recordFromResponse({}); + expect(get(serviceStatusStore)).toEqual({}); + }); + + it('merges multiple degradation signals', async () => { + expect.assertions(1); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.clear(); + + serviceStatusStore.recordFromResponse({ musicbrainz: 'error' }); + serviceStatusStore.recordFromHeader('jellyfin'); + expect(get(serviceStatusStore)).toEqual({ musicbrainz: 'error', jellyfin: 'error' }); + }); + + it('clear resets store to empty', async () => { + expect.assertions(1); + const { serviceStatusStore } = await import('$lib/stores/serviceStatus'); + serviceStatusStore.recordFromResponse({ musicbrainz: 'error' }); + serviceStatusStore.clear(); + expect(get(serviceStatusStore)).toEqual({}); + }); +}); diff --git a/frontend/src/lib/stores/serviceStatus.ts b/frontend/src/lib/stores/serviceStatus.ts new file mode 100644 index 0000000..f608ab9 --- /dev/null +++ b/frontend/src/lib/stores/serviceStatus.ts @@ -0,0 +1,54 @@ +import { writable } from 'svelte/store'; + +export interface ServiceDegradation { + [source: string]: string; +} + +const CLEAR_DELAY_MS = 10_000; + +function createServiceStatusStore() { + const { subscribe, set, update } = writable({}); + let clearTimer: ReturnType | null = null; + + function scheduleClear() { + if (clearTimer !== null) clearTimeout(clearTimer); + clearTimer = setTimeout(() => set({}), CLEAR_DELAY_MS); + } + + return { + subscribe, + + /** + * Merge degradation info from a backend response. + * Called by pageFetch / API wrappers after every successful response. + */ + recordFromResponse(serviceStatus: ServiceDegradation | null | undefined) { + if (!serviceStatus || Object.keys(serviceStatus).length === 0) return; + update((current) => ({ ...current, ...serviceStatus })); + scheduleClear(); + }, + + /** + * Parse the X-Degraded-Services header (comma-separated source names). + */ + recordFromHeader(header: string | null) { + if (!header) return; + const sources = header.split(',').map((s) => s.trim()).filter(Boolean); + if (sources.length === 0) return; + const patch: ServiceDegradation = {}; + for (const src of sources) { + patch[src] = 'error'; + } + update((current) => ({ ...current, ...patch })); + scheduleClear(); + }, + + /** Dismiss all degradation signals (user action or navigation). */ + clear() { + if (clearTimer !== null) clearTimeout(clearTimer); + set({}); + } + }; +} + +export const serviceStatusStore = createServiceStatusStore(); diff --git a/frontend/src/lib/stores/syncStatus.svelte.ts b/frontend/src/lib/stores/syncStatus.svelte.ts new file mode 100644 index 0000000..7f1c38d --- /dev/null +++ b/frontend/src/lib/stores/syncStatus.svelte.ts @@ -0,0 +1,298 @@ +import { browser } from '$app/environment'; +import { api } from '$lib/api/client'; +import { libraryStore } from '$lib/stores/library'; + +export type SyncStatus = { + is_syncing: boolean; + phase: string | null; + total_items: number; + processed_items: number; + progress_percent: number; + current_item: string | null; + error_message: string | null; + total_artists: number; + processed_artists: number; + total_albums: number; + processed_albums: number; +}; + +const PHASE_LABELS: Record = { + artists: 'Artist Images', + discovery: 'Artist Discovery', + albums: 'Album Data', + audiodb_prewarm: 'AudioDB Images' +}; + +const PHASE_ORDER = ['artists', 'discovery', 'albums', 'audiodb_prewarm']; + +const EMPTY_STATUS: SyncStatus = { + is_syncing: false, + phase: null, + total_items: 0, + processed_items: 0, + progress_percent: 0, + current_item: null, + error_message: null, + total_artists: 0, + processed_artists: 0, + total_albums: 0, + processed_albums: 0 +}; + +const MAX_RECONNECT_ATTEMPTS = 5; +const POLL_ACTIVE_MS = 1500; +const POLL_IDLE_MS = 5000; +const AUTO_HIDE_SUCCESS_MS = 4000; +const AUTO_HIDE_ERROR_MS = 6000; + +function createSyncStatusStore() { + let status = $state({ ...EMPTY_STATUS }); + let isDismissed = $state(false); + let showIndicator = $state(false); + let connectionMode = $state<'sse' | 'polling'>('sse'); + + let eventSource: EventSource | null = null; + let pollInterval: ReturnType | null = null; + let hideTimeout: ReturnType | null = null; + let reconnectTimeout: ReturnType | null = null; + let reconnectAttempts = 0; + let statusVersion = 0; + let connected = false; + + function clearAllTimers(): void { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + } + + function applyStatus(newStatus: SyncStatus): void { + statusVersion++; + const wasSyncing = status.is_syncing; + status = newStatus; + + if (newStatus.is_syncing && !wasSyncing) { + isDismissed = false; + } + + if (wasSyncing && !newStatus.is_syncing && !newStatus.error_message && browser) { + libraryStore.refresh(); + } + + handleStatusUpdate(newStatus); + + if (connectionMode === 'polling' && wasSyncing !== newStatus.is_syncing) { + schedulePoll(); + } + } + + function handleStatusUpdate(newStatus: SyncStatus): void { + if (newStatus.is_syncing) { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + if (!isDismissed) { + showIndicator = true; + } + } else if (newStatus.error_message) { + showIndicator = true; + if (!hideTimeout) { + hideTimeout = setTimeout(() => { + showIndicator = false; + hideTimeout = null; + }, AUTO_HIDE_ERROR_MS); + } + } else if (showIndicator && !hideTimeout) { + hideTimeout = setTimeout(() => { + showIndicator = false; + hideTimeout = null; + }, AUTO_HIDE_SUCCESS_MS); + } + } + + function connectSSE(): void { + if (!browser || document.hidden) return; + if (eventSource) { + eventSource.close(); + eventSource = null; + } + + eventSource = new EventSource('/api/v1/cache/sync/stream'); + + eventSource.onopen = () => { + connectionMode = 'sse'; + reconnectAttempts = 0; + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + }; + + eventSource.onmessage = (event) => { + try { + applyStatus(JSON.parse(event.data)); + } catch { + // ignore malformed messages + } + }; + + eventSource.onerror = () => { + eventSource?.close(); + eventSource = null; + reconnectAttempts++; + + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + + if (status.is_syncing && !pollInterval) { + startPolling(); + } + + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); + reconnectTimeout = setTimeout(() => { + reconnectTimeout = null; + if (connected && !document.hidden) connectSSE(); + }, delay); + } else { + connectionMode = 'polling'; + if (!pollInterval) startPolling(); + } + }; + } + + async function fetchStatus(): Promise { + try { + const data = await api.global.get('/api/v1/cache/sync/status'); + applyStatus(data); + } catch { + // ignore fetch errors + } + } + + function startPolling(): void { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + void fetchStatus(); + schedulePoll(); + } + + function schedulePoll(): void { + if (pollInterval) { + clearInterval(pollInterval); + } + pollInterval = setInterval( + () => void fetchStatus(), + status.is_syncing ? POLL_ACTIVE_MS : POLL_IDLE_MS + ); + } + + function handleVisibilityChange(): void { + if (!connected) return; + if (document.hidden) { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + eventSource?.close(); + eventSource = null; + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + } else { + if (connectionMode === 'sse') { + reconnectAttempts = 0; + connectSSE(); + } else { + startPolling(); + } + } + } + + return { + get status() { + return status; + }, + get isActive() { + return status.is_syncing; + }, + get phase() { + return status.phase; + }, + get progress() { + return status.progress_percent; + }, + get currentItem() { + return status.current_item; + }, + get error() { + return status.error_message; + }, + get totalItems() { + return status.total_items; + }, + get processedItems() { + return status.processed_items; + }, + get isDismissed() { + return isDismissed; + }, + get showIndicator() { + return showIndicator && !isDismissed; + }, + get connectionMode() { + return connectionMode; + }, + get phaseLabel() { + return status.phase ? (PHASE_LABELS[status.phase] ?? 'Syncing') : 'Library'; + }, + get phaseNumber() { + if (!status.phase) return 0; + const idx = PHASE_ORDER.indexOf(status.phase); + return idx >= 0 ? idx + 1 : 0; + }, + get totalPhases() { + return PHASE_ORDER.length; + }, + + connect(): void { + if (!browser || connected) return; + connected = true; + connectSSE(); + document.addEventListener('visibilitychange', handleVisibilityChange); + }, + + disconnect(): void { + if (!browser) return; + connected = false; + eventSource?.close(); + eventSource = null; + clearAllTimers(); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }, + + dismiss(): void { + isDismissed = true; + }, + + checkStatus(): void { + void fetchStatus(); + } + }; +} + +export const syncStatus = createSyncStatusStore(); diff --git a/frontend/src/lib/stores/toast.ts b/frontend/src/lib/stores/toast.ts new file mode 100644 index 0000000..e3b3f19 --- /dev/null +++ b/frontend/src/lib/stores/toast.ts @@ -0,0 +1,21 @@ +import { writable } from 'svelte/store'; + +interface Toast { + message: string; + type: 'success' | 'error' | 'info'; + duration?: number; +} + +function createToastStore() { + const { subscribe, set } = writable(null); + return { + subscribe, + show: (toast: Toast) => { + set(toast); + setTimeout(() => set(null), toast.duration ?? 3000); + }, + hide: () => set(null) + }; +} + +export const toastStore = createToastStore(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..00d06ff --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,1031 @@ +export type Artist = { + title: string; + musicbrainz_id: string; + in_library: boolean; + cover_url?: string | null; + thumb_url?: string | null; + fanart_url?: string | null; + banner_url?: string | null; + disambiguation?: string | null; + type_info?: string | null; + release_group_count?: number | null; + listen_count?: number | null; + score?: number; +}; + +export type Album = { + title: string; + artist: string | null; + year: number | null; + musicbrainz_id: string; + in_library: boolean; + requested?: boolean; + cover_url?: string | null; + album_thumb_url?: string | null; + album_back_url?: string | null; + album_cdart_url?: string | null; + album_spine_url?: string | null; + album_3d_case_url?: string | null; + album_3d_flat_url?: string | null; + album_3d_face_url?: string | null; + album_3d_thumb_url?: string | null; + type_info?: string | null; + disambiguation?: string | null; + track_count?: number | null; + listen_count?: number | null; + score?: number; +}; + +export type LibraryAlbum = { + artist: string; + album: string; + year?: number | null; + monitored: boolean; + quality?: string | null; + cover_url?: string | null; + musicbrainz_id?: string | null; + artist_mbid?: string | null; + date_added?: number | null; +}; + +export type SearchResults = { + artists: Artist[]; + albums: Album[]; + top_artist?: Artist | null; + top_album?: Album | null; +}; + +export type SuggestResult = { + type: 'artist' | 'album'; + title: string; + artist?: string | null; + year?: number | null; + musicbrainz_id: string; + in_library: boolean; + requested?: boolean; + disambiguation?: string | null; + score: number; +}; + +export type EnrichmentSource = 'listenbrainz' | 'lastfm' | 'none'; + +export type ArtistEnrichment = { + musicbrainz_id: string; + release_group_count?: number | null; + listen_count?: number | null; +}; + +export type AlbumEnrichment = { + musicbrainz_id: string; + track_count?: number | null; + listen_count?: number | null; +}; + +export type ArtistEnrichmentRequest = { + musicbrainz_id: string; + name: string; +}; + +export type AlbumEnrichmentRequest = { + musicbrainz_id: string; + artist_name: string; + album_name: string; +}; + +export type EnrichmentBatchRequest = { + artists: ArtistEnrichmentRequest[]; + albums: AlbumEnrichmentRequest[]; +}; + +export type EnrichmentResponse = { + artists: ArtistEnrichment[]; + albums: AlbumEnrichment[]; + source: EnrichmentSource; +}; + +export type ReleaseGroup = { + id: string; + title: string; + type?: string; + year?: number; + first_release_date?: string; + in_library: boolean; + requested?: boolean; +}; + +export type ExternalLink = { + type: string; + url: string; + label: string; + category?: string; +}; + +export type ArtistInfo = { + name: string; + musicbrainz_id: string; + disambiguation?: string | null; + type?: string | null; + country?: string | null; + life_span?: { + begin?: string | null; + end?: string | null; + ended?: boolean; + } | null; + description?: string | null; + image?: string | null; + fanart_url?: string | null; + banner_url?: string | null; + thumb_url?: string | null; + fanart_url_2?: string | null; + fanart_url_3?: string | null; + fanart_url_4?: string | null; + wide_thumb_url?: string | null; + logo_url?: string | null; + clearart_url?: string | null; + cutout_url?: string | null; + tags: string[]; + aliases: string[]; + external_links: ExternalLink[]; + in_library: boolean; + albums: ReleaseGroup[]; + singles: ReleaseGroup[]; + eps: ReleaseGroup[]; + release_group_count?: number; +}; + +export type ArtistReleases = { + albums: ReleaseGroup[]; + singles: ReleaseGroup[]; + eps: ReleaseGroup[]; + total_count: number; + has_more: boolean; +}; + +export type UserPreferences = { + primary_types: string[]; + secondary_types: string[]; + release_statuses: string[]; +}; + +export type ReleaseTypeOption = { + id: string; + title: string; + description: string; +}; + +export type Track = { + position: number; + disc_number?: number | null; + title: string; + length?: number | null; + recording_id?: string | null; +}; + +export type AlbumInfo = { + title: string; + musicbrainz_id: string; + artist_name: string; + artist_id: string; + release_date?: string | null; + year?: number | null; + type?: string | null; + label?: string | null; + barcode?: string | null; + country?: string | null; + disambiguation?: string | null; + tracks: Track[]; + total_tracks: number; + total_length?: number | null; + in_library: boolean; + requested?: boolean; + cover_url?: string | null; + album_thumb_url?: string | null; + album_back_url?: string | null; + album_cdart_url?: string | null; + album_spine_url?: string | null; + album_3d_case_url?: string | null; + album_3d_flat_url?: string | null; + album_3d_face_url?: string | null; + album_3d_thumb_url?: string | null; +}; + +export type AlbumBasicInfo = { + title: string; + musicbrainz_id: string; + artist_name: string; + artist_id: string; + release_date?: string | null; + year?: number | null; + type?: string | null; + disambiguation?: string | null; + in_library: boolean; + requested?: boolean; + cover_url?: string | null; + album_thumb_url?: string | null; +}; + +export type AlbumTracksInfo = { + tracks: Track[]; + total_tracks: number; + total_length?: number | null; + label?: string | null; + barcode?: string | null; + country?: string | null; +}; + +export type LidarrConnectionSettings = { + lidarr_url: string; + lidarr_api_key: string; + quality_profile_id: number; + metadata_profile_id: number; + root_folder_path: string; +}; + +export type JellyfinConnectionSettings = { + jellyfin_url: string; + api_key: string; + user_id: string; + enabled: boolean; +}; + +export type ListenBrainzConnectionSettings = { + username: string; + user_token: string; + enabled: boolean; +}; + +export type HomeSettings = { + cache_ttl_trending: number; + cache_ttl_personal: number; +}; + +export type HomeArtist = { + mbid: string | null; + name: string; + image_url: string | null; + listen_count: number | null; + in_library: boolean; +}; + +export type HomeAlbum = { + mbid: string | null; + name: string; + artist_name: string | null; + artist_mbid: string | null; + image_url: string | null; + release_date: string | null; + listen_count: number | null; + in_library: boolean; + requested?: boolean; +}; + +export type HomeTrack = { + mbid: string | null; + name: string; + artist_name: string | null; + artist_mbid: string | null; + album_name: string | null; + listen_count: number | null; + listened_at: string | null; + image_url?: string | null; +}; + +export type HomeGenre = { + name: string; + listen_count: number | null; + artist_count: number | null; + artist_mbid: string | null; +}; + +export type HomeSection = { + title: string; + type: 'artists' | 'albums' | 'tracks' | 'genres'; + items: (HomeArtist | HomeAlbum | HomeTrack | HomeGenre)[]; + source: string | null; + fallback_message: string | null; + connect_service: string | null; +}; + +export type ServicePrompt = { + service: string; + title: string; + description: string; + icon: string; + color: string; + features: string[]; +}; + +export type HomeResponse = { + recently_added: HomeSection | null; + library_artists: HomeSection | null; + library_albums: HomeSection | null; + recommended_artists: HomeSection | null; + trending_artists: HomeSection | null; + popular_albums: HomeSection | null; + recently_played: HomeSection | null; + top_genres: HomeSection | null; + genre_list: HomeSection | null; + fresh_releases: HomeSection | null; + favorite_artists: HomeSection | null; + your_top_albums: HomeSection | null; + weekly_exploration: WeeklyExplorationSection | null; + service_prompts: ServicePrompt[]; + integration_status: Record; + genre_artists: Record; + genre_artist_images: Record; + discover_preview: DiscoverPreview | null; +}; + +export type DiscoverPreview = { + seed_artist: string; + seed_artist_mbid: string; + items: HomeArtist[]; +}; + +export type BecauseYouListenTo = { + seed_artist: string; + seed_artist_mbid: string; + listen_count: number; + section: HomeSection; + banner_url?: string | null; + wide_thumb_url?: string | null; + fanart_url?: string | null; +}; + +export type WeeklyExplorationTrack = { + title: string; + artist_name: string; + album_name: string; + recording_mbid: string | null; + artist_mbid: string | null; + release_group_mbid: string | null; + cover_url: string | null; + duration_ms: number | null; +}; + +export type WeeklyExplorationSection = { + title: string; + playlist_date: string; + tracks: WeeklyExplorationTrack[]; + source_url: string; +}; + +export type DiscoverResponse = { + because_you_listen_to: BecauseYouListenTo[]; + discover_queue_enabled: boolean; + fresh_releases: HomeSection | null; + missing_essentials: HomeSection | null; + rediscover: HomeSection | null; + artists_you_might_like: HomeSection | null; + popular_in_your_genres: HomeSection | null; + genre_list: HomeSection | null; + globally_trending: HomeSection | null; + weekly_exploration: WeeklyExplorationSection | null; + lastfm_weekly_artist_chart: HomeSection | null; + lastfm_weekly_album_chart: HomeSection | null; + lastfm_recent_scrobbles: HomeSection | null; + genre_artists: Record; + genre_artist_images: Record; + integration_status: Record; + service_prompts: ServicePrompt[]; + refreshing: boolean; +}; + +export type QualityProfile = { + id: number; + name: string; +}; + +export type MetadataProfile = { + id: number; + name: string; +}; + +export type RootFolder = { + id: string; + path: string; +}; + +export type LidarrVerifyResponse = { + success: boolean; + message: string; + quality_profiles: QualityProfile[]; + metadata_profiles: MetadataProfile[]; + root_folders: RootFolder[]; +}; + +export type LidarrMetadataProfilePreferences = { + profile_id: number; + profile_name: string; + primary_types: string[]; + secondary_types: string[]; + release_statuses: string[]; +}; + +export type TrendingTimeRange = { + range_key: string; + label: string; + featured: HomeArtist | null; + items: HomeArtist[]; + total_count: number; +}; + +export type TrendingArtistsResponse = { + this_week: TrendingTimeRange; + this_month: TrendingTimeRange; + this_year: TrendingTimeRange; + all_time: TrendingTimeRange; +}; + +export type PopularTimeRange = { + range_key: string; + label: string; + featured: HomeAlbum | null; + items: HomeAlbum[]; + total_count: number; +}; + +export type PopularAlbumsResponse = { + this_week: PopularTimeRange; + this_month: PopularTimeRange; + this_year: PopularTimeRange; + all_time: PopularTimeRange; +}; + +export type TrendingArtistsRangeResponse = { + range_key: string; + label: string; + items: HomeArtist[]; + offset: number; + limit: number; + has_more: boolean; +}; + +export type PopularAlbumsRangeResponse = { + range_key: string; + label: string; + items: HomeAlbum[]; + offset: number; + limit: number; + has_more: boolean; +}; + +export type GenreLibrarySection = { + artists: HomeArtist[]; + albums: HomeAlbum[]; + artist_count: number; + album_count: number; +}; + +export type GenrePopularSection = { + artists: HomeArtist[]; + albums: HomeAlbum[]; + has_more_artists: boolean; + has_more_albums: boolean; +}; + +export type GenreDetailResponse = { + genre: string; + library: GenreLibrarySection | null; + popular: GenrePopularSection | null; + artists: HomeArtist[]; + total_count: number | null; +}; + +export type SimilarArtist = { + musicbrainz_id: string; + name: string; + listen_count: number; + in_library: boolean; + image_url?: string | null; +}; + +export type SimilarArtistsResponse = { + similar_artists: SimilarArtist[]; + source: string; + configured: boolean; +}; + +export type TopSong = { + recording_mbid?: string | null; + release_group_mbid?: string | null; + original_release_mbid?: string | null; + title: string; + artist_name: string; + release_name?: string | null; + listen_count: number; + disc_number?: number | null; + track_number?: number | null; +}; + +export type TopSongsResponse = { + songs: TopSong[]; + source: string; + configured: boolean; +}; + +export type ResolvedTrack = { + release_group_mbid?: string | null; + disc_number?: number | null; + track_number?: number | null; + source?: string | null; + track_source_id?: string | null; + stream_url?: string | null; + format?: string | null; + duration?: number | null; +}; + +export type TopAlbum = { + release_group_mbid?: string | null; + title: string; + artist_name: string; + year?: number | null; + listen_count: number; + in_library: boolean; + requested?: boolean; + cover_url?: string | null; +}; + +export type TopAlbumsResponse = { + albums: TopAlbum[]; + source: string; + configured: boolean; +}; + +export type DiscoveryAlbum = { + musicbrainz_id: string; + title: string; + artist_name: string; + artist_id?: string | null; + year?: number | null; + in_library: boolean; + requested?: boolean; + cover_url?: string | null; +}; + +export type SimilarAlbumsResponse = { + albums: DiscoveryAlbum[]; + source: string; + configured: boolean; +}; + +export type DiscoverQueueItemLight = { + release_group_mbid: string; + album_name: string; + artist_name: string; + artist_mbid: string; + cover_url: string | null; + recommendation_reason: string; + is_wildcard: boolean; + in_library: boolean; +}; + +export type DiscoverQueueEnrichment = { + artist_mbid: string | null; + release_date: string | null; + country: string | null; + tags: string[]; + youtube_url: string | null; + youtube_search_url: string; + youtube_search_available: boolean; + artist_description: string | null; + listen_count: number | null; +}; + +export type YouTubeSearchResponse = { + video_id: string | null; + embed_url: string | null; + error: string | null; + cached: boolean; +}; + +export type YouTubeQuotaStatus = { + used: number; + limit: number; + remaining: number; + date: string; +}; + +export type TrackCacheCheckItem = { + artist: string; + track: string; + cached: boolean; +}; + +export type DiscoverQueueItemFull = DiscoverQueueItemLight & { + enrichment?: DiscoverQueueEnrichment; +}; + +export type DiscoverQueueResponse = { + items: DiscoverQueueItemFull[]; + queue_id: string; +}; + +export type QueueStatusResponse = { + status: 'idle' | 'building' | 'ready' | 'error'; + source: string; + queue_id?: string; + item_count?: number; + built_at?: number; + stale?: boolean; + error?: string; +}; + +export type QueueGenerateResponse = { + action: 'started' | 'already_building' | 'already_ready'; + status: string; + source: string; + queue_id?: string; + item_count?: number; + built_at?: number; + stale?: boolean; + error?: string; +}; + +export type MoreByArtistResponse = { + albums: DiscoveryAlbum[]; + artist_name: string; +}; + +export type YouTubeLink = { + album_id: string; + video_id: string | null; + album_name: string; + artist_name: string; + embed_url: string | null; + cover_url: string | null; + created_at: string; + is_manual: boolean; + track_count: number; +}; + +export type YouTubeLinkResponse = { + link: YouTubeLink; + quota: YouTubeQuotaStatus; +}; + +export type YouTubeLinkGenerateRequest = { + artist_name: string; + album_name: string; + album_id: string; + cover_url?: string | null; +}; + +export type YouTubeTrackLink = { + album_id: string; + track_number: number; + disc_number?: number | null; + track_name: string; + video_id: string; + artist_name: string; + embed_url: string; + created_at: string; + album_name?: string; +}; + +export type YouTubeTrackLinkResponse = { + track_link: YouTubeTrackLink; + quota: YouTubeQuotaStatus; +}; + +export type YouTubeTrackLinkBatchResponse = { + track_links: YouTubeTrackLink[]; + failed: { track_number: number; disc_number?: number | null; track_name: string; reason: string }[]; + quota: YouTubeQuotaStatus; +}; + + +export type StatusMessage = { + title?: string | null; + messages: string[]; +}; + +export type ActiveRequestItem = { + musicbrainz_id: string; + artist_name: string; + album_title: string; + artist_mbid?: string | null; + year?: number | null; + cover_url?: string | null; + requested_at: string; + status: string; + progress?: number | null; + eta?: string | null; + size?: number | null; + size_remaining?: number | null; + download_status?: string | null; + download_state?: string | null; + status_messages?: StatusMessage[] | null; + error_message?: string | null; + lidarr_queue_id?: number | null; + quality?: string | null; + protocol?: string | null; + download_client?: string | null; +}; + +export type RequestHistoryItem = { + musicbrainz_id: string; + artist_name: string; + album_title: string; + artist_mbid?: string | null; + year?: number | null; + cover_url?: string | null; + requested_at: string; + completed_at?: string | null; + status: string; + in_library: boolean; +}; + +export type ActiveRequestsResponse = { + items: ActiveRequestItem[]; + count: number; +}; + +export type RequestHistoryResponse = { + items: RequestHistoryItem[]; + total: number; + page: number; + page_size: number; + total_pages: number; +}; + + +export type JellyfinTrackInfo = { + jellyfin_id: string; + title: string; + track_number: number; + disc_number?: number | null; + duration_seconds: number; + album_name: string; + artist_name: string; + codec?: string | null; + bitrate?: number | null; +}; + +export type JellyfinAlbumMatch = { + found: boolean; + jellyfin_album_id?: string | null; + tracks: JellyfinTrackInfo[]; +}; + +export type JellyfinAlbumSummary = { + jellyfin_id: string; + name: string; + artist_name: string; + year?: number | null; + track_count: number; + image_url?: string | null; + musicbrainz_id?: string | null; + artist_musicbrainz_id?: string | null; +}; + +export type JellyfinPaginatedResponse = { + items: JellyfinAlbumSummary[]; + total: number; + offset: number; + limit: number; +}; + +export type JellyfinSearchResponse = { + albums: JellyfinAlbumSummary[]; + artists: JellyfinArtistSummary[]; + tracks: JellyfinTrackInfo[]; +}; + +export type JellyfinLibraryStats = { + total_tracks: number; + total_albums: number; + total_artists: number; +}; + +export type JellyfinArtistSummary = { + jellyfin_id: string; + name: string; + image_url?: string | null; + album_count: number; + musicbrainz_id?: string | null; +}; + + +export type NavidromeConnectionSettings = { + navidrome_url: string; + username: string; + password: string; + enabled: boolean; +}; + +export type NavidromeTrackInfo = { + navidrome_id: string; + title: string; + track_number: number; + disc_number?: number | null; + duration_seconds: number; + album_name: string; + artist_name: string; + codec?: string | null; + bitrate?: number | null; +}; + +export type NavidromeAlbumSummary = { + navidrome_id: string; + name: string; + artist_name: string; + year?: number | null; + track_count: number; + image_url?: string | null; + musicbrainz_id?: string | null; + artist_musicbrainz_id?: string | null; +}; + +export type NavidromeAlbumDetail = NavidromeAlbumSummary & { + tracks: NavidromeTrackInfo[]; +}; + +export type NavidromeAlbumMatch = { + found: boolean; + navidrome_album_id?: string | null; + tracks: NavidromeTrackInfo[]; +}; + +export type NavidromeArtistSummary = { + navidrome_id: string; + name: string; + image_url?: string | null; + album_count: number; + musicbrainz_id?: string | null; +}; + +export type NavidromeSearchResponse = { + albums: NavidromeAlbumSummary[]; + artists: NavidromeArtistSummary[]; + tracks: NavidromeTrackInfo[]; +}; + +export type NavidromeLibraryStats = { + total_tracks: number; + total_albums: number; + total_artists: number; +}; + +export type NavidromePaginatedResponse = { + items: NavidromeAlbumSummary[]; + total: number; +}; + + +export type LocalTrackInfo = { + track_file_id: number; + title: string; + track_number: number; + disc_number?: number | null; + duration_seconds?: number | null; + size_bytes: number; + format: string; + bitrate?: number | null; + date_added?: string | null; +}; + +export type LocalAlbumMatch = { + found: boolean; + tracks: LocalTrackInfo[]; + total_size_bytes: number; + primary_format?: string | null; +}; + +export type LocalAlbumSummary = { + lidarr_album_id: number; + musicbrainz_id: string; + name: string; + artist_name: string; + artist_mbid?: string | null; + year?: number | null; + track_count: number; + total_size_bytes: number; + primary_format?: string | null; + cover_url?: string | null; + date_added?: string | null; +}; + +export type LocalPaginatedResponse = { + items: LocalAlbumSummary[]; + total: number; + offset: number; + limit: number; +}; + +export type FormatInfo = { + count: number; + size_bytes: number; + size_human: string; +}; + +export type LocalStorageStats = { + total_tracks: number; + total_albums: number; + total_artists: number; + total_size_bytes: number; + total_size_human: string; + disk_free_bytes: number; + disk_free_human: string; + format_breakdown: Record; +}; + +export type LocalFilesConnectionSettings = { + enabled: boolean; + music_path: string; + lidarr_root_path: string; +}; + +export type LastFmConnectionSettings = { + api_key: string; + shared_secret: string; + session_key: string; + username: string; + enabled: boolean; +}; + +export type LastFmConnectionSettingsResponse = { + api_key: string; + shared_secret: string; + session_key: string; + username: string; + enabled: boolean; +}; + +export type LastFmVerifyResponse = { + valid: boolean; + message: string; +}; + +export type LastFmAuthTokenResponse = { + token: string; + auth_url: string; +}; + +export type LastFmAuthSessionResponse = { + username: string; + success: boolean; + message: string; +}; + +export type ScrobbleSettings = { + scrobble_to_lastfm: boolean; + scrobble_to_listenbrainz: boolean; +}; + +export type NowPlayingSubmission = { + track_name: string; + artist_name: string; + album_name: string; + duration_ms: number; + mbid?: string; +}; + +export type ScrobbleSubmission = { + track_name: string; + artist_name: string; + album_name: string; + timestamp: number; + duration_ms: number; + mbid?: string; +}; + +export type ServiceResult = { + success: boolean; + error?: string; +}; + +export type ScrobbleResponse = { + accepted: boolean; + services: Record; +}; + +export type LastFmTag = { + name: string; + url?: string | null; +}; + +export type LastFmSimilarArtistDetail = { + name: string; + mbid?: string | null; + match: number; + url?: string | null; +}; + +export type LastFmArtistEnrichment = { + bio?: string | null; + summary?: string | null; + tags: LastFmTag[]; + listeners: number; + playcount: number; + similar_artists: LastFmSimilarArtistDetail[]; + url?: string | null; +}; + +export type LastFmAlbumEnrichment = { + summary?: string | null; + tags: LastFmTag[]; + listeners: number; + playcount: number; + url?: string | null; +}; diff --git a/frontend/src/lib/utils/abortController.ts b/frontend/src/lib/utils/abortController.ts new file mode 100644 index 0000000..27a78dd --- /dev/null +++ b/frontend/src/lib/utils/abortController.ts @@ -0,0 +1,27 @@ +export type Abortable = { + readonly signal: AbortSignal; + abort: () => void; + reset: () => AbortSignal; + isAborted: () => boolean; +}; + +export function createAbortable(): Abortable { + let controller = new AbortController(); + + return { + get signal() { + return controller.signal; + }, + abort() { + controller.abort(); + }, + reset() { + controller.abort(); + controller = new AbortController(); + return controller.signal; + }, + isAborted() { + return controller.signal.aborted; + } + }; +} diff --git a/frontend/src/lib/utils/albumCardPlayback.ts b/frontend/src/lib/utils/albumCardPlayback.ts new file mode 100644 index 0000000..a6a062e --- /dev/null +++ b/frontend/src/lib/utils/albumCardPlayback.ts @@ -0,0 +1,189 @@ +import { get } from 'svelte/store'; +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; +import { integrationStore } from '$lib/stores/integration'; +import { playerStore } from '$lib/stores/player.svelte'; +import { playbackToast } from '$lib/stores/playbackToast.svelte'; +import { normalizeCodec } from '$lib/player/queueHelpers'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import type { QueueItem } from '$lib/player/types'; +import type { + JellyfinAlbumMatch, + LocalAlbumMatch, + NavidromeAlbumMatch, + JellyfinTrackInfo, + LocalTrackInfo, + NavidromeTrackInfo +} from '$lib/types'; + +export interface AlbumCardMeta { + mbid: string; + albumName: string; + artistName: string; + coverUrl: string | null; + artistId?: string; +} + +type SourceResult = { source: 'local' | 'navidrome' | 'jellyfin'; items: QueueItem[] }; + +function buildLocalItems(tracks: LocalTrackInfo[], meta: AlbumCardMeta): QueueItem[] { + const cover = getCoverUrl(meta.coverUrl, meta.mbid); + return tracks.map((t) => ({ + trackSourceId: String(t.track_file_id), + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + albumId: meta.mbid, + albumName: meta.albumName, + coverUrl: cover, + sourceType: 'local' as const, + artistId: meta.artistId, + streamUrl: API.stream.local(t.track_file_id), + format: t.format.toLowerCase() + })); +} + +function buildNavidromeItems(tracks: NavidromeTrackInfo[], meta: AlbumCardMeta): QueueItem[] { + const cover = getCoverUrl(meta.coverUrl, meta.mbid); + return tracks.map((t) => ({ + trackSourceId: t.navidrome_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + albumId: meta.mbid, + albumName: meta.albumName, + coverUrl: cover, + sourceType: 'navidrome' as const, + artistId: meta.artistId, + streamUrl: API.stream.navidrome(t.navidrome_id), + format: normalizeCodec(t.codec) + })); +} + +function buildJellyfinItems(tracks: JellyfinTrackInfo[], meta: AlbumCardMeta): QueueItem[] { + const cover = getCoverUrl(meta.coverUrl, meta.mbid); + return tracks.map((t) => ({ + trackSourceId: t.jellyfin_id, + trackName: t.title, + artistName: meta.artistName, + trackNumber: t.track_number, + albumId: meta.mbid, + albumName: meta.albumName, + coverUrl: cover, + sourceType: 'jellyfin' as const, + artistId: meta.artistId, + streamUrl: API.stream.jellyfin(t.jellyfin_id), + format: normalizeCodec(t.codec) + })); +} + +/** + * Probes configured sources in parallel and returns QueueItems from the + * highest-priority source that has tracks (local > navidrome > jellyfin). + */ +export async function fetchAlbumQueueItems( + meta: AlbumCardMeta, + signal?: AbortSignal +): Promise { + const status = get(integrationStore); + const probes: Promise[] = []; + + if (status.localfiles) { + probes.push( + api.global + .get(API.local.albumMatch(meta.mbid), { signal }) + .then((data) => { + if (!data?.found || data.tracks.length === 0) return null; + return { source: 'local' as const, items: buildLocalItems(data.tracks, meta) }; + }) + .catch(() => null) + ); + } + + if (status.navidrome) { + const url = new URL(API.navidromeLibrary.albumMatch(meta.mbid), window.location.origin); + if (meta.albumName) url.searchParams.set('name', meta.albumName); + if (meta.artistName) url.searchParams.set('artist', meta.artistName); + probes.push( + api.global + .get(url.toString(), { signal }) + .then((data) => { + if (!data?.found || data.tracks.length === 0) return null; + return { + source: 'navidrome' as const, + items: buildNavidromeItems(data.tracks, meta) + }; + }) + .catch(() => null) + ); + } + + if (status.jellyfin) { + probes.push( + api.global + .get(API.jellyfinLibrary.albumMatch(meta.mbid), { signal }) + .then((data) => { + if (!data?.found || data.tracks.length === 0) return null; + return { + source: 'jellyfin' as const, + items: buildJellyfinItems(data.tracks, meta) + }; + }) + .catch(() => null) + ); + } + + if (probes.length === 0) return []; + + const results = await Promise.all(probes); + const priority: Array<'local' | 'navidrome' | 'jellyfin'> = ['local', 'navidrome', 'jellyfin']; + for (const src of priority) { + const hit = results.find((r) => r?.source === src); + if (hit) return hit.items; + } + + return []; +} + +export async function cardQuickPlay(meta: AlbumCardMeta, signal?: AbortSignal): Promise { + const items = await fetchAlbumQueueItems(meta, signal); + if (items.length === 0) { + playbackToast.show('Nothing here can be played right now', 'info'); + return false; + } + playerStore.playQueue(items, 0, false); + return true; +} + +export async function cardQuickShuffle( + meta: AlbumCardMeta, + signal?: AbortSignal +): Promise { + const items = await fetchAlbumQueueItems(meta, signal); + if (items.length === 0) { + playbackToast.show('Nothing here can be played right now', 'info'); + return false; + } + playerStore.playQueue(items, 0, true); + return true; +} + +export async function cardAddToQueue(meta: AlbumCardMeta, signal?: AbortSignal): Promise { + const items = await fetchAlbumQueueItems(meta, signal); + if (items.length === 0) { + playbackToast.show('Nothing here can be played right now', 'info'); + return false; + } + playerStore.addMultipleToQueue(items); + return true; +} + +export async function cardPlayNext(meta: AlbumCardMeta, signal?: AbortSignal): Promise { + const items = await fetchAlbumQueueItems(meta, signal); + if (items.length === 0) { + playbackToast.show('Nothing here can be played right now', 'info'); + return false; + } + playerStore.playMultipleNext(items); + return true; +} diff --git a/frontend/src/lib/utils/albumDetailCache.ts b/frontend/src/lib/utils/albumDetailCache.ts new file mode 100644 index 0000000..89ffbe7 --- /dev/null +++ b/frontend/src/lib/utils/albumDetailCache.ts @@ -0,0 +1,68 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { + AlbumBasicInfo, + AlbumTracksInfo, + LastFmAlbumEnrichment, + MoreByArtistResponse, + SimilarAlbumsResponse, + YouTubeLink, + YouTubeTrackLink, + JellyfinAlbumMatch, + LocalAlbumMatch, + NavidromeAlbumMatch +} from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +const MAX_ALBUM_DETAIL_CACHE_ENTRIES = 120; + +export type AlbumDiscoveryCachePayload = { + moreByArtist: MoreByArtistResponse | null; + similarAlbums: SimilarAlbumsResponse | null; +}; + +export type AlbumYouTubeCachePayload = { + albumLink: YouTubeLink | null; + trackLinks: YouTubeTrackLink[]; +}; + +export type AlbumSourceMatchCachePayload = { + jellyfin: JellyfinAlbumMatch | null; + local: LocalAlbumMatch | null; + navidrome: NavidromeAlbumMatch | null; +}; + +export const albumBasicCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_BASIC_CACHE, + CACHE_TTL.ALBUM_DETAIL_BASIC, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); + +export const albumTracksCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_TRACKS_CACHE, + CACHE_TTL.ALBUM_DETAIL_TRACKS, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); + +export const albumDiscoveryCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_DISCOVERY_CACHE, + CACHE_TTL.ALBUM_DETAIL_DISCOVERY, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); + +export const albumLastFmCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_LASTFM_CACHE, + CACHE_TTL.ALBUM_DETAIL_LASTFM, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); + +export const albumYouTubeCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_YOUTUBE_CACHE, + CACHE_TTL.ALBUM_DETAIL_YOUTUBE, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); + +export const albumSourceMatchCache = createLocalStorageCache( + CACHE_KEYS.ALBUM_SOURCE_MATCH_CACHE, + CACHE_TTL.ALBUM_DETAIL_SOURCE_MATCH, + { maxEntries: MAX_ALBUM_DETAIL_CACHE_ENTRIES } +); diff --git a/frontend/src/lib/utils/albumRemove.ts b/frontend/src/lib/utils/albumRemove.ts new file mode 100644 index 0000000..3a5b043 --- /dev/null +++ b/frontend/src/lib/utils/albumRemove.ts @@ -0,0 +1,64 @@ +import { libraryStore } from '$lib/stores/library'; +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; + +export interface AlbumRemoveResult { + success: boolean; + artist_removed: boolean; + artist_name?: string | null; + error?: string; +} + +export interface AlbumRemovePreviewResult { + success: boolean; + artist_will_be_removed: boolean; + artist_name?: string | null; + error?: string; +} + +export async function getAlbumRemovePreview( + musicbrainzId: string +): Promise { + try { + const data = await api.global.get<{ + artist_will_be_removed?: boolean; + artist_name?: string | null; + }>(API.library.removeAlbumPreview(musicbrainzId)); + return { + success: true, + artist_will_be_removed: data.artist_will_be_removed ?? false, + artist_name: data.artist_name ?? null + }; + } catch (e) { + return { + success: false, + artist_will_be_removed: false, + error: e instanceof Error ? e.message : 'Unknown error' + }; + } +} + +export async function removeAlbum( + musicbrainzId: string, + deleteFiles: boolean = false +): Promise { + try { + const url = `${API.library.removeAlbum(musicbrainzId)}?delete_files=${deleteFiles}`; + const data = await api.global.delete<{ + artist_removed?: boolean; + artist_name?: string | null; + }>(url); + libraryStore.removeMbid(musicbrainzId); + return { + success: true, + artist_removed: data?.artist_removed ?? false, + artist_name: data?.artist_name ?? null + }; + } catch (e) { + return { + success: false, + artist_removed: false, + error: e instanceof Error ? e.message : 'Unknown error' + }; + } +} diff --git a/frontend/src/lib/utils/albumRequest.ts b/frontend/src/lib/utils/albumRequest.ts new file mode 100644 index 0000000..bbc3ffb --- /dev/null +++ b/frontend/src/lib/utils/albumRequest.ts @@ -0,0 +1,54 @@ +import { errorModal } from '$lib/stores/errorModal'; +import { libraryStore } from '$lib/stores/library'; +import { notifyRequestCountChanged } from '$lib/utils/requestsApi'; +import { api, ApiError } from '$lib/api/client'; + +export type AlbumRequestResult = { + success: boolean; + error?: string; +}; + +export type AlbumRequestContext = { + artist?: string; + album?: string; + year?: number | null; +}; + +export async function requestAlbum( + musicbrainzId: string, + context?: AlbumRequestContext +): Promise { + try { + await api.global.post('/api/v1/requests/new', { + musicbrainz_id: musicbrainzId, + artist: context?.artist ?? undefined, + album: context?.album ?? undefined, + year: context?.year ?? undefined + }); + + libraryStore.addRequested(musicbrainzId); + notifyRequestCountChanged(); + return { success: true }; + } catch (e) { + if (e instanceof ApiError) { + const errorDetail = e.message || 'Unknown error'; + + if (errorDetail.includes('Metadata Profile') || errorDetail.includes('Cannot add this')) { + const albumTypeMatch = errorDetail.match(/Cannot add this (\w+)/); + const albumType = albumTypeMatch ? albumTypeMatch[1] : 'release'; + + errorModal.show( + `Cannot Add ${albumType}`, + errorDetail, + 'Go to Lidarr -> Settings -> Profiles -> Metadata Profiles, and enable the appropriate release types in your active profile. After enabling, refresh the artist in Lidarr.' + ); + } else { + errorModal.show('Request Failed', errorDetail, ''); + } + + return { success: false, error: errorDetail }; + } + errorModal.show('Request Failed', 'Network error occurred', ''); + return { success: false, error: 'Network error occurred' }; + } +} diff --git a/frontend/src/lib/utils/artistDetailCache.ts b/frontend/src/lib/utils/artistDetailCache.ts new file mode 100644 index 0000000..d1b6aee --- /dev/null +++ b/frontend/src/lib/utils/artistDetailCache.ts @@ -0,0 +1,28 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { ArtistInfo, LastFmArtistEnrichment } from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +const MAX_ARTIST_DETAIL_CACHE_ENTRIES = 120; + +export type ArtistExtendedCachePayload = { + description: string | null; + image: string | null; +}; + +export const artistBasicCache = createLocalStorageCache( + CACHE_KEYS.ARTIST_BASIC_CACHE, + CACHE_TTL.ARTIST_DETAIL_BASIC, + { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } +); + +export const artistExtendedCache = createLocalStorageCache( + CACHE_KEYS.ARTIST_EXTENDED_CACHE, + CACHE_TTL.ARTIST_DETAIL_EXTENDED, + { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } +); + +export const artistLastFmCache = createLocalStorageCache( + CACHE_KEYS.ARTIST_LASTFM_CACHE, + CACHE_TTL.ARTIST_DETAIL_LASTFM, + { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } +); diff --git a/frontend/src/lib/utils/colors.ts b/frontend/src/lib/utils/colors.ts new file mode 100644 index 0000000..9ecb426 --- /dev/null +++ b/frontend/src/lib/utils/colors.ts @@ -0,0 +1,62 @@ +export const DEFAULT_GRADIENT = 'from-base-300 via-base-200 to-base-100'; + +export async function extractDominantColor(imgUrl: string): Promise { + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(DEFAULT_GRADIENT); + return; + } + + canvas.width = 50; + canvas.height = 50; + ctx.drawImage(img, 0, 0, 50, 50); + + const imageData = ctx.getImageData(0, 0, 50, 50).data; + let r = 0, + g = 0, + b = 0, + count = 0; + + for (let i = 0; i < imageData.length; i += 16) { + const pr = imageData[i]; + const pg = imageData[i + 1]; + const pb = imageData[i + 2]; + const pa = imageData[i + 3]; + + if (pa > 128) { + r += pr; + g += pg; + b += pb; + count++; + } + } + + if (count > 0) { + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + + const darkerR = Math.round(r * 0.3); + const darkerG = Math.round(g * 0.3); + const darkerB = Math.round(b * 0.3); + + resolve( + `from-[rgb(${darkerR},${darkerG},${darkerB})] via-[rgb(${Math.round(r * 0.15)},${Math.round(g * 0.15)},${Math.round(b * 0.15)})] to-base-100` + ); + } else { + resolve(DEFAULT_GRADIENT); + } + } catch (e) { + resolve(DEFAULT_GRADIENT); + } + }; + img.onerror = () => resolve(DEFAULT_GRADIENT); + img.src = imgUrl; + }); +} diff --git a/frontend/src/lib/utils/detailCacheHydration.spec.ts b/frontend/src/lib/utils/detailCacheHydration.spec.ts new file mode 100644 index 0000000..3dde020 --- /dev/null +++ b/frontend/src/lib/utils/detailCacheHydration.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { hydrateDetailCacheEntry } from './detailCacheHydration'; + +describe('hydrateDetailCacheEntry', () => { + it('hydrates data and returns stale status when cache exists', () => { + const onHydrate = vi.fn(); + const cache = { + get: vi.fn().mockReturnValue({ data: { value: 42 }, timestamp: 1234 }), + isStale: vi.fn().mockReturnValue(false) + }; + + const shouldRefresh = hydrateDetailCacheEntry({ + cache, + cacheKey: 'album-id', + onHydrate + }); + + expect(cache.get).toHaveBeenCalledWith('album-id'); + expect(onHydrate).toHaveBeenCalledWith({ value: 42 }); + expect(cache.isStale).toHaveBeenCalledWith(1234); + expect(shouldRefresh).toBe(false); + }); + + it('returns refresh=true when cache entry is missing', () => { + const onHydrate = vi.fn(); + const cache = { + get: vi.fn().mockReturnValue(null), + isStale: vi.fn() + }; + + const shouldRefresh = hydrateDetailCacheEntry({ + cache, + cacheKey: 'artist-id', + onHydrate + }); + + expect(cache.get).toHaveBeenCalledWith('artist-id'); + expect(onHydrate).not.toHaveBeenCalled(); + expect(cache.isStale).not.toHaveBeenCalled(); + expect(shouldRefresh).toBe(true); + }); +}); diff --git a/frontend/src/lib/utils/detailCacheHydration.ts b/frontend/src/lib/utils/detailCacheHydration.ts new file mode 100644 index 0000000..45f8fb8 --- /dev/null +++ b/frontend/src/lib/utils/detailCacheHydration.ts @@ -0,0 +1,29 @@ +type CachedEntry = { + data: T; + timestamp: number; +}; + +type LocalStorageDetailCache = { + get: (suffix?: string) => CachedEntry | null; + isStale: (timestamp: number) => boolean; +}; + +type HydrateCacheEntryParams = { + cache: LocalStorageDetailCache; + cacheKey: string; + onHydrate: (data: T) => void; +}; + +export function hydrateDetailCacheEntry({ + cache, + cacheKey, + onHydrate +}: HydrateCacheEntryParams): boolean { + const cachedEntry = cache.get(cacheKey); + if (!cachedEntry) { + return true; + } + + onHydrate(cachedEntry.data); + return cache.isStale(cachedEntry.timestamp); +} diff --git a/frontend/src/lib/utils/discoverCache.ts b/frontend/src/lib/utils/discoverCache.ts new file mode 100644 index 0000000..da91685 --- /dev/null +++ b/frontend/src/lib/utils/discoverCache.ts @@ -0,0 +1,13 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; +import type { DiscoverResponse } from '$lib/types'; + +const discoverCache = createLocalStorageCache( + CACHE_KEYS.DISCOVER_CACHE, + CACHE_TTL.DISCOVER +); + +export const getDiscoverCachedData = discoverCache.get; +export const setDiscoverCachedData = discoverCache.set; +export const isDiscoverCacheStale = discoverCache.isStale; +export const updateDiscoverCacheTTL = discoverCache.updateTTL; diff --git a/frontend/src/lib/utils/discoverQueueActions.spec.ts b/frontend/src/lib/utils/discoverQueueActions.spec.ts new file mode 100644 index 0000000..6c4d26a --- /dev/null +++ b/frontend/src/lib/utils/discoverQueueActions.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { resolveQueueCloseAction } from './discoverQueueActions'; + +describe('resolveQueueCloseAction', () => { + it('returns save when queue has items and not at last item', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 5, isLastItem: false })).toBe('save'); + }); + + it('returns remove when queue is empty', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 0, isLastItem: false })).toBe('remove'); + }); + + it('returns remove when at last item', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 5, isLastItem: true })).toBe('remove'); + }); + + it('returns remove when queue is empty and at last item', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 0, isLastItem: true })).toBe('remove'); + }); + + it('returns save when queue has 1 item and not at last item', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 1, isLastItem: false })).toBe('save'); + }); + + it('returns remove when queue has 1 item and at last item', () => { + expect.assertions(1); + expect(resolveQueueCloseAction({ queueLength: 1, isLastItem: true })).toBe('remove'); + }); +}); diff --git a/frontend/src/lib/utils/discoverQueueActions.ts b/frontend/src/lib/utils/discoverQueueActions.ts new file mode 100644 index 0000000..ff67228 --- /dev/null +++ b/frontend/src/lib/utils/discoverQueueActions.ts @@ -0,0 +1,18 @@ +import type { QueueCacheData } from '$lib/utils/discoverQueueCache'; + +export interface QueueCloseState { + queueLength: number; + isLastItem: boolean; +} + +/** + * Determines whether to save or remove the queue from localStorage on close/navigate. + * Returns 'save' if the queue has items and we're not at the last item, + * otherwise returns 'remove'. + */ +export function resolveQueueCloseAction(state: QueueCloseState): 'save' | 'remove' { + if (state.queueLength > 0 && !state.isLastItem) { + return 'save'; + } + return 'remove'; +} diff --git a/frontend/src/lib/utils/discoverQueueCache.svelte.spec.ts b/frontend/src/lib/utils/discoverQueueCache.svelte.spec.ts new file mode 100644 index 0000000..97c5cf2 --- /dev/null +++ b/frontend/src/lib/utils/discoverQueueCache.svelte.spec.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { + getQueueCachedData, + removeQueueCachedData, + setQueueCachedData, + subscribeQueueCacheChanges, + updateDiscoverQueueCacheTTL +} from './discoverQueueCache'; + +describe('discoverQueueCache', () => { + beforeEach(() => { + localStorage.clear(); + updateDiscoverQueueCacheTTL(CACHE_TTL.DISCOVER_QUEUE); + vi.restoreAllMocks(); + }); + + it('stores and retrieves queue items with enrichment payload', () => { + expect.assertions(3); + setQueueCachedData( + { + items: [ + { + release_group_mbid: 'rg-1', + album_name: 'Album 1', + artist_name: 'Artist 1', + artist_mbid: 'artist-1', + cover_url: null, + recommendation_reason: 'reason', + is_wildcard: false, + in_library: false, + enrichment: { + artist_mbid: 'artist-1', + release_date: '1970-01-01', + country: 'GB', + tags: ['prog rock'], + youtube_url: null, + youtube_search_url: 'https://youtube.example', + youtube_search_available: true, + artist_description: 'desc', + listen_count: 10 + } + } + ], + currentIndex: 0, + queueId: 'queue-1' + }, + 'listenbrainz' + ); + + const cached = getQueueCachedData('listenbrainz'); + expect(cached).not.toBeNull(); + expect(cached?.data.items[0].enrichment?.release_date).toBe('1970-01-01'); + expect(cached?.data.queueId).toBe('queue-1'); + }); + + it('invalidates stale queue cache entries on read', () => { + expect.assertions(2); + updateDiscoverQueueCacheTTL(10); + vi.spyOn(Date, 'now').mockReturnValue(1000); + setQueueCachedData( + { + items: [], + currentIndex: 0, + queueId: 'queue-2' + }, + 'lastfm' + ); + + vi.spyOn(Date, 'now').mockReturnValue(1200); + const cached = getQueueCachedData('lastfm'); + expect(cached).toBeNull(); + expect(localStorage.getItem(`${CACHE_KEYS.DISCOVER_QUEUE}_lastfm`)).toBeNull(); + }); + + it('emits cache change events for same-tab updates', () => { + expect.assertions(2); + const sources: Array = []; + const unsubscribe = subscribeQueueCacheChanges((source) => { + sources.push(source); + }); + + setQueueCachedData( + { + items: [], + currentIndex: 0, + queueId: 'queue-3' + }, + 'listenbrainz' + ); + removeQueueCachedData('listenbrainz'); + unsubscribe(); + + expect(sources).toHaveLength(2); + expect(sources).toEqual(['listenbrainz', 'listenbrainz']); + }); +}); diff --git a/frontend/src/lib/utils/discoverQueueCache.ts b/frontend/src/lib/utils/discoverQueueCache.ts new file mode 100644 index 0000000..e5c1660 --- /dev/null +++ b/frontend/src/lib/utils/discoverQueueCache.ts @@ -0,0 +1,72 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; +import type { DiscoverQueueItemFull } from '$lib/types'; + +export interface QueueCacheData { + items: DiscoverQueueItemFull[]; + currentIndex: number; + queueId: string; +} + +const queueCache = createLocalStorageCache( + CACHE_KEYS.DISCOVER_QUEUE, + CACHE_TTL.DISCOVER_QUEUE +); + +const QUEUE_CACHE_EVENT = 'discover-queue-cache-changed'; + +function notifyQueueCacheChanged(source?: string): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent<{ source?: string }>(QUEUE_CACHE_EVENT, { + detail: { source } + }) + ); +} + +export function subscribeQueueCacheChanges(listener: (source?: string) => void): () => void { + if (typeof window === 'undefined') return () => {}; + + const handler = (event: Event) => { + const customEvent = event as CustomEvent<{ source?: string }>; + listener(customEvent.detail?.source); + }; + + window.addEventListener(QUEUE_CACHE_EVENT, handler); + return () => { + window.removeEventListener(QUEUE_CACHE_EVENT, handler); + }; +} + +export const getQueueCachedData = (source?: string) => { + const cached = queueCache.get(source); + if (!cached) return null; + + if (queueCache.isStale(cached.timestamp)) { + queueCache.remove(source); + notifyQueueCacheChanged(source); + return null; + } + + return cached; +}; + +export const setQueueCachedData = (data: QueueCacheData, source?: string) => { + queueCache.set(data, source); + notifyQueueCacheChanged(source); +}; + +export const removeQueueCachedData = (source?: string) => { + queueCache.remove(source); + notifyQueueCacheChanged(source); +}; +export const updateDiscoverQueueCacheTTL = queueCache.updateTTL; + +const KNOWN_SOURCES = ['listenbrainz', 'lastfm'] as const; + +export function removeAllQueueCachedData(): void { + removeQueueCachedData(); + for (const src of KNOWN_SOURCES) { + removeQueueCachedData(src); + } +} diff --git a/frontend/src/lib/utils/dismissedPrompts.ts b/frontend/src/lib/utils/dismissedPrompts.ts new file mode 100644 index 0000000..7b7037d --- /dev/null +++ b/frontend/src/lib/utils/dismissedPrompts.ts @@ -0,0 +1,28 @@ +const STORAGE_KEY = 'musicseerr-dismissed-prompts'; + +const isBrowser = typeof localStorage !== 'undefined'; + +function getDismissed(): Set { + if (!isBrowser) return new Set(); + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +function saveDismissed(dismissed: Set): void { + if (!isBrowser) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify([...dismissed])); +} + +export function isDismissed(service: string): boolean { + return getDismissed().has(service); +} + +export function dismiss(service: string): void { + const dismissed = getDismissed(); + dismissed.add(service); + saveDismissed(dismissed); +} diff --git a/frontend/src/lib/utils/enrichment.spec.ts b/frontend/src/lib/utils/enrichment.spec.ts new file mode 100644 index 0000000..f0fcc73 --- /dev/null +++ b/frontend/src/lib/utils/enrichment.spec.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { getListenTitle, applyArtistEnrichment, applyAlbumEnrichment } from './enrichment'; +import type { Artist, Album, EnrichmentResponse } from '$lib/types'; + +describe('getListenTitle', () => { + it('returns "Last.fm listeners" for lastfm artist', () => { + expect.assertions(1); + expect(getListenTitle('lastfm', 'artist')).toBe('Last.fm listeners'); + }); + + it('returns "Last.fm plays" for lastfm album', () => { + expect.assertions(1); + expect(getListenTitle('lastfm', 'album')).toBe('Last.fm plays'); + }); + + it('returns "ListenBrainz plays" for listenbrainz artist', () => { + expect.assertions(1); + expect(getListenTitle('listenbrainz', 'artist')).toBe('ListenBrainz plays'); + }); + + it('returns "ListenBrainz plays" for listenbrainz album', () => { + expect.assertions(1); + expect(getListenTitle('listenbrainz', 'album')).toBe('ListenBrainz plays'); + }); + + it('returns "Plays" for none source', () => { + expect.assertions(1); + expect(getListenTitle('none')).toBe('Plays'); + }); + + it('defaults kind to artist', () => { + expect.assertions(1); + expect(getListenTitle('lastfm')).toBe('Last.fm listeners'); + }); +}); + +describe('applyArtistEnrichment', () => { + const baseArtist: Artist = { + musicbrainz_id: 'art-1', + title: 'Muse', + in_library: false, + release_group_count: null, + listen_count: null + }; + + it('applies enrichment data to matching artists', () => { + expect.assertions(2); + const enrichment: EnrichmentResponse = { + artists: [{ musicbrainz_id: 'art-1', release_group_count: 10, listen_count: 5000 }], + albums: [], + source: 'listenbrainz' + }; + const result = applyArtistEnrichment([baseArtist], enrichment); + expect(result[0].release_group_count).toBe(10); + expect(result[0].listen_count).toBe(5000); + }); + + it('preserves zero listen_count from enrichment', () => { + expect.assertions(1); + const enrichment: EnrichmentResponse = { + artists: [{ musicbrainz_id: 'art-1', release_group_count: 3, listen_count: 0 }], + albums: [], + source: 'lastfm' + }; + const result = applyArtistEnrichment([{ ...baseArtist, listen_count: 999 }], enrichment); + expect(result[0].listen_count).toBe(0); + }); + + it('keeps existing value when enrichment listen_count is null', () => { + expect.assertions(1); + const enrichment: EnrichmentResponse = { + artists: [{ musicbrainz_id: 'art-1', release_group_count: null, listen_count: null }], + albums: [], + source: 'none' + }; + const result = applyArtistEnrichment([{ ...baseArtist, listen_count: 42 }], enrichment); + expect(result[0].listen_count).toBe(42); + }); +}); + +describe('applyAlbumEnrichment', () => { + const baseAlbum: Album = { + musicbrainz_id: 'alb-1', + title: 'Absolution', + artist: null, + year: null, + in_library: false, + track_count: null, + listen_count: null + }; + + it('applies enrichment data to matching albums', () => { + expect.assertions(1); + const enrichment: EnrichmentResponse = { + artists: [], + albums: [{ musicbrainz_id: 'alb-1', track_count: 12, listen_count: 80000 }], + source: 'lastfm' + }; + const result = applyAlbumEnrichment([baseAlbum], enrichment); + expect(result[0].listen_count).toBe(80000); + }); + + it('preserves zero listen_count from enrichment', () => { + expect.assertions(1); + const enrichment: EnrichmentResponse = { + artists: [], + albums: [{ musicbrainz_id: 'alb-1', track_count: null, listen_count: 0 }], + source: 'lastfm' + }; + const result = applyAlbumEnrichment([{ ...baseAlbum, listen_count: 500 }], enrichment); + expect(result[0].listen_count).toBe(0); + }); +}); diff --git a/frontend/src/lib/utils/enrichment.ts b/frontend/src/lib/utils/enrichment.ts new file mode 100644 index 0000000..de36cc0 --- /dev/null +++ b/frontend/src/lib/utils/enrichment.ts @@ -0,0 +1,69 @@ +import type { + Artist, + Album, + EnrichmentResponse, + EnrichmentSource, + ArtistEnrichmentRequest, + AlbumEnrichmentRequest +} from '$lib/types'; +import { api } from '$lib/api/client'; + +export function getListenTitle( + source: EnrichmentSource, + kind: 'artist' | 'album' = 'artist' +): string { + if (source === 'lastfm') return kind === 'album' ? 'Last.fm plays' : 'Last.fm listeners'; + if (source === 'listenbrainz') return 'ListenBrainz plays'; + return 'Plays'; +} + +export async function fetchEnrichmentBatch( + artists: ArtistEnrichmentRequest[], + albums: AlbumEnrichmentRequest[], + signal?: AbortSignal +): Promise { + if (artists.length === 0 && albums.length === 0) return null; + + try { + return await api.post( + '/api/v1/search/enrich/batch', + { artists, albums }, + { signal } + ); + } catch { + return null; + } +} + +export function applyArtistEnrichment( + artists: Artist[], + enrichment: EnrichmentResponse +): Artist[] { + if (enrichment.artists.length === 0) return artists; + + const map = new Map(enrichment.artists.map((a) => [a.musicbrainz_id, a])); + return artists.map((artist) => { + const enrich = map.get(artist.musicbrainz_id); + if (!enrich) return artist; + return { + ...artist, + release_group_count: enrich.release_group_count ?? artist.release_group_count, + listen_count: enrich.listen_count ?? artist.listen_count + }; + }); +} + +export function applyAlbumEnrichment(albums: Album[], enrichment: EnrichmentResponse): Album[] { + if (enrichment.albums.length === 0) return albums; + + const map = new Map(enrichment.albums.map((a) => [a.musicbrainz_id, a])); + return albums.map((album) => { + const enrich = map.get(album.musicbrainz_id); + if (!enrich) return album; + return { + ...album, + track_count: enrich.track_count ?? album.track_count, + listen_count: enrich.listen_count ?? album.listen_count + }; + }); +} diff --git a/frontend/src/lib/utils/entityRoutes.ts b/frontend/src/lib/utils/entityRoutes.ts new file mode 100644 index 0000000..cdaabf1 --- /dev/null +++ b/frontend/src/lib/utils/entityRoutes.ts @@ -0,0 +1,17 @@ +import { resolve } from '$app/paths'; + +export function albumHref(mbid: string): string { + return resolve('/album/[id]', { id: mbid }); +} + +export function artistHref(mbid: string): string { + return resolve('/artist/[id]', { id: mbid }); +} + +export function albumHrefOrNull(mbid: string | null | undefined): string | null { + return mbid ? albumHref(mbid) : null; +} + +export function artistHrefOrNull(mbid: string | null | undefined): string | null { + return mbid ? artistHref(mbid) : null; +} diff --git a/frontend/src/lib/utils/errorHandling.spec.ts b/frontend/src/lib/utils/errorHandling.spec.ts new file mode 100644 index 0000000..ec191b6 --- /dev/null +++ b/frontend/src/lib/utils/errorHandling.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { getCoverUrl, isAbortError } from './errorHandling'; + +describe('isAbortError', () => { + it('returns true for Error named AbortError', () => { + expect.assertions(1); + const error = new Error('aborted'); + error.name = 'AbortError'; + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for DOMException AbortError when available', () => { + expect.assertions(1); + if (typeof DOMException === 'undefined') { + expect(isAbortError(new Error('no domexception'))).toBe(false); + return; + } + expect(isAbortError(new DOMException('aborted', 'AbortError'))).toBe(true); + }); + + it('returns false for non-abort errors', () => { + expect.assertions(1); + expect(isAbortError(new Error('other'))).toBe(false); + }); +}); + +describe('getCoverUrl', () => { + const validMbid = '12345678-1234-1234-1234-123456789abc'; + + it('returns API URL when albumId is a valid MBID', () => { + expect.assertions(1); + expect(getCoverUrl(null, validMbid)).toBe( + `/api/v1/covers/release-group/${validMbid}?size=250` + ); + }); + + it('ignores coverUrl when albumId is a valid MBID', () => { + expect.assertions(1); + expect(getCoverUrl('/some/custom/url.jpg', validMbid)).toBe( + `/api/v1/covers/release-group/${validMbid}?size=250` + ); + }); + + it('returns coverUrl when albumId is not a valid MBID and coverUrl is provided', () => { + expect.assertions(1); + expect(getCoverUrl('/custom.jpg', 'not-a-uuid')).toBe('/custom.jpg'); + }); + + it('returns API fallback URL when albumId is not a valid MBID and coverUrl is null', () => { + expect.assertions(1); + expect(getCoverUrl(null, 'not-a-uuid')).toBe( + '/api/v1/covers/release-group/not-a-uuid?size=250' + ); + }); + + it('returns API fallback URL when albumId is not a valid MBID and coverUrl is undefined', () => { + expect.assertions(1); + expect(getCoverUrl(undefined, 'not-a-uuid')).toBe( + '/api/v1/covers/release-group/not-a-uuid?size=250' + ); + }); + + it('returns API fallback URL when coverUrl is empty string', () => { + expect.assertions(1); + expect(getCoverUrl('', 'not-a-uuid')).toBe( + '/api/v1/covers/release-group/not-a-uuid?size=250' + ); + }); + + it('always returns a non-empty string', () => { + expect.assertions(3); + expect(getCoverUrl(null, validMbid)).toBeTruthy(); + expect(getCoverUrl(null, 'invalid')).toBeTruthy(); + expect(getCoverUrl('/img.jpg', 'invalid')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/utils/errorHandling.ts b/frontend/src/lib/utils/errorHandling.ts new file mode 100644 index 0000000..c927ce8 --- /dev/null +++ b/frontend/src/lib/utils/errorHandling.ts @@ -0,0 +1,15 @@ +import { isValidMbid } from '$lib/utils/formatting'; + +export function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + +export function getCoverUrl(coverUrl: string | null | undefined, albumId: string): string { + if (isValidMbid(albumId)) { + return `/api/v1/covers/release-group/${albumId}?size=250`; + } + return coverUrl || `/api/v1/covers/release-group/${albumId}?size=250`; +} diff --git a/frontend/src/lib/utils/formatting.spec.ts b/frontend/src/lib/utils/formatting.spec.ts new file mode 100644 index 0000000..ed2e5cf --- /dev/null +++ b/frontend/src/lib/utils/formatting.spec.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { formatListenCount } from './formatting'; + +describe('formatListenCount', () => { + it('returns empty string for null', () => { + expect.assertions(1); + expect(formatListenCount(null)).toBe(''); + }); + + it('formats zero as "0 plays"', () => { + expect.assertions(1); + expect(formatListenCount(0)).toBe('0 plays'); + }); + + it('formats zero compact as "0"', () => { + expect.assertions(1); + expect(formatListenCount(0, true)).toBe('0'); + }); + + it('formats small number with suffix', () => { + expect.assertions(1); + expect(formatListenCount(42)).toBe('42 plays'); + }); + + it('formats thousands', () => { + expect.assertions(1); + expect(formatListenCount(1500)).toBe('1.5K plays'); + }); + + it('formats thousands compact', () => { + expect.assertions(1); + expect(formatListenCount(1500, true)).toBe('1.5K'); + }); + + it('formats millions', () => { + expect.assertions(1); + expect(formatListenCount(2500000)).toBe('2.5M plays'); + }); + + it('formats millions compact', () => { + expect.assertions(1); + expect(formatListenCount(2500000, true)).toBe('2.5M'); + }); + + it('formats billions', () => { + expect.assertions(1); + expect(formatListenCount(3596400000)).toBe('3.6B plays'); + }); + + it('formats billions compact', () => { + expect.assertions(1); + expect(formatListenCount(3596400000, true)).toBe('3.6B'); + }); +}); diff --git a/frontend/src/lib/utils/formatting.ts b/frontend/src/lib/utils/formatting.ts new file mode 100644 index 0000000..7c09e47 --- /dev/null +++ b/frontend/src/lib/utils/formatting.ts @@ -0,0 +1,105 @@ +export function formatListenCount(count: number | null, compact = false): string { + if (count == null) return ''; + const suffix = compact ? '' : ' plays'; + if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(1)}B${suffix}`; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M${suffix}`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K${suffix}`; + return `${count}${suffix}`; +} + +export function formatListenedAt(timestamp: string | null): string { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function formatDuration(ms?: number | null): string { + if (!ms) return '--:--'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +export function formatDurationSec(sec?: number | null): string { + if (!sec && sec !== 0) return '--:--'; + const minutes = Math.floor(sec / 60); + const seconds = sec % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +export function formatTotalDuration(ms?: number | null): string { + if (!ms) return ''; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + if (hours > 0) { + return `${hours} hr ${minutes} min`; + } + return `${minutes} min`; +} + +export function formatTotalDurationSec(sec?: number | null): string { + if (!sec) return ''; + const hours = Math.floor(sec / 3600); + const minutes = Math.floor((sec % 3600) / 60); + + if (hours > 0) { + return `${hours} hr ${minutes} min`; + } + return `${minutes} min`; +} + +export function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +export function isValidMbid(id: string | null | undefined): boolean { + if (!id) return false; + const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return UUID_PATTERN.test(id); +} + +export function formatLastUpdated(date: Date | null): string { + if (!date) return ''; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return date.toLocaleDateString(); +} + +export function countryToFlag(code: string | null): string { + if (!code || code.length !== 2) return ''; + return String.fromCodePoint( + ...code + .toUpperCase() + .split('') + .map((c) => 0x1f1e6 + c.charCodeAt(0) - 65) + ); +} diff --git a/frontend/src/lib/utils/homeCache.ts b/frontend/src/lib/utils/homeCache.ts new file mode 100644 index 0000000..35877fe --- /dev/null +++ b/frontend/src/lib/utils/homeCache.ts @@ -0,0 +1,19 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; +import type { HomeResponse } from '$lib/types'; + +const homeCache = createLocalStorageCache(CACHE_KEYS.HOME_CACHE, CACHE_TTL.HOME); + +export const getHomeCachedData = homeCache.get; +export const setHomeCachedData = homeCache.set; +export const isHomeCacheStale = homeCache.isStale; +export const updateHomeCacheTTL = homeCache.updateTTL; + +export { formatLastUpdated } from '$lib/utils/formatting'; + +export function getGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +} diff --git a/frontend/src/lib/utils/imageSuffix.spec.ts b/frontend/src/lib/utils/imageSuffix.spec.ts new file mode 100644 index 0000000..9479fea --- /dev/null +++ b/frontend/src/lib/utils/imageSuffix.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { appendAudioDBSizeSuffix } from './imageSuffix'; + +describe('appendAudioDBSizeSuffix', () => { + const baseUrl = 'https://r2.theaudiodb.com/images/media/artist/thumb/abc123.jpg'; + + it('appends /small for xs size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'xs')).toBe(`${baseUrl}/small`); + }); + + it('appends /small for sm size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'sm')).toBe(`${baseUrl}/small`); + }); + + it('appends /small for md size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'md')).toBe(`${baseUrl}/small`); + }); + + it('appends /medium for lg size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'lg')).toBe(`${baseUrl}/medium`); + }); + + it('appends /medium for xl size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'xl')).toBe(`${baseUrl}/medium`); + }); + + it('appends /medium for hero size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'hero')).toBe(`${baseUrl}/medium`); + }); + + it('returns original URL for full size', () => { + expect.assertions(1); + expect(appendAudioDBSizeSuffix(baseUrl, 'full')).toBe(baseUrl); + }); + + it('is idempotent when URL already ends with /small', () => { + expect.assertions(1); + const urlWithSuffix = `${baseUrl}/small`; + expect(appendAudioDBSizeSuffix(urlWithSuffix, 'md')).toBe(urlWithSuffix); + }); + + it('is idempotent when URL already ends with /medium', () => { + expect.assertions(1); + const urlWithSuffix = `${baseUrl}/medium`; + expect(appendAudioDBSizeSuffix(urlWithSuffix, 'lg')).toBe(urlWithSuffix); + }); + + it('handles empty string input gracefully', () => { + expect.assertions(2); + expect(appendAudioDBSizeSuffix('', 'md')).toBe('/small'); + expect(appendAudioDBSizeSuffix('', 'full')).toBe(''); + }); +}); diff --git a/frontend/src/lib/utils/imageSuffix.ts b/frontend/src/lib/utils/imageSuffix.ts new file mode 100644 index 0000000..237bd40 --- /dev/null +++ b/frontend/src/lib/utils/imageSuffix.ts @@ -0,0 +1,20 @@ +type ComponentSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'hero' | 'full'; + +export function appendAudioDBSizeSuffix(url: string, size: ComponentSize): string { + if (url.endsWith('/small') || url.endsWith('/medium')) { + return url; + } + + switch (size) { + case 'xs': + case 'sm': + case 'md': + return `${url}/small`; + case 'lg': + case 'xl': + case 'hero': + return `${url}/medium`; + case 'full': + return url; + } +} diff --git a/frontend/src/lib/utils/jellyfinLibraryCache.ts b/frontend/src/lib/utils/jellyfinLibraryCache.ts new file mode 100644 index 0000000..a81aa02 --- /dev/null +++ b/frontend/src/lib/utils/jellyfinLibraryCache.ts @@ -0,0 +1,36 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { JellyfinAlbumSummary, JellyfinLibraryStats } from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +type JellyfinSidebarData = { + recentAlbums: JellyfinAlbumSummary[]; + favoriteAlbums: JellyfinAlbumSummary[]; + genres: string[]; + stats: JellyfinLibraryStats | null; +}; + +type JellyfinAlbumsListData = { + items: JellyfinAlbumSummary[]; + total: number; +}; + +export const jellyfinSidebarCache = createLocalStorageCache( + CACHE_KEYS.JELLYFIN_SIDEBAR, + CACHE_TTL.JELLYFIN_SIDEBAR +); + +export const jellyfinAlbumsListCache = createLocalStorageCache( + CACHE_KEYS.JELLYFIN_ALBUMS_LIST, + CACHE_TTL.JELLYFIN_ALBUMS_LIST, + { maxEntries: 80 } +); + +export const getJellyfinSidebarCachedData = jellyfinSidebarCache.get; +export const setJellyfinSidebarCachedData = jellyfinSidebarCache.set; +export const isJellyfinSidebarCacheStale = jellyfinSidebarCache.isStale; +export const updateJellyfinSidebarCacheTTL = jellyfinSidebarCache.updateTTL; + +export const getJellyfinAlbumsListCachedData = jellyfinAlbumsListCache.get; +export const setJellyfinAlbumsListCachedData = jellyfinAlbumsListCache.set; +export const isJellyfinAlbumsListCacheStale = jellyfinAlbumsListCache.isStale; +export const updateJellyfinAlbumsListCacheTTL = jellyfinAlbumsListCache.updateTTL; diff --git a/frontend/src/lib/utils/lazyImage.ts b/frontend/src/lib/utils/lazyImage.ts new file mode 100644 index 0000000..6c1ea82 --- /dev/null +++ b/frontend/src/lib/utils/lazyImage.ts @@ -0,0 +1,104 @@ +import { browser } from '$app/environment'; + +let sharedObserver: IntersectionObserver | null = null; +let observerRefCount = 0; +let pendingImages: Set = new Set(); + +export function cancelPendingImages() { + pendingImages.forEach(img => { + if (sharedObserver) { + sharedObserver.unobserve(img); + } + img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + }); + pendingImages.clear(); +} + +function getSharedObserver(): IntersectionObserver | null { + if (!browser) return null; + + if (!sharedObserver) { + sharedObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + const src = img.dataset.src; + if (src && img.src !== src) { + img.src = src; + sharedObserver?.unobserve(img); + } + } + }); + }, + { + rootMargin: '200px', + threshold: 0.01 + } + ); + } + + observerRefCount++; + return sharedObserver; +} + +function releaseSharedObserver() { + observerRefCount--; + if (observerRefCount <= 0 && sharedObserver) { + sharedObserver.disconnect(); + sharedObserver = null; + observerRefCount = 0; + } +} + +export function lazyImage(img: HTMLImageElement) { + const observer = getSharedObserver(); + + pendingImages.add(img); + + if (observer) { + observer.observe(img); + } else { + const src = img.dataset.src; + if (src) { + img.src = src; + pendingImages.delete(img); + } + } + + const handleLoad = () => pendingImages.delete(img); + const handleError = () => pendingImages.delete(img); + img.addEventListener('load', handleLoad); + img.addEventListener('error', handleError); + + return { + update() { + if (observer) { + observer.unobserve(img); + pendingImages.add(img); + observer.observe(img); + } + }, + destroy() { + img.removeEventListener('load', handleLoad); + img.removeEventListener('error', handleError); + pendingImages.delete(img); + if (observer) { + observer.unobserve(img); + } + releaseSharedObserver(); + } + }; +} + +export function resetLazyImage(img: HTMLImageElement, newSrc: string) { + img.classList.add('opacity-0'); + img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + img.dataset.src = newSrc; + + if (sharedObserver) { + sharedObserver.unobserve(img); + pendingImages.add(img); + sharedObserver.observe(img); + } +} diff --git a/frontend/src/lib/utils/libraryController.svelte.ts b/frontend/src/lib/utils/libraryController.svelte.ts new file mode 100644 index 0000000..9068d30 --- /dev/null +++ b/frontend/src/lib/utils/libraryController.svelte.ts @@ -0,0 +1,423 @@ +import { isAbortError } from '$lib/utils/errorHandling'; +import { ApiError } from '$lib/api/client'; +import { toastStore } from '$lib/stores/toast'; +import { playerStore } from '$lib/stores/player.svelte'; +import type { QueueItem } from '$lib/player/types'; +import type { MenuItem } from '$lib/components/ContextMenu.svelte'; +import { ListPlus, ListStart, ListMusic } from 'lucide-svelte'; + +export const PAGE_SIZE = 48; + +export interface SidebarData { + recentAlbums: TAlbum[]; + favoriteAlbums: TAlbum[]; + genres: string[]; + stats: Record | null; +} + +export interface LibraryAdapter { + sourceType: 'jellyfin' | 'navidrome' | 'local'; + + getAlbumId(album: TAlbum): string | number; + getAlbumName(album: TAlbum): string; + getArtistName(album: TAlbum): string; + getAlbumMbid(album: TAlbum): string | undefined; + getAlbumImageUrl(album: TAlbum): string | null; + getAlbumYear(album: TAlbum): number | null | undefined; + + fetchAlbums(params: { + limit: number; + offset: number; + sortBy: string; + sortOrder: string; + genre?: string; + search?: string; + signal: AbortSignal; + }): Promise<{ items: TAlbum[]; total: number }>; + + fetchSidebarData( + signal: AbortSignal, + current: SidebarData + ): Promise<{ data: SidebarData; hasFreshData: boolean }>; + + fetchAlbumQueueItems(album: TAlbum): Promise; + launchPlayback(album: TAlbum, shuffle: boolean): Promise; + + getAlbumsListCached( + key: string + ): { data: { items: TAlbum[]; total: number }; timestamp: number } | null; + setAlbumsListCached(key: string, data: { items: TAlbum[]; total: number }): void; + isAlbumsListCacheStale(timestamp: number): boolean; + getSidebarCached(): { data: SidebarData; timestamp: number } | null; + setSidebarCached(data: SidebarData): void; + isSidebarCacheStale(timestamp: number): boolean; + + sortOptions: { value: string; label: string }[]; + defaultSortBy: string; + ascValue: string; + descValue: string; + getDefaultSortOrder(field: string): string; + supportsGenres: boolean; + supportsFavorites: boolean; + supportsShuffle: boolean; + errorMessage: string; +} + +export function createLibraryController(adapter: LibraryAdapter) { + let albums = $state([]); + let recentAlbums = $state([]); + let favoriteAlbums = $state([]); + let genres = $state([]); + let stats = $state | null>(null); + let total = $state(0); + let loading = $state(true); + let loadingMore = $state(false); + let fetchError = $state(''); + + let sortBy = $state(adapter.defaultSortBy); + let sortOrder = $state(adapter.ascValue); + let selectedGenre = $state(''); + let searchQuery = $state(''); + let searchTimeout: ReturnType | null = null; + let fetchId = 0; + let albumsAbortController: AbortController | null = null; + let sidebarAbortController: AbortController | null = null; + let playingAlbumId = $state(null); + + let detailModalOpen = $state(false); + let selectedAlbum = $state(null); + let menuLoadingAlbumId = $state(null); + let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null); + + function getCacheKey(offset: number): string { + const search = searchQuery.trim() || ''; + const genre = selectedGenre || ''; + return `${sortBy}:${sortOrder}:${genre}:${search}:${PAGE_SIZE}:${offset}`; + } + + async function fetchAlbums(reset = false): Promise { + const id = ++fetchId; + fetchError = ''; + + if (albumsAbortController) albumsAbortController.abort(); + albumsAbortController = new AbortController(); + const signal = albumsAbortController.signal; + + if (reset) { + loading = true; + albums = []; + } else { + loadingMore = true; + } + + try { + const offset = reset ? 0 : albums.length; + const cacheKey = getCacheKey(offset); + const cached = adapter.getAlbumsListCached(cacheKey); + const albumsBeforeCache = [...albums]; + if (cached) { + albums = reset ? cached.data.items : [...albums, ...cached.data.items]; + total = cached.data.total; + if (!adapter.isAlbumsListCacheStale(cached.timestamp)) { + loading = false; + loadingMore = false; + return; + } + // Stale: show cached data, but keep loadingMore true to prevent re-trigger + loading = false; + } + + const data = await adapter.fetchAlbums({ + limit: PAGE_SIZE, + offset, + sortBy, + sortOrder, + genre: selectedGenre || undefined, + search: searchQuery.trim() || undefined, + signal + }); + if (id !== fetchId) return; + + albums = reset ? data.items : [...albumsBeforeCache, ...data.items]; + total = data.total; + adapter.setAlbumsListCached(cacheKey, { items: data.items, total: data.total }); + } catch (e) { + if (isAbortError(e)) return; + if (id === fetchId) { + fetchError = e instanceof ApiError ? e.message : adapter.errorMessage; + } + } finally { + if (id === fetchId) { + loading = false; + loadingMore = false; + } + } + } + + async function fetchSidebar(forceRefresh = false): Promise { + const cached = adapter.getSidebarCached(); + if (cached && !forceRefresh) { + recentAlbums = cached.data.recentAlbums; + favoriteAlbums = cached.data.favoriteAlbums; + genres = cached.data.genres; + stats = cached.data.stats; + if (!adapter.isSidebarCacheStale(cached.timestamp)) return; + } + + if (sidebarAbortController) sidebarAbortController.abort(); + sidebarAbortController = new AbortController(); + + try { + const { data: result, hasFreshData } = await adapter.fetchSidebarData( + sidebarAbortController.signal, + { recentAlbums, favoriteAlbums, genres, stats } + ); + recentAlbums = result.recentAlbums; + favoriteAlbums = result.favoriteAlbums; + genres = result.genres; + stats = result.stats; + if (hasFreshData) adapter.setSidebarCached(result); + } catch (e) { + if (isAbortError(e)) return; + } + } + + function openDetail(album: TAlbum): void { + selectedAlbum = album; + detailModalOpen = true; + } + + function handleDetailClose(): void { + selectedAlbum = null; + } + + function handleSortChange(value: string): void { + if (value !== sortBy) { + sortBy = value; + sortOrder = adapter.getDefaultSortOrder(value); + } + fetchAlbums(true); + } + + function toggleSortOrder(): void { + sortOrder = sortOrder === adapter.ascValue ? adapter.descValue : adapter.ascValue; + fetchAlbums(true); + } + + function handleGenreChange(value: string): void { + selectedGenre = value; + fetchAlbums(true); + } + + function handleSearch(): void { + if (searchTimeout) clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => fetchAlbums(true), 300); + } + + function loadMore(): void { + if (!loadingMore && albums.length < total) fetchAlbums(false); + } + + async function quickPlay(album: TAlbum, e: Event): Promise { + e.stopPropagation(); + playingAlbumId = adapter.getAlbumId(album); + try { + await adapter.launchPlayback(album, false); + } catch (e) { + if (!isAbortError(e)) + toastStore.show({ message: 'Playback failed', type: 'error' }); + } finally { + playingAlbumId = null; + } + } + + async function quickShuffle(album: TAlbum, e: Event): Promise { + e.stopPropagation(); + playingAlbumId = adapter.getAlbumId(album); + try { + await adapter.launchPlayback(album, true); + } catch (e) { + if (!isAbortError(e)) + toastStore.show({ message: 'Playback failed', type: 'error' }); + } finally { + playingAlbumId = null; + } + } + + async function addAlbumToQueue(album: TAlbum): Promise { + menuLoadingAlbumId = adapter.getAlbumId(album); + try { + const items = await adapter.fetchAlbumQueueItems(album); + if (items.length === 0) { + toastStore.show({ message: 'No tracks found for this album', type: 'info' }); + return; + } + playerStore.addMultipleToQueue(items); + } catch (e) { + if (!isAbortError(e)) + toastStore.show({ message: 'Failed to load album tracks', type: 'error' }); + } finally { + menuLoadingAlbumId = null; + } + } + + async function playAlbumNext(album: TAlbum): Promise { + menuLoadingAlbumId = adapter.getAlbumId(album); + try { + const items = await adapter.fetchAlbumQueueItems(album); + if (items.length === 0) { + toastStore.show({ message: 'No tracks found for this album', type: 'info' }); + return; + } + playerStore.playMultipleNext(items); + } catch (e) { + if (!isAbortError(e)) + toastStore.show({ message: 'Failed to load album tracks', type: 'error' }); + } finally { + menuLoadingAlbumId = null; + } + } + + async function addAlbumToPlaylist(album: TAlbum): Promise { + menuLoadingAlbumId = adapter.getAlbumId(album); + try { + const items = await adapter.fetchAlbumQueueItems(album); + if (items.length === 0) { + toastStore.show({ message: 'No tracks found for this album', type: 'info' }); + return; + } + playlistModalRef?.open(items); + } catch (e) { + if (!isAbortError(e)) + toastStore.show({ message: 'Failed to load album tracks', type: 'error' }); + } finally { + menuLoadingAlbumId = null; + } + } + + function getAlbumMenuItems(album: TAlbum): MenuItem[] { + const isLoading = menuLoadingAlbumId === adapter.getAlbumId(album); + return [ + { + label: 'Add to Queue', + icon: ListPlus, + onclick: () => void addAlbumToQueue(album), + disabled: isLoading + }, + { + label: 'Play Next', + icon: ListStart, + onclick: () => void playAlbumNext(album), + disabled: isLoading + }, + { + label: 'Add to Playlist', + icon: ListMusic, + onclick: () => void addAlbumToPlaylist(album), + disabled: isLoading + } + ]; + } + + function init(): void { + fetchAlbums(true); + fetchSidebar(); + } + + function cleanup(): void { + if (searchTimeout) clearTimeout(searchTimeout); + if (albumsAbortController) { + albumsAbortController.abort(); + albumsAbortController = null; + } + if (sidebarAbortController) { + sidebarAbortController.abort(); + sidebarAbortController = null; + } + } + + return { + get albums() { + return albums; + }, + get recentAlbums() { + return recentAlbums; + }, + get favoriteAlbums() { + return favoriteAlbums; + }, + get genres() { + return genres; + }, + get stats() { + return stats; + }, + get total() { + return total; + }, + get loading() { + return loading; + }, + get loadingMore() { + return loadingMore; + }, + get fetchError() { + return fetchError; + }, + get sortBy() { + return sortBy; + }, + get sortOrder() { + return sortOrder; + }, + get selectedGenre() { + return selectedGenre; + }, + get searchQuery() { + return searchQuery; + }, + set searchQuery(v: string) { + searchQuery = v; + }, + get playingAlbumId() { + return playingAlbumId; + }, + get detailModalOpen() { + return detailModalOpen; + }, + set detailModalOpen(v: boolean) { + detailModalOpen = v; + }, + get selectedAlbum() { + return selectedAlbum; + }, + get playlistModalRef() { + return playlistModalRef; + }, + set playlistModalRef(v) { + playlistModalRef = v; + }, + + adapter, + + fetchAlbums, + fetchSidebar, + openDetail, + handleDetailClose, + handleSortChange, + toggleSortOrder, + handleGenreChange, + handleSearch, + loadMore, + quickPlay, + quickShuffle, + addAlbumToQueue, + playAlbumNext, + addAlbumToPlaylist, + getAlbumMenuItems, + init, + cleanup + }; +} + +export type LibraryController = ReturnType>; diff --git a/frontend/src/lib/utils/localFilesCache.ts b/frontend/src/lib/utils/localFilesCache.ts new file mode 100644 index 0000000..0610684 --- /dev/null +++ b/frontend/src/lib/utils/localFilesCache.ts @@ -0,0 +1,34 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { LocalAlbumSummary, LocalStorageStats } from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +type LocalFilesSidebarData = { + recentAlbums: LocalAlbumSummary[]; + stats: LocalStorageStats | null; +}; + +type LocalFilesAlbumsListData = { + items: LocalAlbumSummary[]; + total: number; +}; + +export const localFilesSidebarCache = createLocalStorageCache( + CACHE_KEYS.LOCAL_FILES_SIDEBAR, + CACHE_TTL.LOCAL_FILES_SIDEBAR +); + +export const localFilesAlbumsListCache = createLocalStorageCache( + CACHE_KEYS.LOCAL_FILES_ALBUMS_LIST, + CACHE_TTL.LOCAL_FILES_ALBUMS_LIST, + { maxEntries: 80 } +); + +export const getLocalFilesSidebarCachedData = localFilesSidebarCache.get; +export const setLocalFilesSidebarCachedData = localFilesSidebarCache.set; +export const isLocalFilesSidebarCacheStale = localFilesSidebarCache.isStale; +export const updateLocalFilesSidebarCacheTTL = localFilesSidebarCache.updateTTL; + +export const getLocalFilesAlbumsListCachedData = localFilesAlbumsListCache.get; +export const setLocalFilesAlbumsListCachedData = localFilesAlbumsListCache.set; +export const isLocalFilesAlbumsListCacheStale = localFilesAlbumsListCache.isStale; +export const updateLocalFilesAlbumsListCacheTTL = localFilesAlbumsListCache.updateTTL; diff --git a/frontend/src/lib/utils/localStorageCache.ts b/frontend/src/lib/utils/localStorageCache.ts new file mode 100644 index 0000000..bf3abd0 --- /dev/null +++ b/frontend/src/lib/utils/localStorageCache.ts @@ -0,0 +1,189 @@ +import { browser } from '$app/environment'; + +interface CachedEntry { + data: T; + timestamp: number; +} + +interface LocalStorageCacheOptions { + maxEntries?: number; +} + +interface LocalStorageRecord { + key: string; + entry: CachedEntry; +} + +export function createLocalStorageCache( + baseKey: string, + initialTtl: number, + options: LocalStorageCacheOptions = {} +) { + let ttl = initialTtl; + const keyPrefix = `${baseKey}_`; + const maxEntries = options.maxEntries; + + function resolveKey(suffix?: string): string { + return suffix ? `${baseKey}_${suffix}` : baseKey; + } + + function parseEntry(raw: string | null): CachedEntry | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as CachedEntry; + if (typeof parsed?.timestamp !== 'number') { + return null; + } + return parsed; + } catch { + return null; + } + } + + function getMatchingKeys(): string[] { + const keys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key) continue; + if (key === baseKey || key.startsWith(keyPrefix)) { + keys.push(key); + } + } + return keys; + } + + function getRecords(): LocalStorageRecord[] { + const records: LocalStorageRecord[] = []; + for (const key of getMatchingKeys()) { + const entry = parseEntry(localStorage.getItem(key)); + if (!entry) { + localStorage.removeItem(key); + continue; + } + records.push({ key, entry }); + } + return records; + } + + function isStale(timestamp: number): boolean { + return Date.now() - timestamp > ttl; + } + + function isSuffixKey(key: string): boolean { + return key.startsWith(keyPrefix); + } + + function isQuotaExceededError(error: unknown): boolean { + if (!(error instanceof DOMException)) return false; + return ( + error.code === 22 || + error.code === 1014 || + error.name === 'QuotaExceededError' || + error.name === 'NS_ERROR_DOM_QUOTA_REACHED' + ); + } + + function removeStaleEntries(): number { + if (!browser) return 0; + let removed = 0; + for (const { key, entry } of getRecords()) { + if (isStale(entry.timestamp)) { + localStorage.removeItem(key); + removed += 1; + } + } + return removed; + } + + function enforceMaxEntriesLimit(): number { + if (!browser) return 0; + if (!maxEntries || maxEntries <= 0) return 0; + + const suffixRecords = getRecords() + .filter((record) => isSuffixKey(record.key)) + .sort((a, b) => b.entry.timestamp - a.entry.timestamp); + + if (suffixRecords.length <= maxEntries) return 0; + + let removed = 0; + for (const record of suffixRecords.slice(maxEntries)) { + localStorage.removeItem(record.key); + removed += 1; + } + + return removed; + } + + function removeOldestEntry(): boolean { + if (!browser) return false; + const oldest = getRecords() + .filter((record) => isSuffixKey(record.key)) + .sort((a, b) => a.entry.timestamp - b.entry.timestamp)[0]; + if (!oldest) return false; + localStorage.removeItem(oldest.key); + return true; + } + + function tryWrite(key: string, payload: string): boolean { + try { + localStorage.setItem(key, payload); + return true; + } catch (error) { + if (!isQuotaExceededError(error)) { + console.warn(`[localStorageCache] Failed to write key "${key}":`, error); + } + return false; + } + } + + function get(suffix?: string): CachedEntry | null { + if (!browser) return null; + const key = resolveKey(suffix); + const raw = localStorage.getItem(key); + const entry = parseEntry(raw); + if (!entry && raw) { + localStorage.removeItem(key); + } + if (!entry) { + return null; + } + return entry; + } + + function set(data: T, suffix?: string): void { + if (!browser) return; + const key = resolveKey(suffix); + const payload = JSON.stringify({ data, timestamp: Date.now() } satisfies CachedEntry); + + if (tryWrite(key, payload)) { + enforceMaxEntriesLimit(); + return; + } + + removeStaleEntries(); + enforceMaxEntriesLimit(); + + if (tryWrite(key, payload)) { + enforceMaxEntriesLimit(); + return; + } + + if (removeOldestEntry() && tryWrite(key, payload)) { + enforceMaxEntriesLimit(); + return; + } + + console.warn(`[localStorageCache] Storage quota exceeded for key "${key}".`); + } + + function remove(suffix?: string): void { + if (!browser) return; + localStorage.removeItem(resolveKey(suffix)); + } + + function updateTTL(newTtl: number): void { + ttl = newTtl; + } + + return { get, set, remove, isStale, updateTTL }; +} diff --git a/frontend/src/lib/utils/navidromeLibraryCache.ts b/frontend/src/lib/utils/navidromeLibraryCache.ts new file mode 100644 index 0000000..b1dffa9 --- /dev/null +++ b/frontend/src/lib/utils/navidromeLibraryCache.ts @@ -0,0 +1,36 @@ +import { CACHE_KEYS, CACHE_TTL } from '$lib/constants'; +import type { NavidromeAlbumSummary, NavidromeLibraryStats } from '$lib/types'; +import { createLocalStorageCache } from '$lib/utils/localStorageCache'; + +type NavidromeSidebarData = { + recentAlbums: NavidromeAlbumSummary[]; + favoriteAlbums: NavidromeAlbumSummary[]; + genres: string[]; + stats: NavidromeLibraryStats | null; +}; + +type NavidromeAlbumsListData = { + items: NavidromeAlbumSummary[]; + total: number; +}; + +export const navidromeSidebarCache = createLocalStorageCache( + CACHE_KEYS.NAVIDROME_SIDEBAR, + CACHE_TTL.NAVIDROME_SIDEBAR +); + +export const navidromeAlbumsListCache = createLocalStorageCache( + CACHE_KEYS.NAVIDROME_ALBUMS_LIST, + CACHE_TTL.NAVIDROME_ALBUMS_LIST, + { maxEntries: 80 } +); + +export const getNavidromeSidebarCachedData = navidromeSidebarCache.get; +export const setNavidromeSidebarCachedData = navidromeSidebarCache.set; +export const isNavidromeSidebarCacheStale = navidromeSidebarCache.isStale; +export const updateNavidromeSidebarCacheTTL = navidromeSidebarCache.updateTTL; + +export const getNavidromeAlbumsListCachedData = navidromeAlbumsListCache.get; +export const setNavidromeAlbumsListCachedData = navidromeAlbumsListCache.set; +export const isNavidromeAlbumsListCacheStale = navidromeAlbumsListCache.isStale; +export const updateNavidromeAlbumsListCacheTTL = navidromeAlbumsListCache.updateTTL; diff --git a/frontend/src/lib/utils/navigationAbort.test.ts b/frontend/src/lib/utils/navigationAbort.test.ts new file mode 100644 index 0000000..6469d45 --- /dev/null +++ b/frontend/src/lib/utils/navigationAbort.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + pageFetch, + abortAllPageRequests, + getNavigationSignal, + isAbortError +} from '$lib/utils/navigationAbort'; + +beforeEach(() => { + vi.stubGlobal('window', {}); + abortAllPageRequests(); + vi.restoreAllMocks(); +}); + +describe('getNavigationSignal', () => { + it('returns a non-aborted signal initially', () => { + const signal = getNavigationSignal(); + expect(signal.aborted).toBe(false); + }); + + it('returns an aborted signal after abortAllPageRequests', () => { + const signal = getNavigationSignal(); + abortAllPageRequests(); + expect(signal.aborted).toBe(true); + }); + + it('returns a fresh non-aborted signal after reset', () => { + abortAllPageRequests(); + const signal = getNavigationSignal(); + expect(signal.aborted).toBe(false); + }); +}); + +describe('abortAllPageRequests', () => { + it('aborts the current navigation signal', () => { + const signal = getNavigationSignal(); + expect(signal.aborted).toBe(false); + abortAllPageRequests(); + expect(signal.aborted).toBe(true); + }); + + it('can be called multiple times safely', () => { + abortAllPageRequests(); + abortAllPageRequests(); + const signal = getNavigationSignal(); + expect(signal.aborted).toBe(false); + }); +}); + +describe('pageFetch', () => { + it('attaches the navigation signal to requests', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', mockFetch); + + await pageFetch('/api/v1/test'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1]?.signal).toBe(getNavigationSignal()); + }); + + it('combines navigation signal with caller signal via AbortSignal.any', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', mockFetch); + + const localController = new AbortController(); + await pageFetch('/api/v1/test', { signal: localController.signal }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const callArgs = mockFetch.mock.calls[0]; + const signal = callArgs[1]?.signal as AbortSignal; + expect(signal).toBeDefined(); + expect(signal).not.toBe(getNavigationSignal()); + expect(signal).not.toBe(localController.signal); + expect(signal.aborted).toBe(false); + }); + + it('aborts when navigation fires even with local signal', async () => { + const localController = new AbortController(); + const mockFetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); + }); + vi.stubGlobal('fetch', mockFetch); + + const promise = pageFetch('/api/v1/test', { signal: localController.signal }); + abortAllPageRequests(); + + await expect(promise).rejects.toThrow(); + expect(true).toBe(true); + }); + + it('aborts when local signal fires', async () => { + const localController = new AbortController(); + const mockFetch = vi.fn().mockImplementation((_url: string, init?: RequestInit) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); + }); + vi.stubGlobal('fetch', mockFetch); + + const promise = pageFetch('/api/v1/test', { signal: localController.signal }); + localController.abort(); + + await expect(promise).rejects.toThrow(); + expect(true).toBe(true); + }); + + it('preserves other init options', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', mockFetch); + + await pageFetch('/api/v1/test', { + method: 'GET', + headers: { 'X-Custom': 'value' } + }); + + expect(mockFetch).toHaveBeenCalledOnce(); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1]?.method).toBe('GET'); + expect((callArgs[1]?.headers as Record)['X-Custom']).toBe('value'); + }); +}); + +describe('isAbortError', () => { + it('returns true for DOMException with AbortError name', () => { + const err = new DOMException('The operation was aborted', 'AbortError'); + expect(isAbortError(err)).toBe(true); + }); + + it('returns true for Error with AbortError name', () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + expect(isAbortError(err)).toBe(true); + }); + + it('returns false for regular Error', () => { + expect(isAbortError(new Error('network error'))).toBe(false); + }); + + it('returns false for non-error values', () => { + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + expect(isAbortError('AbortError')).toBe(false); + expect(isAbortError(42)).toBe(false); + }); +}); + +describe('raw fetch is not affected by navigation abort', () => { + it('native fetch does not use navigation signal', async () => { + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', mockFetch); + + await fetch('/api/v1/mutation', { method: 'POST' }); + abortAllPageRequests(); + + expect(mockFetch).toHaveBeenCalledOnce(); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1]?.signal).toBeUndefined(); + }); +}); + +describe('SSR safety', () => { + it('falls back to raw fetch when window is undefined', async () => { + vi.stubGlobal('window', undefined); + const mockFetch = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', mockFetch); + + await pageFetch('/api/v1/test'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const callArgs = mockFetch.mock.calls[0]; + expect(callArgs[1]?.signal).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/utils/navigationAbort.ts b/frontend/src/lib/utils/navigationAbort.ts new file mode 100644 index 0000000..2aab380 --- /dev/null +++ b/frontend/src/lib/utils/navigationAbort.ts @@ -0,0 +1,29 @@ +import { serviceStatusStore } from '$lib/stores/serviceStatus'; + +let controller = new AbortController(); + +export function getNavigationSignal(): AbortSignal { + return controller.signal; +} + +export function abortAllPageRequests(): void { + controller.abort(); + controller = new AbortController(); +} + +export async function pageFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + if (typeof window === 'undefined') return fetch(input, init); + const navSignal = getNavigationSignal(); + const existingSignal = init?.signal; + const signal = existingSignal ? AbortSignal.any([navSignal, existingSignal]) : navSignal; + const response = await fetch(input, { ...init, signal }); + + const degradedHeader = response.headers.get('X-Degraded-Services'); + if (degradedHeader) { + serviceStatusStore.recordFromHeader(degradedHeader); + } + + return response; +} + +export { isAbortError } from '$lib/utils/errorHandling'; diff --git a/frontend/src/lib/utils/navigationProgress.ts b/frontend/src/lib/utils/navigationProgress.ts new file mode 100644 index 0000000..017c660 --- /dev/null +++ b/frontend/src/lib/utils/navigationProgress.ts @@ -0,0 +1,88 @@ +type NavigationProgressControllerOptions = { + delayMs: number; + minVisibleMs: number; + onVisibleChange: (visible: boolean) => void; + now?: () => number; +}; + +export type NavigationProgressController = { + start: () => void; + finish: () => void; + cleanup: () => void; +}; + +export function createNavigationProgressController({ + delayMs, + minVisibleMs, + onVisibleChange, + now = () => Date.now() +}: NavigationProgressControllerOptions): NavigationProgressController { + let delayTimer: ReturnType | null = null; + let hideTimer: ReturnType | null = null; + let shownAt = 0; + let isVisible = false; + + function clearTimers() { + if (delayTimer) { + clearTimeout(delayTimer); + delayTimer = null; + } + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + } + + function setVisible(nextVisible: boolean) { + if (isVisible === nextVisible) return; + isVisible = nextVisible; + onVisibleChange(nextVisible); + } + + return { + start() { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + if (delayTimer) { + clearTimeout(delayTimer); + } + + delayTimer = setTimeout(() => { + shownAt = now(); + setVisible(true); + delayTimer = null; + }, delayMs); + }, + finish() { + if (delayTimer) { + clearTimeout(delayTimer); + delayTimer = null; + return; + } + + if (!isVisible) { + return; + } + + const remaining = minVisibleMs - (now() - shownAt); + if (remaining <= 0) { + setVisible(false); + return; + } + + if (hideTimer) { + clearTimeout(hideTimer); + } + hideTimer = setTimeout(() => { + setVisible(false); + hideTimer = null; + }, remaining); + }, + cleanup() { + clearTimers(); + setVisible(false); + } + }; +} diff --git a/frontend/src/lib/utils/requestsApi.ts b/frontend/src/lib/utils/requestsApi.ts new file mode 100644 index 0000000..e5b85a2 --- /dev/null +++ b/frontend/src/lib/utils/requestsApi.ts @@ -0,0 +1,55 @@ +import type { + ActiveRequestsResponse, + RequestHistoryResponse +} from '$lib/types'; +import { api } from '$lib/api/client'; +import { requestCountStore } from '$lib/stores/requestCountStore.svelte'; +export type { ActiveRequestsResponse, RequestHistoryResponse } from '$lib/types'; + +export function notifyRequestCountChanged(count?: number): void { + requestCountStore.notify(count); +} + +export async function fetchActiveRequests(signal?: AbortSignal): Promise { + return api.global.get('/api/v1/requests/active', { signal }); +} + +export async function fetchRequestHistory( + page: number = 1, + pageSize: number = 20, + status?: string, + signal?: AbortSignal, + sort?: string +): Promise { + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (status) params.set('status', status); + if (sort) params.set('sort', sort); + return api.global.get(`/api/v1/requests/history?${params}`, { signal }); +} + +export async function cancelRequest( + musicbrainzId: string +): Promise<{ success: boolean; message: string }> { + const data = await api.global.delete<{ success: boolean; message: string }>(`/api/v1/requests/active/${musicbrainzId}`); + notifyRequestCountChanged(); + return data; +} + +export async function retryRequest( + musicbrainzId: string +): Promise<{ success: boolean; message: string }> { + const data = await api.global.post<{ success: boolean; message: string }>(`/api/v1/requests/retry/${musicbrainzId}`); + notifyRequestCountChanged(); + return data; +} + +export async function clearHistoryItem( + musicbrainzId: string +): Promise<{ success: boolean }> { + return api.global.delete<{ success: boolean }>(`/api/v1/requests/history/${musicbrainzId}`); +} + +export async function fetchActiveRequestCount(signal?: AbortSignal): Promise { + const data = await api.global.get<{ count?: number }>('/api/v1/requests/active/count', { signal }); + return data.count ?? 0; +} diff --git a/frontend/src/lib/utils/serviceStatus.ts b/frontend/src/lib/utils/serviceStatus.ts new file mode 100644 index 0000000..684549a --- /dev/null +++ b/frontend/src/lib/utils/serviceStatus.ts @@ -0,0 +1,15 @@ +import { serviceStatusStore } from '$lib/stores/serviceStatus'; + +/** + * Extract and record `service_status` from a parsed JSON response body. + * Call this after parsing JSON from any API response that may include + * the optional `service_status` field. + */ +export function extractServiceStatus(data: unknown): void { + if (data && typeof data === 'object' && 'service_status' in data) { + const status = (data as Record).service_status; + if (status && typeof status === 'object' && !Array.isArray(status)) { + serviceStatusStore.recordFromResponse(status as Record); + } + } +} diff --git a/frontend/src/lib/utils/settingsForm.spec.ts b/frontend/src/lib/utils/settingsForm.spec.ts new file mode 100644 index 0000000..7d5eec6 --- /dev/null +++ b/frontend/src/lib/utils/settingsForm.spec.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockApiGet, mockApiPut, mockApiPost, mockSetStatus } = vi.hoisted(() => ({ + mockApiGet: vi.fn(), + mockApiPut: vi.fn(), + mockApiPost: vi.fn(), + mockSetStatus: vi.fn(), +})); + +vi.mock('$lib/api/client', () => { + class ApiError extends Error { + status: number; + code: string; + details: unknown; + constructor(status: number, code: string, message: string, details?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + this.details = details; + } + } + return { + api: { + global: { get: mockApiGet, put: mockApiPut, post: mockApiPost }, + get: mockApiGet, put: mockApiPut, post: mockApiPost, + }, + ApiError, + }; +}); + +vi.mock('$lib/utils/errorHandling', () => ({ + isAbortError: (e: unknown) => e instanceof DOMException && (e as DOMException).name === 'AbortError', +})); + +vi.mock('$lib/stores/integration', () => ({ + integrationStore: { setStatus: mockSetStatus }, +})); + +import { createSettingsForm } from './settingsForm.svelte'; + +interface TestSettings { + url: string; + enabled: boolean; +} + +const defaultConfig = { + loadEndpoint: '/api/v1/settings/test', + saveEndpoint: '/api/v1/settings/test', +}; + +describe('createSettingsForm', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('initializes with loading=true and null data', () => { + const form = createSettingsForm(defaultConfig); + expect(form.loading).toBe(true); + expect(form.data).toBeNull(); + expect(form.message).toBe(''); + form.cleanup(); + }); + + describe('load', () => { + it('fetches data and sets state on success', async () => { + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + const form = createSettingsForm(defaultConfig); + await form.load(); + + expect(mockApiGet).toHaveBeenCalledWith('/api/v1/settings/test'); + expect(form.data).toEqual(data); + expect(form.loading).toBe(false); + expect(form.message).toBe(''); + form.cleanup(); + }); + + it('tracks wasAlreadyEnabled from enabledField', async () => { + mockApiGet.mockResolvedValueOnce({ url: 'http://test', enabled: true }); + const form = createSettingsForm({ ...defaultConfig, enabledField: 'enabled' }); + await form.load(); + + expect(form.wasAlreadyEnabled).toBe(true); + form.cleanup(); + }); + + it('shows error message on failure', async () => { + mockApiGet.mockRejectedValueOnce(new Error('Network error')); + const form = createSettingsForm(defaultConfig); + await form.load(); + + expect(form.loading).toBe(false); + expect(form.message).toBe("Couldn't load your settings"); + expect(form.messageType).toBe('error'); + form.cleanup(); + }); + + it('uses defaultValue on load failure', async () => { + const defaultValue = { url: '', enabled: false }; + mockApiGet.mockRejectedValueOnce(new Error('fail')); + const form = createSettingsForm({ + ...defaultConfig, + defaultValue, + }); + await form.load(); + + expect(form.data).toEqual(defaultValue); + form.cleanup(); + }); + }); + + describe('save', () => { + it('PUTs data and returns true on success', async () => { + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPut.mockResolvedValueOnce(data); + + const form = createSettingsForm(defaultConfig); + await form.load(); + const result = await form.save(); + + expect(mockApiPut).toHaveBeenCalledWith('/api/v1/settings/test', data); + expect(result).toBe(true); + expect(form.message).toBe('Settings saved'); + expect(form.messageType).toBe('success'); + form.cleanup(); + }); + + it('auto-clears success message after 5s', async () => { + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPut.mockResolvedValueOnce(data); + + const form = createSettingsForm(defaultConfig); + await form.load(); + await form.save(); + + expect(form.message).toBe('Settings saved'); + vi.advanceTimersByTime(5000); + expect(form.message).toBe(''); + form.cleanup(); + }); + + it('returns false on failure', async () => { + mockApiGet.mockResolvedValueOnce({ url: '', enabled: false }); + mockApiPut.mockRejectedValueOnce(new Error('save failed')); + + const form = createSettingsForm(defaultConfig); + await form.load(); + const result = await form.save(); + + expect(result).toBe(false); + expect(form.message).toBe("Couldn't save your settings"); + expect(form.messageType).toBe('error'); + form.cleanup(); + }); + + it('uses ApiError message on failure', async () => { + const { ApiError } = await import('$lib/api/client'); + mockApiGet.mockResolvedValueOnce({ url: '', enabled: false }); + mockApiPut.mockRejectedValueOnce(new ApiError(400, 'VALIDATION', 'Invalid URL format')); + + const form = createSettingsForm(defaultConfig); + await form.load(); + const result = await form.save(); + + expect(result).toBe(false); + expect(form.message).toBe('Invalid URL format'); + form.cleanup(); + }); + + it('refreshes integration status when configured', async () => { + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPut.mockResolvedValueOnce(data); + mockApiGet.mockResolvedValueOnce({ jellyfin: true }); + + const form = createSettingsForm({ + ...defaultConfig, + refreshIntegration: true, + }); + await form.load(); + await form.save(); + + expect(mockSetStatus).toHaveBeenCalledWith({ jellyfin: true }); + form.cleanup(); + }); + + it('calls afterSave callback on success', async () => { + const afterSave = vi.fn(); + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPut.mockResolvedValueOnce(data); + + const form = createSettingsForm({ + ...defaultConfig, + afterSave, + }); + await form.load(); + await form.save(); + + expect(afterSave).toHaveBeenCalledWith(data); + form.cleanup(); + }); + + it('updates wasAlreadyEnabled after save', async () => { + mockApiGet.mockResolvedValueOnce({ url: '', enabled: false }); + mockApiPut.mockResolvedValueOnce({ url: 'http://new', enabled: true }); + + const form = createSettingsForm({ + ...defaultConfig, + enabledField: 'enabled', + }); + await form.load(); + expect(form.wasAlreadyEnabled).toBe(false); + + await form.save(); + expect(form.wasAlreadyEnabled).toBe(true); + form.cleanup(); + }); + }); + + describe('test', () => { + it('POSTs data to test endpoint and sets testResult', async () => { + const data = { url: 'http://test', enabled: true }; + const testData = { success: true, message: 'Connected' }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPost.mockResolvedValueOnce(testData); + + const form = createSettingsForm({ + ...defaultConfig, + testEndpoint: '/api/v1/settings/test/verify', + }); + await form.load(); + await form.test(); + + expect(mockApiPost).toHaveBeenCalledWith('/api/v1/settings/test/verify', data); + expect(form.testResult).toEqual(testData); + expect(form.testing).toBe(false); + form.cleanup(); + }); + + it('calls afterTest callback', async () => { + const afterTest = vi.fn(); + mockApiGet.mockResolvedValueOnce({ url: 'http://test', enabled: true }); + mockApiPost.mockResolvedValueOnce({ success: true }); + + const form = createSettingsForm({ + ...defaultConfig, + testEndpoint: '/api/v1/settings/test/verify', + afterTest, + }); + await form.load(); + await form.test(); + + expect(afterTest).toHaveBeenCalledWith({ success: true }); + form.cleanup(); + }); + + it('sets failure testResult on error', async () => { + mockApiGet.mockResolvedValueOnce({ url: 'http://test', enabled: true }); + mockApiPost.mockRejectedValueOnce(new Error('timeout')); + + const form = createSettingsForm({ + ...defaultConfig, + testEndpoint: '/api/v1/settings/test/verify', + }); + await form.load(); + await form.test(); + + expect(form.testResult).toEqual({ + success: false, + valid: false, + message: "Couldn't test the connection", + }); + form.cleanup(); + }); + + it('does nothing without testEndpoint', async () => { + mockApiGet.mockResolvedValueOnce({ url: 'http://test', enabled: true }); + const form = createSettingsForm(defaultConfig); + await form.load(); + await form.test(); + + expect(mockApiPost).not.toHaveBeenCalled(); + form.cleanup(); + }); + }); + + describe('cleanup', () => { + it('clears pending auto-clear timer', async () => { + const data = { url: 'http://test', enabled: true }; + mockApiGet.mockResolvedValueOnce(data); + mockApiPut.mockResolvedValueOnce(data); + + const form = createSettingsForm(defaultConfig); + await form.load(); + await form.save(); + expect(form.message).toBe('Settings saved'); + + form.cleanup(); + vi.advanceTimersByTime(5000); + expect(form.message).toBe('Settings saved'); + }); + }); +}); diff --git a/frontend/src/lib/utils/settingsForm.svelte.ts b/frontend/src/lib/utils/settingsForm.svelte.ts new file mode 100644 index 0000000..9f98162 --- /dev/null +++ b/frontend/src/lib/utils/settingsForm.svelte.ts @@ -0,0 +1,139 @@ +import { api, ApiError } from '$lib/api/client'; +import { isAbortError } from '$lib/utils/errorHandling'; +import { integrationStore } from '$lib/stores/integration'; + +export interface SettingsFormConfig { + loadEndpoint: string; + saveEndpoint: string; + testEndpoint?: string; + defaultValue?: T; + enabledField?: keyof T; + refreshIntegration?: boolean; + afterSave?: (data: T) => void | Promise; + afterTest?: (result: unknown) => void; +} + +export function createSettingsForm(config: SettingsFormConfig) { + let data = $state(null); + let loading = $state(true); + let saving = $state(false); + let testing = $state(false); + let message = $state(''); + let messageType = $state<'success' | 'error'>('success'); + let testResult = $state(null); + let wasAlreadyEnabled = $state(false); + let clearTimer: ReturnType | null = null; + + function clearMessage() { + message = ''; + messageType = 'success'; + } + + function showMessage(msg: string, type: 'success' | 'error' = 'success', autoClear = true) { + message = msg; + messageType = type; + if (clearTimer) clearTimeout(clearTimer); + clearTimer = null; + if (autoClear && type === 'success') { + clearTimer = setTimeout(clearMessage, 5000); + } + } + + async function refreshIntegrationStatus() { + try { + const status = await api.global.get>('/api/v1/home/integration-status'); + if (status) integrationStore.setStatus(status); + } catch { + /* sidebar will refresh on next page load */ + } + } + + async function load() { + loading = true; + message = ''; + try { + const result = await api.global.get(config.loadEndpoint); + data = result ?? config.defaultValue ?? null; + if (config.enabledField && data) { + wasAlreadyEnabled = Boolean(data[config.enabledField]); + } + } catch (e) { + if (!isAbortError(e)) { + showMessage("Couldn't load your settings", 'error', false); + } + if (config.defaultValue) data = { ...config.defaultValue }; + } finally { + loading = false; + } + } + + /** Returns true on success, false on failure. */ + async function save(): Promise { + if (!data) return false; + saving = true; + message = ''; + try { + const result = await api.global.put(config.saveEndpoint, data); + if (result) data = result; + if (config.enabledField && data) { + wasAlreadyEnabled = Boolean(data[config.enabledField]); + } + showMessage('Settings saved'); + if (config.refreshIntegration) await refreshIntegrationStatus(); + if (config.afterSave) await config.afterSave(data!); + return true; + } catch (e) { + if (!isAbortError(e)) { + const msg = e instanceof ApiError ? e.message : "Couldn't save your settings"; + showMessage(msg, 'error', false); + } + return false; + } finally { + saving = false; + } + } + + async function test() { + if (!data || !config.testEndpoint) return; + testing = true; + testResult = null; + try { + const result = await api.global.post(config.testEndpoint, data); + testResult = result; + if (config.afterTest) config.afterTest(result); + } catch (e) { + if (!isAbortError(e)) { + const msg = e instanceof ApiError ? e.message : "Couldn't test the connection"; + testResult = { success: false, valid: false, message: msg }; + } + } finally { + testing = false; + } + } + + function cleanup() { + if (clearTimer) { + clearTimeout(clearTimer); + clearTimer = null; + } + } + + return { + get data() { return data; }, + set data(v: T | null) { data = v; }, + get loading() { return loading; }, + get saving() { return saving; }, + get testing() { return testing; }, + get message() { return message; }, + get messageType() { return messageType; }, + get testResult() { return testResult; }, + set testResult(v: unknown) { testResult = v; }, + get wasAlreadyEnabled() { return wasAlreadyEnabled; }, + load, + save, + test, + showMessage, + clearMessage, + cleanup, + }; +} diff --git a/frontend/src/lib/utils/sources.ts b/frontend/src/lib/utils/sources.ts new file mode 100644 index 0000000..17f3dae --- /dev/null +++ b/frontend/src/lib/utils/sources.ts @@ -0,0 +1,17 @@ +export type SourceType = 'jellyfin' | 'local' | 'youtube' | 'navidrome'; + +export function getSourceLabel(sourceType: string): string { + if (sourceType === 'local') return 'Local'; + if (sourceType === 'jellyfin') return 'Jellyfin'; + if (sourceType === 'navidrome') return 'Navidrome'; + if (sourceType === 'youtube') return 'YouTube'; + return 'Unknown'; +} + +export function getSourceColor(sourceType: string): string { + if (sourceType === 'jellyfin') return 'rgb(var(--brand-jellyfin))'; + if (sourceType === 'navidrome') return 'rgb(var(--brand-navidrome))'; + if (sourceType === 'local') return 'rgb(var(--brand-localfiles))'; + if (sourceType === 'youtube') return 'var(--color-youtube)'; + return 'currentColor'; +} diff --git a/frontend/src/lib/utils/timeRangeFallback.spec.ts b/frontend/src/lib/utils/timeRangeFallback.spec.ts new file mode 100644 index 0000000..d2e2ce0 --- /dev/null +++ b/frontend/src/lib/utils/timeRangeFallback.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { getTimeRangeFallbackPath } from './timeRangeFallback'; +import type { HomeAlbum, HomeArtist } from '$lib/types'; + +describe('getTimeRangeFallbackPath', () => { + it('returns album search path for no-MBID album items', () => { + expect.assertions(1); + const item: HomeAlbum = { + mbid: null, + name: 'Kid A', + artist_name: 'Radiohead', + artist_mbid: null, + image_url: null, + release_date: null, + listen_count: null, + in_library: false + }; + expect(getTimeRangeFallbackPath('album', item)).toBe('/search/albums?q=Radiohead%20Kid%20A'); + }); + + it('returns artist search path for no-MBID artist items', () => { + expect.assertions(1); + const item: HomeArtist = { + mbid: null, + name: 'Massive Attack', + image_url: null, + listen_count: null, + in_library: false + }; + expect(getTimeRangeFallbackPath('artist', item)).toBe('/search/artists?q=Massive%20Attack'); + }); + + it('returns null for empty artist names', () => { + expect.assertions(1); + const item: HomeArtist = { + mbid: null, + name: ' ', + image_url: null, + listen_count: null, + in_library: false + }; + expect(getTimeRangeFallbackPath('artist', item)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/utils/timeRangeFallback.ts b/frontend/src/lib/utils/timeRangeFallback.ts new file mode 100644 index 0000000..21a1f4c --- /dev/null +++ b/frontend/src/lib/utils/timeRangeFallback.ts @@ -0,0 +1,17 @@ +import type { HomeAlbum, HomeArtist } from '$lib/types'; + +export type TimeRangeItemType = 'album' | 'artist'; + +export function getTimeRangeFallbackPath( + itemType: TimeRangeItemType, + item: HomeAlbum | HomeArtist +): string | null { + if (itemType === 'album') { + const album = item as HomeAlbum; + const query = [album.artist_name, album.name].filter(Boolean).join(' ').trim(); + return query ? `/search/albums?q=${encodeURIComponent(query)}` : null; + } + + const query = item.name?.trim(); + return query ? `/search/artists?q=${encodeURIComponent(query)}` : null; +} diff --git a/frontend/src/lib/utils/youtubeCardPlayback.ts b/frontend/src/lib/utils/youtubeCardPlayback.ts new file mode 100644 index 0000000..d7ceb63 --- /dev/null +++ b/frontend/src/lib/utils/youtubeCardPlayback.ts @@ -0,0 +1,99 @@ +import { API } from '$lib/constants'; +import { api } from '$lib/api/client'; +import { playerStore } from '$lib/stores/player.svelte'; +import { playbackToast } from '$lib/stores/playbackToast.svelte'; +import { buildQueueItemsFromYouTube, type TrackMeta } from '$lib/player/queueHelpers'; +import { openGlobalPlaylistModal } from '$lib/components/AddToPlaylistModal.svelte'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import type { QueueItem } from '$lib/player/types'; +import type { YouTubeLink, YouTubeTrackLink } from '$lib/types'; + +type FetchResult = { items: QueueItem[]; error: boolean }; + +function buildMeta(link: YouTubeLink): TrackMeta { + return { + albumId: link.album_id, + albumName: link.album_name, + artistName: link.artist_name, + coverUrl: getCoverUrl(link.cover_url, link.album_id) + }; +} + +async function fetchTrackItems(link: YouTubeLink): Promise { + try { + const tracks = await api.global.get(API.youtube.trackLinks(link.album_id)); + if (tracks.length === 0) return { items: [], error: false }; + return { items: buildQueueItemsFromYouTube(tracks, buildMeta(link)), error: false }; + } catch { + return { items: [], error: true }; + } +} + +export async function ytCardQuickPlay(link: YouTubeLink): Promise { + const { items, error } = await fetchTrackItems(link); + if (error) { + playbackToast.show("Couldn't load the track list", 'error'); + return false; + } + if (items.length === 0) { + playbackToast.show('No tracks linked yet', 'info'); + return false; + } + playerStore.playQueue(items, 0, false); + return true; +} + +export async function ytCardQuickShuffle(link: YouTubeLink): Promise { + const { items, error } = await fetchTrackItems(link); + if (error) { + playbackToast.show("Couldn't load the track list", 'error'); + return false; + } + if (items.length === 0) { + playbackToast.show('No tracks linked yet', 'info'); + return false; + } + playerStore.playQueue(items, 0, true); + return true; +} + +export async function ytCardAddToQueue(link: YouTubeLink): Promise { + const { items, error } = await fetchTrackItems(link); + if (error) { + playbackToast.show("Couldn't load the track list", 'error'); + return false; + } + if (items.length === 0) { + playbackToast.show('No tracks linked yet', 'info'); + return false; + } + playerStore.addMultipleToQueue(items); + return true; +} + +export async function ytCardPlayNext(link: YouTubeLink): Promise { + const { items, error } = await fetchTrackItems(link); + if (error) { + playbackToast.show("Couldn't load the track list", 'error'); + return false; + } + if (items.length === 0) { + playbackToast.show('No tracks linked yet', 'info'); + return false; + } + playerStore.playMultipleNext(items); + return true; +} + +export async function ytCardAddToPlaylist(link: YouTubeLink): Promise { + const { items, error } = await fetchTrackItems(link); + if (error) { + playbackToast.show("Couldn't load the track list", 'error'); + return; + } + if (items.length === 0) { + playbackToast.show('No tracks linked yet', 'info'); + return; + } + openGlobalPlaylistModal(items); +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..dfb7e01 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,559 @@ + + +
    + {#if showNavigationProgress} +
    + +
    + {/if} + + + +
    + + +
    + + +
    + {@render children()} +
    +
    + +
    + +
    + +
    +
    + + + +
    +
    + +
    +
    +
    +
    +
    + + + + + + + {#if $errorModal.show} + + + + + {/if} + + {#if playbackToast.visible} +
    +
    + {#if playbackToast.type === 'error'} + + {:else if playbackToast.type === 'warning'} + + {:else} + + {/if} + {playbackToast.message} + +
    +
    + {/if} + + {#if browser} + + {/if} + + + + +
    diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..bda58c9 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,450 @@ + + + + Home - Musicseerr + + +
    + + {#snippet title()} + + {getGreeting()} + {/snippet} + + +
    + +
    + + {#if error && !homeData} +
    + +

    {error}

    + +
    + {:else} +
    + {#if !lidarrConfigured && lidarrPrompt} +
    + +
    + +

    Welcome to Musicseerr!

    +

    + To get started, connect your Lidarr server. This is required to manage your music + library, request albums, and track your collection. +

    +
    + {#each lidarrPrompt.features as feature} + {feature} + {/each} +
    + + + Connect Lidarr + +
    +
    + {/if} + + {#if otherPrompts.length > 0 && lidarrConfigured} +
    + {#each otherPrompts as prompt} + + {/each} +
    + {/if} + + {#if loading && !homeData} +
    +
    + +
    + {:else} + {#if whatsHotBlocks.length > 0} +
    + + {#snippet icon()}{/snippet} + +
    + {#each whatsHotBlocks as block (block.key)} +
    + {#if block.kind === 'section'} + + {:else} + + {/if} +
    + {/each} +
    +
    + {/if} + + {#if forYouBlocks.length > 0} +
    + + {#snippet icon()}{/snippet} + +
    + {#each forYouBlocks as block (block.key)} +
    + {#if block.kind === 'section'} + + {:else} + + {/if} +
    + {/each} +
    +
    + {/if} + {/if} + + {#if loading && !homeData} +
    +
    +
    + {#each Array(10) as _} +
    + {/each} +
    +
    + {:else if homeData?.genre_list && homeData.genre_list.items.length > 0} +
    + +
    + {/if} + + {#if loading && !homeData} + {#each Array(4) as _} +
    +
    + +
    + {/each} + {:else if postGenreSections.length > 0} +
    + + {#snippet icon()}{/snippet} + +
    + {#each postGenreSections as { key, section, link } (key)} +
    + +
    + {/each} +
    +
    + {/if} + + {#if !loading && !hasContent && servicePrompts.length === 0} +
    + +

    Welcome to Musicseerr

    +

    + Your music library appears to be empty. Add some albums in Lidarr to get started, or + connect additional services for personalized recommendations. +

    + Settings +
    + {/if} +
    + {/if} +
    diff --git a/frontend/src/routes/album/[id]/+page.svelte b/frontend/src/routes/album/[id]/+page.svelte new file mode 100644 index 0000000..c9c6a6f --- /dev/null +++ b/frontend/src/routes/album/[id]/+page.svelte @@ -0,0 +1,216 @@ + + +
    +
    + +
    + + {#if state.error} +
    +
    + {state.error} +
    +
    + {:else if state.loadingBasic || !state.album} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + {#each Array(8) as _} +
    + {/each} +
    +
    + {:else if state.album} + {@const album = state.album} +
    + + + {#if state.loadingTracks} +
    +

    Tracks

    +
    +
      + {#each Array(8) as _} +
    • +
      +
      +
      +
      +
      +
    • + {/each} +
    +
    +
    + {:else if state.tracksInfo && state.tracksInfo.tracks.length > 0} +
    +
    +

    Tracks

    + {#if state.quota} +
    + + {state.quota.remaining}/{state.quota.limit} +
    + {/if} +
    + + + + + + +
    + {:else if state.tracksError} +
    +

    Tracks

    +
    + Couldn't load the track list. + +
    +
    + {:else} +
    +

    Tracks

    +
    + No tracks available. + +
    +
    + {/if} + + {#if album.release_date} +
    + Release Date: {album.release_date} +
    + {/if} + + {#if $integrationStore.lastfm} + + {/if} + + +
    + {:else} +
    +

    Album not found

    +
    + {/if} +
    + + + +{#if state.showDeleteModal && state.album} + { state.showDeleteModal = false; }} + /> +{/if} + +{#if state.showArtistRemovedModal} + { state.showArtistRemovedModal = false; }} + /> +{/if} diff --git a/frontend/src/routes/album/[id]/+page.ts b/frontend/src/routes/album/[id]/+page.ts new file mode 100644 index 0000000..e466770 --- /dev/null +++ b/frontend/src/routes/album/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + return { + albumId: params.id + }; +}; diff --git a/frontend/src/routes/album/[id]/AlbumDiscovery.svelte b/frontend/src/routes/album/[id]/AlbumDiscovery.svelte new file mode 100644 index 0000000..e92645e --- /dev/null +++ b/frontend/src/routes/album/[id]/AlbumDiscovery.svelte @@ -0,0 +1,31 @@ + + +
    + +
    + + diff --git a/frontend/src/routes/album/[id]/AlbumHeader.svelte b/frontend/src/routes/album/[id]/AlbumHeader.svelte new file mode 100644 index 0000000..93afcdc --- /dev/null +++ b/frontend/src/routes/album/[id]/AlbumHeader.svelte @@ -0,0 +1,184 @@ + + +
    + + +
    +
    + +
    + +
    +
    + {album.type || 'Album'} +
    + +

    + {album.title} +

    + + {#if album.disambiguation} +

    ({album.disambiguation})

    + {/if} + +
    + + + {#if album.year} + + {album.year} + {/if} + + {#if tracksInfo && tracksInfo.total_tracks > 0} + + {tracksInfo.total_tracks} {tracksInfo.total_tracks === 1 ? 'track' : 'tracks'} + {:else if loadingTracks} + + + {/if} + + {#if tracksInfo?.total_length} + + {formatTotalDuration(tracksInfo.total_length)} + {/if} +
    + +
    + {#if tracksInfo?.label} +
    + Label: {tracksInfo.label} +
    + {/if} + {#if tracksInfo?.country} +
    + Country: {tracksInfo.country} +
    + {/if} + {#if tracksInfo?.barcode} +
    + Barcode: {tracksInfo.barcode} +
    + {/if} +
    + + {#if lidarrConfigured} +
    + {#if inLibrary} +
    + + In Library +
    + + {:else if isRequested} +
    + + Requested +
    + + {:else} + + {/if} +
    + {/if} +
    +
    +
    + + diff --git a/frontend/src/routes/album/[id]/AlbumSourceBars.svelte b/frontend/src/routes/album/[id]/AlbumSourceBars.svelte new file mode 100644 index 0000000..ec81636 --- /dev/null +++ b/frontend/src/routes/album/[id]/AlbumSourceBars.svelte @@ -0,0 +1,150 @@ + + +{#if youtubeEnabled} + +{/if} + +{#if jellyfinEnabled} + {#if loadingJellyfin} +
    + {:else if jellyfinMatch?.found} + + {#snippet icon()} + + {/snippet} + + {/if} +{/if} + +{#if localfilesEnabled} + {#if loadingLocal} +
    + {:else if localMatch?.found} + + {#snippet icon()} + + {/snippet} + + {/if} +{/if} + +{#if navidromeEnabled} + {#if loadingNavidrome} +
    + {:else if navidromeMatch?.found} + + {#snippet icon()} + + {/snippet} + + {/if} +{/if} diff --git a/frontend/src/routes/album/[id]/AlbumTrackList.svelte b/frontend/src/routes/album/[id]/AlbumTrackList.svelte new file mode 100644 index 0000000..08460eb --- /dev/null +++ b/frontend/src/routes/album/[id]/AlbumTrackList.svelte @@ -0,0 +1,203 @@ + + +
    +
      + {#each renderedTrackSections as section (section.discNumber)} + {#if renderedTrackSections.length > 1} +
    • +
      + + Disc {section.discNumber} +
      +
    • + {/if} + {#each section.items as row (row.globalIndex)} + {@const track = row.track} + {@const trackDiscNumber = normalizeDiscNumber(track.disc_number)} + {@const tl = trackLinkMap.get(getDiscTrackKey(track)) ?? null} + {@const jellyfinTrack = resolveSourceTrack(trackDiscNumber, track.position, row.globalIndex, jellyfinTrackMap, jellyfinTracks)} + {@const localTrack = resolveSourceTrack(trackDiscNumber, track.position, row.globalIndex, localTrackMap, localTracks)} + {@const navidromeTrack = resolveSourceTrack(trackDiscNumber, track.position, row.globalIndex, navidromeTrackMap, navidromeTracks)} + {@const isCurrentlyPlaying = playerStore.nowPlaying?.albumId === album.musicbrainz_id && (playerStore.currentQueueItem?.discNumber ?? 1) === trackDiscNumber && playerStore.currentQueueItem?.trackNumber === track.position && playerStore.isPlaying} + {@const showJellyfinBtn = jellyfinEnabled && jellyfinMatch?.found} + {@const showLocalBtn = localfilesEnabled && localMatch?.found} + {@const showNavidromeBtn = navidromeEnabled && navidromeMatch?.found} +
    • +
      +
      + {#if isCurrentlyPlaying} + + {:else} + {track.position} + {/if} +
      + +
      +
      + {track.title} +
      +
      + +
      + {formatDuration(track.length)} +
      + + {#if youtubeEnabled || showJellyfinBtn || showLocalBtn || showNavidromeBtn} +
      + {#if youtubeEnabled} + + {/if} + + {#if showJellyfinBtn} + onPlaySourceTrack('jellyfin', track.position, trackDiscNumber, track.title)} + ariaLabel={jellyfinTrack ? 'Play on Jellyfin' : 'Not available on Jellyfin'} + > + {#snippet icon()} + + {/snippet} + + {/if} + + {#if showLocalBtn} + onPlaySourceTrack('local', track.position, trackDiscNumber, track.title)} + ariaLabel={localTrack ? 'Play local file' : 'Not available locally'} + > + {#snippet icon()} + + {/snippet} + + {/if} + + {#if showNavidromeBtn} + onPlaySourceTrack('navidrome', track.position, trackDiscNumber, track.title)} + ariaLabel={navidromeTrack ? 'Play on Navidrome' : 'Not available on Navidrome'} + > + {#snippet icon()} + + {/snippet} + + {/if} + +
      + +
      +
      + {/if} +
      +
    • + {/each} + {/each} +
    +
    diff --git a/frontend/src/routes/album/[id]/albumEventHandlers.ts b/frontend/src/routes/album/[id]/albumEventHandlers.ts new file mode 100644 index 0000000..e682e67 --- /dev/null +++ b/frontend/src/routes/album/[id]/albumEventHandlers.ts @@ -0,0 +1,115 @@ +import { goto } from '$app/navigation'; +import { artistHref } from '$lib/utils/entityRoutes'; +import type { + AlbumBasicInfo, + YouTubeTrackLink, + YouTubeLink, + YouTubeQuotaStatus +} from '$lib/types'; +import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers'; +import { requestAlbum } from '$lib/utils/albumRequest'; + +export interface EventHandlerDeps { + getAlbum: () => AlbumBasicInfo | null; + setAlbum: (a: AlbumBasicInfo | null) => void; + getAlbumId: () => string; + albumBasicCacheSet: (data: AlbumBasicInfo, key: string) => void; + setTrackLinks: (tl: YouTubeTrackLink[]) => void; + getTrackLinks: () => YouTubeTrackLink[]; + setAlbumLink: (l: YouTubeLink) => void; + setQuota: (q: YouTubeQuotaStatus) => void; + setRequesting: (v: boolean) => void; + getRequesting: () => boolean; + setShowDeleteModal: (v: boolean) => void; + setShowArtistRemovedModal: (v: boolean) => void; + setRemovedArtistName: (v: string) => void; + setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void; + setShowToast: (v: boolean) => void; +} + +export function createEventHandlers(deps: EventHandlerDeps) { + function handleTrackGenerated(link: YouTubeTrackLink): void { + const linkKey = getDiscTrackKey(link); + deps.setTrackLinks( + [...deps.getTrackLinks().filter((tl) => getDiscTrackKey(tl) !== linkKey), link].sort( + compareDiscTrack + ) + ); + } + + function handleTrackLinksUpdate(links: YouTubeTrackLink[]): void { + deps.setTrackLinks([...links].sort(compareDiscTrack)); + } + + function handleAlbumLinkUpdate(link: YouTubeLink): void { + deps.setAlbumLink(link); + } + + function handleQuotaUpdate(q: YouTubeQuotaStatus): void { + deps.setQuota(q); + } + + async function handleRequest(): Promise { + const album = deps.getAlbum(); + if (!album || deps.getRequesting()) return; + deps.setRequesting(true); + try { + const result = await requestAlbum(album.musicbrainz_id, { + artist: album.artist_name ?? undefined, + album: album.title, + year: album.year ?? undefined + }); + const current = deps.getAlbum(); + if (result.success && current) { + current.requested = true; + deps.setAlbum(current); + deps.albumBasicCacheSet(current, deps.getAlbumId()); + deps.setToast('Added to Library', 'success'); + deps.setShowToast(true); + } + } finally { + deps.setRequesting(false); + } + } + + function handleDeleteClick(): void { + deps.setShowDeleteModal(true); + } + + function handleDeleted(result: { + artist_removed: boolean; + artist_name?: string | null; + }): void { + deps.setShowDeleteModal(false); + const album = deps.getAlbum(); + if (album) { + album.in_library = false; + album.requested = false; + deps.setAlbum(album); + deps.albumBasicCacheSet(album, deps.getAlbumId()); + } + deps.setToast('Removed from Library', 'success'); + deps.setShowToast(true); + if (result.artist_removed && result.artist_name) { + deps.setRemovedArtistName(result.artist_name); + deps.setShowArtistRemovedModal(true); + } + } + + function goToArtist(): void { + const album = deps.getAlbum(); + // eslint-disable-next-line svelte/no-navigation-without-resolve -- artistHref uses resolve() internally + if (album?.artist_id) goto(artistHref(album.artist_id)); + } + + return { + handleTrackGenerated, + handleTrackLinksUpdate, + handleAlbumLinkUpdate, + handleQuotaUpdate, + handleRequest, + handleDeleteClick, + handleDeleted, + goToArtist + }; +} diff --git a/frontend/src/routes/album/[id]/albumFetchers.ts b/frontend/src/routes/album/[id]/albumFetchers.ts new file mode 100644 index 0000000..0eb9b27 --- /dev/null +++ b/frontend/src/routes/album/[id]/albumFetchers.ts @@ -0,0 +1,114 @@ +import type { + AlbumBasicInfo, + AlbumTracksInfo, + MoreByArtistResponse, + SimilarAlbumsResponse, + YouTubeLink, + YouTubeTrackLink, + JellyfinAlbumMatch, + LocalAlbumMatch, + NavidromeAlbumMatch, + LastFmAlbumEnrichment +} from '$lib/types'; +import { api } from '$lib/api/client'; +import { API } from '$lib/constants'; +import { compareDiscTrack } from '$lib/player/queueHelpers'; + +export async function fetchAlbumBasic( + albumId: string, + signal?: AbortSignal +): Promise { + return api.get(`/api/v1/albums/${albumId}/basic`, { signal }); +} + +export async function fetchAlbumTracks( + albumId: string, + signal?: AbortSignal +): Promise { + return api.get(`/api/v1/albums/${albumId}/tracks`, { signal }); +} + +export async function fetchDiscovery( + albumId: string, + artistId: string, + signal?: AbortSignal +): Promise<{ + moreByArtist: MoreByArtistResponse | null; + similarAlbums: SimilarAlbumsResponse | null; +}> { + const [moreByArtist, similarAlbums] = await Promise.all([ + api + .get( + `/api/v1/albums/${albumId}/more-by-artist?artist_id=${artistId}`, + { signal } + ) + .catch(() => null), + api + .get( + `/api/v1/albums/${albumId}/similar?artist_id=${artistId}`, + { signal } + ) + .catch(() => null) + ]); + return { moreByArtist, similarAlbums }; +} + +export async function fetchYouTubeAlbumLink( + albumId: string, + signal?: AbortSignal +): Promise { + return api.get(API.youtube.link(albumId), { signal }).catch(() => null); +} + +export async function fetchYouTubeTrackLinks( + albumId: string, + signal?: AbortSignal +): Promise { + const data = await api + .get(API.youtube.trackLinks(albumId), { signal }) + .catch(() => null); + return data ? data.sort(compareDiscTrack) : []; +} + +export async function fetchJellyfinMatch( + albumId: string, + signal?: AbortSignal +): Promise { + return api.get(API.jellyfinLibrary.albumMatch(albumId), { signal }); +} + +export async function fetchLocalMatch( + albumId: string, + signal?: AbortSignal +): Promise { + return api.get(API.local.albumMatch(albumId), { signal }); +} + +export async function fetchNavidromeMatch( + albumId: string, + opts: { albumTitle?: string; artistName?: string }, + signal?: AbortSignal +): Promise { + const matchUrl = new URL( + API.navidromeLibrary.albumMatch(albumId), + window.location.origin + ); + if (opts.albumTitle) matchUrl.searchParams.set('name', opts.albumTitle); + if (opts.artistName) matchUrl.searchParams.set('artist', opts.artistName); + return api.get(matchUrl.toString(), { signal }); +} + +export async function fetchLastFm( + albumId: string, + opts: { artistName: string; albumName: string }, + signal?: AbortSignal +): Promise { + const params = new URLSearchParams({ + artist_name: opts.artistName, + album_name: opts.albumName + }); + return api.get( + `/api/v1/albums/${albumId}/lastfm?${params.toString()}`, + { signal } + ); +} diff --git a/frontend/src/routes/album/[id]/albumPageState.svelte.ts b/frontend/src/routes/album/[id]/albumPageState.svelte.ts new file mode 100644 index 0000000..60fae40 --- /dev/null +++ b/frontend/src/routes/album/[id]/albumPageState.svelte.ts @@ -0,0 +1,429 @@ +import { browser } from '$app/environment'; +import { get } from 'svelte/store'; +import { untrack } from 'svelte'; +import type { + AlbumBasicInfo, + AlbumTracksInfo, + MoreByArtistResponse, + SimilarAlbumsResponse, + YouTubeTrackLink, + YouTubeLink, + YouTubeQuotaStatus, + JellyfinAlbumMatch, + JellyfinTrackInfo, + LocalAlbumMatch, + LocalTrackInfo, + NavidromeAlbumMatch, + NavidromeTrackInfo, + LastFmAlbumEnrichment +} from '$lib/types'; +import { libraryStore } from '$lib/stores/library'; +import { integrationStore } from '$lib/stores/integration'; +import { isAbortError } from '$lib/utils/errorHandling'; +import { extractServiceStatus } from '$lib/utils/serviceStatus'; +import { + albumBasicCache, + albumDiscoveryCache, + albumLastFmCache, + albumTracksCache, + albumYouTubeCache, + albumSourceMatchCache +} from '$lib/utils/albumDetailCache'; +import { hydrateDetailCacheEntry } from '$lib/utils/detailCacheHydration'; +import { + compareDiscTrack, + getDiscTrackKey +} from '$lib/player/queueHelpers'; +import type { QueueItem } from '$lib/player/types'; +import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback'; +import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; +import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback'; +import type { MenuItem } from '$lib/components/ContextMenu.svelte'; +import { + fetchAlbumBasic, + fetchAlbumTracks, + fetchDiscovery, + fetchYouTubeAlbumLink, + fetchYouTubeTrackLinks, + fetchJellyfinMatch, + fetchLocalMatch, + fetchNavidromeMatch, + fetchLastFm +} from './albumFetchers'; +import { buildRenderedTrackSections, buildSortedTrackMap } from './albumTrackResolvers'; +import type { RenderedTrackSection } from './albumTrackResolvers'; +import { createEventHandlers } from './albumEventHandlers'; +import { + playSourceTrack as playSourceTrackImpl, + getTrackContextMenuItems as getTrackContextMenuItemsImpl, + buildSourceCallbacks +} from './albumPlaybackHandlers'; + +export interface SourceCallbacks { + onPlayAll: () => void; + onShuffle: () => void; + onAddAllToQueue: () => void; + onPlayAllNext: () => void; + onAddAllToPlaylist: () => void; +} + +export function createAlbumPageState(albumIdGetter: () => string) { + let album = $state(null); + let tracksInfo = $state(null); + let error = $state(null); + let loadingBasic = $state(true); + let loadingTracks = $state(true); + let tracksError = $state(false); + let showToast = $state(false); + let toastMessage = $state('Added to Library'); + let toastType = $state<'success' | 'error' | 'info' | 'warning'>('success'); + let requesting = $state(false); + let showDeleteModal = $state(false); + let showArtistRemovedModal = $state(false); + let removedArtistName = $state(''); + let moreByArtist = $state(null); + let similarAlbums = $state(null); + let loadingDiscovery = $state(true); + let trackLinks = $state([]); + let albumLink = $state(null); + let quota = $state(null); + let jellyfinMatch = $state(null); + let localMatch = $state(null); + let navidromeMatch = $state(null); + let loadingJellyfin = $state(false); + let loadingLocal = $state(false); + let loadingNavidrome = $state(false); + let lastfmEnrichment = $state(null); + let loadingLastfm = $state(true); + let renderedTrackSections = $state([]); + let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null); + let abortController: AbortController | null = null; + + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- derived Map is recreated each time, reactive by nature + const trackLinkMap = $derived(new Map(trackLinks.map((tl) => [getDiscTrackKey(tl), tl]))); + const jellyfinTracks = $derived([...(jellyfinMatch?.tracks ?? [])].sort(compareDiscTrack)); + const localTracks = $derived([...(localMatch?.tracks ?? [])].sort(compareDiscTrack)); + const navidromeTracks = $derived([...(navidromeMatch?.tracks ?? [])].sort(compareDiscTrack)); + const jellyfinTrackMap = $derived(buildSortedTrackMap(jellyfinMatch?.tracks ?? [])); + const localTrackMap = $derived(buildSortedTrackMap(localMatch?.tracks ?? [])); + const navidromeTrackMap = $derived(buildSortedTrackMap(navidromeMatch?.tracks ?? [])); + const inLibrary = $derived( + libraryStore.isInLibrary(album?.musicbrainz_id) || album?.in_library || false + ); + const isRequested = $derived( + !!(album && !inLibrary && (album.requested || libraryStore.isRequested(album.musicbrainz_id))) + ); + + function resetState() { + if (abortController) { abortController.abort(); abortController = null; } + album = null; tracksInfo = null; renderedTrackSections = []; error = null; + loadingBasic = true; loadingTracks = true; tracksError = false; + loadingDiscovery = true; moreByArtist = null; similarAlbums = null; + trackLinks = []; albumLink = null; quota = null; + jellyfinMatch = null; localMatch = null; navidromeMatch = null; + loadingJellyfin = false; loadingLocal = false; loadingNavidrome = false; + lastfmEnrichment = null; loadingLastfm = true; + } + + function hydrateFromCache(albumId: string) { + const refreshBasic = hydrateDetailCacheEntry({ + cache: albumBasicCache, + cacheKey: albumId, + onHydrate: (cached) => { + album = cached; + loadingBasic = false; + } + }); + const refreshTracks = hydrateDetailCacheEntry({ + cache: albumTracksCache, + cacheKey: albumId, + onHydrate: (cached) => { + tracksInfo = cached; + renderedTrackSections = buildRenderedTrackSections(cached.tracks); + loadingTracks = false; + } + }); + const refreshDiscovery = hydrateDetailCacheEntry({ + cache: albumDiscoveryCache, + cacheKey: albumId, + onHydrate: (cached) => { + moreByArtist = cached.moreByArtist; + similarAlbums = cached.similarAlbums; + loadingDiscovery = false; + } + }); + const refreshLastfm = hydrateDetailCacheEntry({ + cache: albumLastFmCache, + cacheKey: albumId, + onHydrate: (cached) => { + lastfmEnrichment = cached; + loadingLastfm = false; + } + }); + const refreshSourceMatch = (() => { + const cached = albumSourceMatchCache.get(albumId); + if (cached && !albumSourceMatchCache.isStale(cached.timestamp)) { + jellyfinMatch = cached.data.jellyfin; + localMatch = cached.data.local; + navidromeMatch = cached.data.navidrome; + loadingJellyfin = false; loadingLocal = false; loadingNavidrome = false; + return false; + } + return true; + })(); + return { refreshBasic, refreshTracks, refreshDiscovery, refreshLastfm, refreshSourceMatch }; + } + + async function doFetchBasic(albumId: string, signal: AbortSignal) { + try { + const result = await fetchAlbumBasic(albumId, signal); + if (result) { album = result; extractServiceStatus(album); albumBasicCache.set(album, albumId); } + } catch (e) { + if (isAbortError(e)) return; + if (!album) error = 'Error loading album'; + } finally { if (!signal.aborted) loadingBasic = false; } + } + + async function doFetchTracks(albumId: string, signal: AbortSignal) { + tracksError = false; + try { + const result = await fetchAlbumTracks(albumId, signal); + if (result) { tracksInfo = result; renderedTrackSections = buildRenderedTrackSections(result.tracks); albumTracksCache.set(result, albumId); } + } catch (e) { + if (isAbortError(e)) return; + if (!tracksInfo) tracksError = true; + } + if (!signal.aborted) loadingTracks = false; + } + + async function doFetchDiscovery(albumId: string, signal: AbortSignal) { + if (!album?.artist_id) { loadingDiscovery = false; return; } + loadingDiscovery = true; + try { + const result = await fetchDiscovery(albumId, album.artist_id, signal); + if (result.moreByArtist) moreByArtist = result.moreByArtist; + if (result.similarAlbums) similarAlbums = result.similarAlbums; + albumDiscoveryCache.set({ moreByArtist, similarAlbums }, albumId); + } catch (e) { if (isAbortError(e)) return; } + finally { if (!signal.aborted) loadingDiscovery = false; } + } + + async function doFetchYouTube(albumId: string, signal: AbortSignal) { + const cached = albumYouTubeCache.get(albumId); + if (cached && !albumYouTubeCache.isStale(cached.timestamp)) { + albumLink = cached.data.albumLink; + trackLinks = cached.data.trackLinks; + return; + } + try { + const [linkData, tracksData] = await Promise.all([ + fetchYouTubeAlbumLink(albumId, signal), fetchYouTubeTrackLinks(albumId, signal) + ]); + if (linkData) albumLink = linkData; + if (tracksData) trackLinks = tracksData; + albumYouTubeCache.set({ albumLink: linkData, trackLinks: tracksData ?? [] }, albumId); + } catch (e) { if (isAbortError(e)) return; } + } + + async function doFetchSourceMatch( + signal: AbortSignal, fetcher: () => Promise, + setter: (v: T | null) => void, loadingSetter: (v: boolean) => void, label: string, + albumId: string, cacheField: 'jellyfin' | 'local' | 'navidrome' + ) { + loadingSetter(true); + try { + const result = await fetcher(); + setter(result); + const existing = albumSourceMatchCache.get(albumId)?.data ?? { jellyfin: null, local: null, navidrome: null }; + albumSourceMatchCache.set({ ...existing, [cacheField]: result }, albumId); + } + catch (e) { if (isAbortError(e)) return; console.error(`Failed to fetch ${label} album data:`, e); } + finally { if (!signal.aborted) loadingSetter(false); } + } + + async function doFetchLastFm(albumId: string, signal: AbortSignal) { + if (!album) { loadingLastfm = false; return; } + await integrationStore.ensureLoaded(); + if (!get(integrationStore).lastfm) { loadingLastfm = false; return; } + loadingLastfm = true; + try { + const result = await fetchLastFm(albumId, { artistName: album.artist_name, albumName: album.title }, signal); + if (result) { lastfmEnrichment = result; albumLastFmCache.set(result, albumId); } + } catch (e) { if (isAbortError(e)) return; console.error('Failed to fetch Last.fm album data:', e); } + finally { if (!signal.aborted) loadingLastfm = false; } + } + + async function loadAlbum(albumId: string) { + const { refreshBasic, refreshTracks, refreshDiscovery, refreshLastfm, refreshSourceMatch } = hydrateFromCache(albumId); + if (abortController) abortController.abort(); + abortController = new AbortController(); + const signal = abortController.signal; + + // Fire source matches that only need albumId immediately (before basic loads) + if (refreshSourceMatch) { + void (async () => { + try { + await integrationStore.ensureLoaded(); + if (signal.aborted) return; + const integrations = get(integrationStore); + if (integrations.jellyfin) + void doFetchSourceMatch(signal, () => fetchJellyfinMatch(albumId, signal), + (v) => (jellyfinMatch = v), (v) => (loadingJellyfin = v), 'Jellyfin', albumId, 'jellyfin'); + if (integrations.localfiles) + void doFetchSourceMatch(signal, () => fetchLocalMatch(albumId, signal), + (v) => (localMatch = v), (v) => (loadingLocal = v), 'local', albumId, 'local'); + } catch { /* ignore integration loading errors */ } + })(); + } + + if (refreshBasic) { + if (refreshTracks) void doFetchTracks(albumId, signal); + void doFetchYouTube(albumId, signal); + await doFetchBasic(albumId, signal); + } else { void doFetchBasic(albumId, signal); } + if (signal.aborted || !album) return; + if (refreshTracks && !refreshBasic) void doFetchTracks(albumId, signal); + if (refreshDiscovery) void doFetchDiscovery(albumId, signal); + if (!refreshBasic) void doFetchYouTube(albumId, signal); + if (refreshLastfm) void doFetchLastFm(albumId, signal); + // Navidrome match needs album title/artist — fire after basic loads + if (refreshSourceMatch) { + void (async () => { + try { + await integrationStore.ensureLoaded(); + if (signal.aborted) return; + const integrations = get(integrationStore); + if (integrations.navidrome) + void doFetchSourceMatch(signal, + () => fetchNavidromeMatch(albumId, { albumTitle: album?.title, artistName: album?.artist_name }, signal), + (v) => (navidromeMatch = v), (v) => (loadingNavidrome = v), 'Navidrome', albumId, 'navidrome'); + } catch { /* ignore integration loading errors */ } + })(); + } + } + + $effect(() => { + const albumId = albumIdGetter(); + if (!browser || !albumId) return; + untrack(() => { resetState(); void loadAlbum(albumId); }); + return () => { if (abortController) { abortController.abort(); abortController = null; } }; + }); + + const eventHandlers = createEventHandlers({ + getAlbum: () => album, + setAlbum: (a) => (album = a), + getAlbumId: albumIdGetter, + albumBasicCacheSet: (data, key) => albumBasicCache.set(data, key), + setTrackLinks: (tl) => (trackLinks = tl), + getTrackLinks: () => trackLinks, + setAlbumLink: (l) => (albumLink = l), + setQuota: (q) => (quota = q), + setRequesting: (v) => (requesting = v), + getRequesting: () => requesting, + setShowDeleteModal: (v) => (showDeleteModal = v), + setShowArtistRemovedModal: (v) => (showArtistRemovedModal = v), + setRemovedArtistName: (v) => (removedArtistName = v), + setToast: (msg, type) => { toastMessage = msg; toastType = type; }, + setShowToast: (v) => (showToast = v) + }); + + function retryTracks(): void { + loadingTracks = true; + tracksError = false; + const signal = abortController?.signal ?? new AbortController().signal; + void doFetchTracks(albumIdGetter(), signal); + } + + const tracksGetters = { + jellyfin: () => jellyfinTracks, + local: () => localTracks, + navidrome: () => navidromeTracks + }; + const albumGetter = () => album; + const playlistRefGetter = () => playlistModalRef; + + function playSourceTrack( + source: 'jellyfin' | 'local' | 'navidrome', + trackPosition: number, + discNumber: number, + title: string + ): void { + if (!album) return; + playSourceTrackImpl(source, trackPosition, discNumber, title, album, jellyfinMatch, localMatch, navidromeMatch); + } + + function getTrackContextMenuItems( + track: { position: number; disc_number?: number | null; title: string }, + resolvedLocal: LocalTrackInfo | null, + resolvedJellyfin: JellyfinTrackInfo | null, + resolvedNavidrome: NavidromeTrackInfo | null = null + ): MenuItem[] { + if (!album) return []; + return getTrackContextMenuItemsImpl(track, album, resolvedLocal, resolvedJellyfin, resolvedNavidrome, playlistModalRef); + } + + const jellyfinCallbacks: SourceCallbacks = buildSourceCallbacks( + () => jellyfinMatch, launchJellyfinPlayback, 'jellyfin', + albumGetter, tracksGetters, playlistRefGetter + ); + const localCallbacks: SourceCallbacks = buildSourceCallbacks( + () => localMatch, launchLocalPlayback, 'local', + albumGetter, tracksGetters, playlistRefGetter + ); + const navidromeCallbacks: SourceCallbacks = buildSourceCallbacks( + () => navidromeMatch, launchNavidromePlayback, 'navidrome', + albumGetter, tracksGetters, playlistRefGetter + ); + + return { + get album() { return album; }, + get tracksInfo() { return tracksInfo; }, + get error() { return error; }, + get loadingBasic() { return loadingBasic; }, + get loadingTracks() { return loadingTracks; }, + get tracksError() { return tracksError; }, + get showToast() { return showToast; }, + set showToast(v: boolean) { showToast = v; }, + get toastMessage() { return toastMessage; }, + get toastType() { return toastType; }, + get requesting() { return requesting; }, + get showDeleteModal() { return showDeleteModal; }, + set showDeleteModal(v: boolean) { showDeleteModal = v; }, + get showArtistRemovedModal() { return showArtistRemovedModal; }, + set showArtistRemovedModal(v: boolean) { showArtistRemovedModal = v; }, + get removedArtistName() { return removedArtistName; }, + get moreByArtist() { return moreByArtist; }, + get similarAlbums() { return similarAlbums; }, + get loadingDiscovery() { return loadingDiscovery; }, + get trackLinks() { return trackLinks; }, + get albumLink() { return albumLink; }, + get quota() { return quota; }, + get jellyfinMatch() { return jellyfinMatch; }, + get localMatch() { return localMatch; }, + get navidromeMatch() { return navidromeMatch; }, + get loadingJellyfin() { return loadingJellyfin; }, + get loadingLocal() { return loadingLocal; }, + get loadingNavidrome() { return loadingNavidrome; }, + get lastfmEnrichment() { return lastfmEnrichment; }, + get loadingLastfm() { return loadingLastfm; }, + get renderedTrackSections() { return renderedTrackSections; }, + get trackLinkMap() { return trackLinkMap; }, + get jellyfinTracks() { return jellyfinTracks; }, + get localTracks() { return localTracks; }, + get navidromeTracks() { return navidromeTracks; }, + get jellyfinTrackMap() { return jellyfinTrackMap; }, + get localTrackMap() { return localTrackMap; }, + get navidromeTrackMap() { return navidromeTrackMap; }, + get inLibrary() { return inLibrary; }, + get isRequested() { return isRequested; }, + get playlistModalRef() { return playlistModalRef; }, + set playlistModalRef(v) { playlistModalRef = v; }, + jellyfinCallbacks, + localCallbacks, + navidromeCallbacks, + ...eventHandlers, + retryTracks, + playSourceTrack, + getTrackContextMenuItems + }; +} diff --git a/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts b/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts new file mode 100644 index 0000000..5f03d61 --- /dev/null +++ b/frontend/src/routes/album/[id]/albumPlaybackHandlers.ts @@ -0,0 +1,219 @@ +import type { + AlbumBasicInfo, + JellyfinAlbumMatch, + JellyfinTrackInfo, + LocalAlbumMatch, + LocalTrackInfo, + NavidromeAlbumMatch, + NavidromeTrackInfo +} from '$lib/types'; +import type { QueueItem, PlaybackMeta } from '$lib/player/types'; +import type { TrackMeta, TrackSourceData } from '$lib/player/queueHelpers'; +import { + buildQueueItem, + buildQueueItemsFromJellyfin, + buildQueueItemsFromLocal, + buildQueueItemsFromNavidrome, + compareDiscTrack, + normalizeDiscNumber +} from '$lib/player/queueHelpers'; +import { getCoverUrl } from '$lib/utils/errorHandling'; +import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback'; +import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; +import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback'; +import { playerStore } from '$lib/stores/player.svelte'; +import type { MenuItem } from '$lib/components/ContextMenu.svelte'; +import { ListPlus, ListStart, ListMusic } from 'lucide-svelte'; +import type { SourceCallbacks } from './albumPageState.svelte'; + +export function getPlaybackMeta(album: AlbumBasicInfo): PlaybackMeta { + return { + albumId: album.musicbrainz_id, + albumName: album.title, + artistName: album.artist_name, + coverUrl: getCoverUrl(album.cover_url ?? null, album.musicbrainz_id), + artistId: album.artist_id + }; +} + +export function getTrackMeta(album: AlbumBasicInfo): TrackMeta { + return { + albumId: album.musicbrainz_id, + albumName: album.title, + artistName: album.artist_name, + coverUrl: album.cover_url ?? null, + artistId: album.artist_id + }; +} + +export function playSource< + T extends { track_number: number; disc_number?: number | null; title: string } +>( + match: { tracks: T[] } | null, + launcher: (tracks: T[], startIndex: number, shuffle: boolean, meta: PlaybackMeta) => void, + album: AlbumBasicInfo, + opts: { + startTrack?: number; + startDisc?: number; + startTitle?: string; + shuffle?: boolean; + } = {} +): void { + if (!match?.tracks.length) return; + const orderedTracks = [...match.tracks].sort(compareDiscTrack); + let idx = 0; + if (opts.startTrack !== undefined) { + idx = orderedTracks.findIndex( + (t) => + t.track_number === opts.startTrack && + normalizeDiscNumber(t.disc_number) === normalizeDiscNumber(opts.startDisc) + ); + if (idx === -1 && opts.startTitle) { + const lower = opts.startTitle.toLowerCase(); + const titleMatcher = (t: T) => { + const tLower = t.title.toLowerCase(); + return tLower === lower || tLower.includes(lower) || lower.includes(tLower); + }; + if (opts.startDisc !== undefined) { + const targetDisc = normalizeDiscNumber(opts.startDisc); + idx = orderedTracks.findIndex( + (t) => normalizeDiscNumber(t.disc_number) === targetDisc && titleMatcher(t) + ); + } + if (idx === -1) idx = orderedTracks.findIndex(titleMatcher); + } + if (idx === -1) return; + } + launcher(orderedTracks, idx, opts.shuffle ?? false, getPlaybackMeta(album)); +} + +export function playSourceTrack( + source: 'jellyfin' | 'local' | 'navidrome', + trackPosition: number, + discNumber: number, + title: string, + album: AlbumBasicInfo, + jellyfinMatch: JellyfinAlbumMatch | null, + localMatch: LocalAlbumMatch | null, + navidromeMatch: NavidromeAlbumMatch | null +): void { + const opts = { startTrack: trackPosition, startDisc: discNumber, startTitle: title }; + if (source === 'jellyfin') playSource(jellyfinMatch, launchJellyfinPlayback, album, opts); + else if (source === 'local') playSource(localMatch, launchLocalPlayback, album, opts); + else playSource(navidromeMatch, launchNavidromePlayback, album, opts); +} + +export function buildTrackQueueItem( + track: { position: number; disc_number?: number | null; title: string }, + album: AlbumBasicInfo, + resolvedLocal: LocalTrackInfo | null, + resolvedJellyfin: JellyfinTrackInfo | null, + resolvedNavidrome: NavidromeTrackInfo | null = null +): QueueItem | null { + const sourceData: TrackSourceData = { + trackPosition: track.position, + discNumber: normalizeDiscNumber(track.disc_number), + trackTitle: track.title, + trackLength: + resolvedLocal?.duration_seconds ?? + resolvedNavidrome?.duration_seconds ?? + resolvedJellyfin?.duration_seconds ?? + undefined, + localTrack: resolvedLocal, + navidromeTrack: resolvedNavidrome, + jellyfinTrack: resolvedJellyfin + }; + return buildQueueItem(getTrackMeta(album), sourceData); +} + +export function getTrackContextMenuItems( + track: { position: number; disc_number?: number | null; title: string }, + album: AlbumBasicInfo, + resolvedLocal: LocalTrackInfo | null, + resolvedJellyfin: JellyfinTrackInfo | null, + resolvedNavidrome: NavidromeTrackInfo | null, + playlistModalRef: { open: (tracks: QueueItem[]) => void } | null +): MenuItem[] { + const queueItem = buildTrackQueueItem(track, album, resolvedLocal, resolvedJellyfin, resolvedNavidrome); + const hasSource = queueItem !== null; + return [ + { + label: 'Add to Queue', + icon: ListPlus, + onclick: () => { if (queueItem) playerStore.addToQueue(queueItem); }, + disabled: !hasSource + }, + { + label: 'Play Next', + icon: ListStart, + onclick: () => { if (queueItem) playerStore.playNext(queueItem); }, + disabled: !hasSource + }, + { + label: 'Add to Playlist', + icon: ListMusic, + onclick: () => { + if (queueItem) playlistModalRef?.open([queueItem]); + }, + disabled: !hasSource + } + ]; +} + +function getSourceQueueItems( + source: 'jellyfin' | 'local' | 'navidrome', + album: AlbumBasicInfo, + jellyfinTracks: JellyfinTrackInfo[], + localTracks: LocalTrackInfo[], + navidromeTracks: NavidromeTrackInfo[] +): QueueItem[] { + const meta = getTrackMeta(album); + if (source === 'jellyfin') + return buildQueueItemsFromJellyfin([...jellyfinTracks].sort(compareDiscTrack), meta); + if (source === 'navidrome') + return buildQueueItemsFromNavidrome([...navidromeTracks].sort(compareDiscTrack), meta); + return buildQueueItemsFromLocal([...localTracks].sort(compareDiscTrack), meta); +} + +export function buildSourceCallbacks( + matchGetter: () => JellyfinAlbumMatch | LocalAlbumMatch | NavidromeAlbumMatch | null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- launcher generics vary by source + launcher: (tracks: any[], startIndex: number, shuffle: boolean, meta: PlaybackMeta) => void, + source: 'jellyfin' | 'local' | 'navidrome', + albumGetter: () => AlbumBasicInfo | null, + tracksGetters: { + jellyfin: () => JellyfinTrackInfo[]; + local: () => LocalTrackInfo[]; + navidrome: () => NavidromeTrackInfo[]; + }, + playlistModalRefGetter: () => { open: (tracks: QueueItem[]) => void } | null +): SourceCallbacks { + return { + onPlayAll: () => { + const a = albumGetter(); + if (a) playSource(matchGetter(), launcher, a); + }, + onShuffle: () => { + const a = albumGetter(); + if (a) playSource(matchGetter(), launcher, a, { shuffle: true }); + }, + onAddAllToQueue: () => { + const a = albumGetter(); + if (!a) return; + const items = getSourceQueueItems(source, a, tracksGetters.jellyfin(), tracksGetters.local(), tracksGetters.navidrome()); + if (items.length > 0) playerStore.addMultipleToQueue(items); + }, + onPlayAllNext: () => { + const a = albumGetter(); + if (!a) return; + const items = getSourceQueueItems(source, a, tracksGetters.jellyfin(), tracksGetters.local(), tracksGetters.navidrome()); + if (items.length > 0) playerStore.playMultipleNext(items); + }, + onAddAllToPlaylist: () => { + const a = albumGetter(); + if (!a) return; + const items = getSourceQueueItems(source, a, tracksGetters.jellyfin(), tracksGetters.local(), tracksGetters.navidrome()); + if (items.length > 0) playlistModalRefGetter()?.open(items); + } + }; +} diff --git a/frontend/src/routes/album/[id]/albumTrackResolvers.ts b/frontend/src/routes/album/[id]/albumTrackResolvers.ts new file mode 100644 index 0000000..d34796d --- /dev/null +++ b/frontend/src/routes/album/[id]/albumTrackResolvers.ts @@ -0,0 +1,73 @@ +import type { AlbumTracksInfo } from '$lib/types'; +import { getDiscTrackKey, normalizeDiscNumber, compareDiscTrack } from '$lib/player/queueHelpers'; + +export type RenderedTrackSection = { + discNumber: number; + items: Array<{ track: AlbumTracksInfo['tracks'][number]; globalIndex: number }>; +}; + +export function buildSortedTrackMap< + T extends { track_number: number; disc_number?: number | null } +>(tracks: T[]): Map { + return new Map( + [...tracks] + .sort(compareDiscTrack) + .filter((t) => Number.isFinite(Number(t.track_number)) && Number(t.track_number) > 0) + .map((t) => [getDiscTrackKey(t), t] as const) + ); +} + +export function resolveSourceTrack< + T extends { track_number: number; disc_number?: number | null } +>( + discNumber: number | undefined, + position: number, + rowIndex: number, + trackMap: Map, + tracks: T[] +): T | null { + const trackKey = getDiscTrackKey({ disc_number: discNumber, track_number: position }); + if (!trackKey.endsWith(':0')) { + const byNumber = trackMap.get(trackKey); + if (byNumber) return byNumber; + } + + const numberingIsUnusable = tracks.length > 0 && trackMap.size < tracks.length; + if (numberingIsUnusable && rowIndex >= 0 && rowIndex < tracks.length) { + return tracks[rowIndex] ?? null; + } + + return null; +} + +export function buildRenderedTrackSections( + tracks: AlbumTracksInfo['tracks'] +): RenderedTrackSection[] { + const grouped = new Map< + number, + Array<{ track: AlbumTracksInfo['tracks'][number]; globalIndex: number }> + >(); + tracks.forEach((track) => { + const discNumber = normalizeDiscNumber(track.disc_number); + const entry = { track, globalIndex: 0 }; + const existing = grouped.get(discNumber); + if (existing) { + existing.push(entry); + } else { + grouped.set(discNumber, [entry]); + } + }); + const sections = Array.from(grouped.entries()) + .sort(([a], [b]) => a - b) + .map(([discNumber, items]) => ({ + discNumber, + items: items.sort((a, b) => Number(a.track.position) - Number(b.track.position)) + })); + let sortedIdx = 0; + for (const section of sections) { + for (const item of section.items) { + item.globalIndex = sortedIdx++; + } + } + return sections; +} diff --git a/frontend/src/routes/album/[id]/page.svelte.spec.ts b/frontend/src/routes/album/[id]/page.svelte.spec.ts new file mode 100644 index 0000000..74f8f56 --- /dev/null +++ b/frontend/src/routes/album/[id]/page.svelte.spec.ts @@ -0,0 +1,378 @@ +import { page } from '@vitest/browser/context'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +const { + mockGoto, + mockPageFetch, + mockHydrateDetailCacheEntry, + mockAlbumBasicCache, + mockAlbumTracksCache, + mockAlbumDiscoveryCache, + mockAlbumLastFmCache, + mockAlbumYouTubeCache, + mockAlbumSourceMatchCache +} = vi.hoisted(() => ({ + mockGoto: vi.fn(), + mockPageFetch: vi.fn(), + mockHydrateDetailCacheEntry: vi.fn(), + mockAlbumBasicCache: { set: vi.fn() }, + mockAlbumTracksCache: { set: vi.fn() }, + mockAlbumDiscoveryCache: { set: vi.fn() }, + mockAlbumLastFmCache: { set: vi.fn() }, + mockAlbumYouTubeCache: { get: vi.fn(), set: vi.fn() }, + mockAlbumSourceMatchCache: { get: vi.fn(), set: vi.fn() } +})); + +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args) +})); + +vi.mock('$lib/stores/library', () => ({ + libraryStore: { + isInLibrary: vi.fn(() => false), + isRequested: vi.fn(() => false) + } +})); + +const integrationState = { + youtube: false, + youtube_api: false, + jellyfin: false, + localfiles: true, + navidrome: true, + lastfm: false, + lidarr: true +}; + +vi.mock('$lib/stores/integration', () => ({ + integrationStore: { + subscribe: vi.fn((cb: (value: unknown) => void) => { + cb(integrationState); + return () => {}; + }), + ensureLoaded: vi.fn().mockResolvedValue(undefined) + } +})); + +vi.mock('$lib/stores/player.svelte', () => ({ + playerStore: { + isPlaying: false, + nowPlaying: null, + currentQueueItem: null, + addToQueue: vi.fn(), + playNext: vi.fn(), + playMultipleNext: vi.fn(), + addMultipleToQueue: vi.fn() + } +})); + +vi.mock('$lib/utils/navigationAbort', () => ({ + pageFetch: (...args: unknown[]) => mockPageFetch(...args) +})); + +vi.mock('$lib/utils/detailCacheHydration', () => ({ + hydrateDetailCacheEntry: (...args: unknown[]) => mockHydrateDetailCacheEntry(...args) +})); + +vi.mock('$lib/utils/albumDetailCache', () => ({ + albumBasicCache: mockAlbumBasicCache, + albumTracksCache: mockAlbumTracksCache, + albumDiscoveryCache: mockAlbumDiscoveryCache, + albumLastFmCache: mockAlbumLastFmCache, + albumYouTubeCache: mockAlbumYouTubeCache, + albumSourceMatchCache: mockAlbumSourceMatchCache +})); + +vi.mock('$lib/utils/serviceStatus', () => ({ + extractServiceStatus: vi.fn() +})); + +vi.mock('$lib/utils/albumRequest', () => ({ + requestAlbum: vi.fn().mockResolvedValue({ success: true }) +})); + +vi.mock('$lib/components/AlbumImage.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/Toast.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/DiscoveryAlbumCarousel.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/LastFmAlbumEnrichment.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/ContextMenu.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/AddToPlaylistModal.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/BackButton.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/TrackPlayButton.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/JellyfinIcon.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/LocalFilesIcon.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/NavidromeIcon.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/DeleteAlbumModal.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/ArtistRemovedModal.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/NowPlayingIndicator.svelte', () => { + const Comp = function () {}; + Comp.prototype = {}; + return { default: Comp }; +}); + +vi.mock('$lib/player/launchJellyfinPlayback', () => ({ launchJellyfinPlayback: vi.fn() })); +vi.mock('$lib/player/launchLocalPlayback', () => ({ launchLocalPlayback: vi.fn() })); +vi.mock('$lib/player/launchNavidromePlayback', () => ({ launchNavidromePlayback: vi.fn() })); + +import AlbumPage from './+page.svelte'; + +const albumId = '3f3a6d95-326e-4384-80b0-0744f20f24ff'; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' } + }); +} + +describe('album detail page track rendering', () => { + beforeEach(() => { + mockGoto.mockReset(); + mockPageFetch.mockReset(); + mockHydrateDetailCacheEntry.mockReset(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test mock with generic cache type + mockHydrateDetailCacheEntry.mockImplementation(({ cache, onHydrate }: any) => { + if (cache === mockAlbumBasicCache) { + onHydrate({ + title: 'Visions', + musicbrainz_id: albumId, + artist_name: 'Grimes', + artist_id: 'artist-1', + in_library: true, + requested: false, + cover_url: null + }); + return false; + } + + if (cache === mockAlbumTracksCache) { + onHydrate({ + tracks: [ + { position: 1, disc_number: 1, title: 'Infinite ❤️ Without Fulfillment', length: 95327 }, + { position: 5, disc_number: 1, title: 'Circumambient', length: 223280 }, + { position: 1, disc_number: 2, title: 'Ambrosia', length: 213093 }, + { position: 5, disc_number: 2, title: 'Be a Body (Baarsden rework)', length: 204626 } + ], + total_tracks: 4, + total_length: 736326, + label: null, + barcode: null, + country: null + }); + return false; + } + + if (cache === mockAlbumDiscoveryCache) { + return true; + } + + if (cache === mockAlbumLastFmCache) { + return true; + } + + return true; + }); + mockPageFetch.mockImplementation((input: string | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.endsWith(`/api/v1/albums/${albumId}/basic`)) { + return Promise.resolve( + jsonResponse({ + title: 'Visions', + musicbrainz_id: albumId, + artist_name: 'Grimes', + artist_id: 'artist-1', + in_library: true, + requested: false, + cover_url: null + }) + ); + } + + if (url.endsWith(`/api/v1/albums/${albumId}/tracks`)) { + return Promise.resolve( + jsonResponse({ + tracks: [ + { position: 1, disc_number: 1, title: 'Infinite ❤️ Without Fulfillment', length: 95327 }, + { position: 5, disc_number: 1, title: 'Circumambient', length: 223280 }, + { position: 1, disc_number: 2, title: 'Ambrosia', length: 213093 }, + { position: 5, disc_number: 2, title: 'Be a Body (Baarsden rework)', length: 204626 } + ], + total_tracks: 4, + total_length: 736326, + label: null, + barcode: null, + country: null + }) + ); + } + + if (url.includes(`/api/v1/albums/${albumId}/more-by-artist`)) { + return Promise.resolve(jsonResponse({ artist_name: 'Grimes', albums: [] })); + } + + if (url.includes(`/api/v1/albums/${albumId}/similar`)) { + return Promise.resolve(jsonResponse({ albums: [] })); + } + + if (url.endsWith(`/api/v1/youtube/link/${albumId}`)) { + return Promise.resolve(jsonResponse({ detail: 'not found' }, 404)); + } + + if (url.endsWith(`/api/v1/youtube/track-links/${albumId}`)) { + return Promise.resolve(jsonResponse([])); + } + + if (url.endsWith(`/api/v1/jellyfin/albums/match/${albumId}`)) { + return Promise.resolve(jsonResponse({ found: false, jellyfin_album_id: null, tracks: [] })); + } + + if (url.endsWith(`/api/v1/local/albums/match/${albumId}`)) { + return Promise.resolve( + jsonResponse({ + found: true, + tracks: [ + { track_file_id: 1, title: 'Infinite ❤️ Without Fulfillment', track_number: 1, disc_number: 1, duration_seconds: 95, size_bytes: 1, format: 'flac' }, + { track_file_id: 2, title: 'Circumambient', track_number: 5, disc_number: 1, duration_seconds: 223, size_bytes: 1, format: 'flac' }, + { track_file_id: 3, title: 'Ambrosia', track_number: 1, disc_number: 2, duration_seconds: 213, size_bytes: 1, format: 'flac' }, + { track_file_id: 4, title: 'Be a Body (Baarsden rework)', track_number: 5, disc_number: 2, duration_seconds: 204, size_bytes: 1, format: 'flac' } + ], + total_size_bytes: 4, + primary_format: 'flac' + }) + ); + } + + if (url.includes(`/api/v1/navidrome/album-match/${albumId}`)) { + return Promise.resolve( + jsonResponse({ + found: true, + navidrome_album_id: 'nav-1', + tracks: [ + { navidrome_id: 'n1', title: 'Infinite ❤️ Without Fulfillment', track_number: 1, disc_number: 1, duration_seconds: 95, codec: 'flac', bitrate: 800, album_name: 'Visions', artist_name: 'Grimes' }, + { navidrome_id: 'n2', title: 'Circumambient', track_number: 5, disc_number: 1, duration_seconds: 223, codec: 'flac', bitrate: 800, album_name: 'Visions', artist_name: 'Grimes' }, + { navidrome_id: 'n3', title: 'Ambrosia', track_number: 1, disc_number: 2, duration_seconds: 213, codec: 'flac', bitrate: 800, album_name: 'Visions', artist_name: 'Grimes' }, + { navidrome_id: 'n4', title: 'Be a Body (Baarsden rework)', track_number: 5, disc_number: 2, duration_seconds: 204, codec: 'flac', bitrate: 800, album_name: 'Visions', artist_name: 'Grimes' } + ] + }) + ); + } + + return Promise.resolve(jsonResponse({})); + }); + }); + + it('renders visible grouped track rows alongside source bars', async () => { + expect.assertions(6); + render(AlbumPage, { + props: { data: { albumId } } + } as Parameters>[1]); + + await expect.element(page.getByText('Local Files')).toBeVisible(); + await expect.element(page.getByText('Navidrome')).toBeVisible(); + await expect.element(page.getByText('Disc 1')).toBeVisible(); + await expect.element(page.getByText('Disc 2')).toBeVisible(); + await expect.element(page.getByText('Infinite ❤️ Without Fulfillment')).toBeVisible(); + await expect.element(page.getByText('Be a Body (Baarsden rework)')).toBeVisible(); + }); + + it('does not refetch tracks when the tracks cache is fresh but basic metadata is stale', async () => { + expect.assertions(2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test mock with generic cache type + mockHydrateDetailCacheEntry.mockImplementation(({ cache, onHydrate }: any) => { + if (cache === mockAlbumBasicCache) { + onHydrate({ + title: 'Visions', + musicbrainz_id: albumId, + artist_name: 'Grimes', + artist_id: 'artist-1', + in_library: true, + requested: false, + cover_url: null + }); + return true; + } + + if (cache === mockAlbumTracksCache) { + onHydrate({ + tracks: [ + { position: 1, disc_number: 1, title: 'Infinite ❤️ Without Fulfillment', length: 95327 } + ], + total_tracks: 1, + total_length: 95327, + label: null, + barcode: null, + country: null + }); + return false; + } + + return true; + }); + + render(AlbumPage, { + props: { data: { albumId } } + } as Parameters>[1]); + + await expect.element(page.getByText('Infinite ❤️ Without Fulfillment')).toBeVisible(); + expect( + mockPageFetch.mock.calls.some( + ([input]) => typeof input === 'string' && input.endsWith(`/api/v1/albums/${albumId}/tracks`) + ) + ).toBe(false); + }); +}); diff --git a/frontend/src/routes/artist/[id]/+page.svelte b/frontend/src/routes/artist/[id]/+page.svelte new file mode 100644 index 0000000..249338e --- /dev/null +++ b/frontend/src/routes/artist/[id]/+page.svelte @@ -0,0 +1,700 @@ + + +
    + + {#if error} +
    +
    + {error} +
    +
    + {:else if loadingBasic && !artist} +
    + + +
    + {:else if artist} +
    + + +
    +
    + + +
    + {#if artist.country} + + 🌍 {artist.country} + + {/if} + {#if artist.life_span?.begin} + + 📅 {artist.life_span.begin}{#if artist.life_span.end} – {artist.life_span.end}{/if} + + {/if} + {#if artist.albums.length + artist.eps.length + artist.singles.length > 0} + + 💿 {artist.albums.length + artist.eps.length + artist.singles.length} releases + + {/if} +
    + + {#if artist.tags.length > 0} +
    + {#each artist.tags.slice(0, 10) as tag} + {tag} + {/each} +
    + {/if} +
    + +
    + {#if !lastfmEnrichment?.bio} + + {/if} + + {#if $integrationStore.lastfm} + + {/if} +
    + + + +
    + +
    + +
    +
    + +
    + +
    + +
    +
    + +
    + +
    + + {#if hasMoreReleases || loadingMoreReleases} +
    + +
    + Loading all releases... + Loaded {loadedReleaseCount} of {totalReleaseCount} releases +
    +
    + {/if} + + {#if artist.albums.length > 0} +
    + (albumsCollapsed = !albumsCollapsed)} + /> +
    + {/if} + + {#if artist.eps.length > 0} +
    + (epsCollapsed = !epsCollapsed)} + /> +
    + {/if} + + {#if artist.singles.length > 0} +
    + (singlesCollapsed = !singlesCollapsed)} + /> +
    + {/if} +
    +
    + {:else} +
    +

    Artist not found

    +
    + {/if} +
    + + + + +{#if showArtistRemovedModal} + { + showArtistRemovedModal = false; + }} + /> +{/if} diff --git a/frontend/src/routes/artist/[id]/+page.ts b/frontend/src/routes/artist/[id]/+page.ts new file mode 100644 index 0000000..f434516 --- /dev/null +++ b/frontend/src/routes/artist/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + return { + artistId: params.id + }; +}; diff --git a/frontend/src/routes/discover/+page.svelte b/frontend/src/routes/discover/+page.svelte new file mode 100644 index 0000000..9422f73 --- /dev/null +++ b/frontend/src/routes/discover/+page.svelte @@ -0,0 +1,474 @@ + + + + Discover - Musicseerr + + +
    + handleRefresh()} + > + {#snippet title()} + + Discover + {/snippet} + + +
    + +
    + + {#if error && !discoverData} +
    + +

    {error}

    + +
    + {:else} +
    + {#if servicePrompts.length > 0} +
    + {#each servicePrompts as prompt} + + {/each} +
    + {/if} + + {#if loading && !discoverData} +
    + {#each Array(3) as _} +
    +
    + +
    + {/each} +
    + {:else if discoverData} +
    + + {#if hasCuratedGroup} +
    + + {#snippet icon()}{/snippet} + + +
    + {#if discoverData.because_you_listen_to.length > 0} + {#each discoverData.because_you_listen_to as entry (entry.seed_artist_mbid || entry.seed_artist)} +
    + +
    + {/each} + {/if} + +
    + (queueModalOpen = true)} /> +
    + + {#if activeSource === 'listenbrainz' && discoverData.weekly_exploration && discoverData.weekly_exploration.tracks.length > 0} +
    + +
    + {/if} +
    +
    + {:else} +
    + (queueModalOpen = true)} /> +
    + {/if} + + {#if hasExploreGroup} +
    + + {#snippet icon()}{/snippet} + + +
    + {#if discoverData.fresh_releases && discoverData.fresh_releases.items.length > 0} + + {/if} + + {#if discoverData.missing_essentials && discoverData.missing_essentials.items.length > 0} + + {/if} + + {#if discoverData.rediscover && discoverData.rediscover.items.length > 0} + + {/if} + + {#if discoverData.artists_you_might_like && discoverData.artists_you_might_like.items.length > 0} + + {/if} + + {#if discoverData.popular_in_your_genres && discoverData.popular_in_your_genres.items.length > 0} + + {/if} +
    +
    + {/if} + + {#if hasChartsGroup} +
    + + {#snippet icon()}{/snippet} + + +
    + {#if discoverData.globally_trending && discoverData.globally_trending.items.length > 0} + + {/if} + + {#if discoverData.lastfm_recent_scrobbles && discoverData.lastfm_recent_scrobbles.items.length > 0} + + {/if} + + {#if discoverData.lastfm_weekly_artist_chart && discoverData.lastfm_weekly_artist_chart.items.length > 0} + + {/if} + + {#if discoverData.genre_list && discoverData.genre_list.items.length > 0} +
    + +
    + {/if} + + {#if discoverData.lastfm_weekly_album_chart && discoverData.lastfm_weekly_album_chart.items.length > 0} + + {/if} +
    +
    + {/if} + + {#if !hasContent && servicePrompts.length === 0} + {#if discoverData.refreshing || isUpdating} +
    + +

    + Building Your Recommendations +

    +

    + We're analyzing your listening history and building personalized recommendations. + This may take a moment on first load. +

    +
    + {:else} +
    + +

    + Building Recommendations +

    +

    + Your personalized recommendations are being prepared. Try refreshing in a moment. +

    + +
    + {/if} + {:else if !hasContent && servicePrompts.length > 0} +
    + +

    Nothing to Discover Yet

    +

    + Connect your music services to get personalized recommendations. The more services you + connect, the better your recommendations will be. +

    + Connect Services +
    + {/if} + +
    + {/if} +
    + {/if} +
    + + diff --git a/frontend/src/routes/genre/+page.svelte b/frontend/src/routes/genre/+page.svelte new file mode 100644 index 0000000..695ef28 --- /dev/null +++ b/frontend/src/routes/genre/+page.svelte @@ -0,0 +1,430 @@ + + + + {genreName ? `${genreName}` : 'Genre'} - Musicseerr + + +
    + {#if heroArtistMbid} +
    + (heroImageLoaded = true)} + /> +
    +
    + {/if} + +
    +
    + + + Back + +
    +
    + +
    +
    +

    + {genreName || 'Genre'} +

    + {#if genreData} +

    + {#if hasLibraryContent} + {genreData.library?.artist_count ?? 0} artists · {genreData.library?.album_count ?? + 0} albums in your library + {:else} + Explore popular {genreName} music + {/if} +

    + {/if} +
    +
    +
    + + {#if loading} +
    +
    +
    +
    +
    +
    +
    +
    +
    + {#each Array(12) as _} +
    +
    +
    +
    +
    +
    +
    + {/each} +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + {#each Array(6) as _} +
    +
    +
    +
    +
    +
    +
    + {/each} +
    +
    + {:else if error} +
    + +

    {error}

    + +
    + {:else if genreData} + {#if hasLibraryContent} +
    +
    +
    + +
    +
    +

    From Your Library

    +

    + {genreData.library?.artist_count ?? 0} artists · {genreData.library?.album_count ?? + 0} albums +

    +
    +
    + + {#if (genreData.library?.artists?.length ?? 0) > 0} +

    Artists

    +
    + {#each genreData.library?.artists ?? [] as artist (artist.mbid || artist.name)} + + {/each} +
    + {/if} + + {#if (genreData.library?.albums?.length ?? 0) > 0} +

    Albums

    +
    + {#each genreData.library?.albums ?? [] as album (album.mbid || album.name)} + + {/each} +
    + {/if} +
    +
    + {/if} + +
    +
    +
    + +
    +
    +

    Popular Artists

    +

    Top {genreName} artists

    +
    +
    + + {#if (genreData.popular?.artists?.length ?? 0) === 0} +
    + +

    No artists found for this genre

    +
    + {:else} +
    + {#each genreData.popular?.artists ?? [] as artist (artist.mbid || artist.name)} + + {/each} +
    + {#if genreData.popular?.has_more_artists} +
    + +
    + {/if} + {/if} +
    + +
    +
    +
    + +
    +
    +

    Popular Albums

    +

    Top {genreName} albums

    +
    +
    + + {#if (genreData.popular?.albums?.length ?? 0) === 0} +
    + +

    No albums found for this genre

    +
    + {:else} +
    + {#each genreData.popular?.albums ?? [] as album (album.mbid || album.name)} + + {/each} +
    + {#if genreData.popular?.has_more_albums} +
    + +
    + {/if} + {/if} +
    + {/if} +
    +
    diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts new file mode 100644 index 0000000..c7cee45 --- /dev/null +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -0,0 +1,172 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { createRawSnippet } from 'svelte'; + +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$app/navigation', () => ({ + goto: vi.fn(), + beforeNavigate: vi.fn(), + afterNavigate: vi.fn() +})); +vi.mock('$app/paths', () => ({ + base: '', + assets: '', + resolve: vi.fn((_route: string, params: Record) => `/${params?.id ?? ''}`), + resolveRoute: vi.fn((_route: string, params: Record) => `/${params?.id ?? ''}`), + asset: vi.fn((file: string) => file) +})); +vi.mock('$app/stores', () => ({ + page: { + subscribe: vi.fn((cb: (v: unknown) => void) => { + cb({ + url: new URL('http://localhost/'), + params: {}, + route: { id: '/' }, + status: 200, + error: null, + data: {}, + form: null, + state: {} + }); + return () => {}; + }) + } +})); +vi.mock('$lib/stores/errorModal', () => ({ + errorModal: { subscribe: vi.fn((cb: (v: unknown) => void) => { cb({ show: false }); return () => {}; }) } +})); +vi.mock('$lib/stores/library', () => ({ + libraryStore: { subscribe: vi.fn((cb: (v: unknown) => void) => { cb({}); return () => {}; }), initialize: vi.fn() } +})); +vi.mock('$lib/stores/integration', () => ({ + integrationStore: { + subscribe: vi.fn((cb: (v: unknown) => void) => { + cb(integrationState); + return () => {}; + }), + ensureLoaded: vi.fn().mockResolvedValue(undefined) + } +})); +vi.mock('$lib/stores/cacheTtl', () => ({ initCacheTTLs: vi.fn() })); +vi.mock('$lib/stores/player.svelte', () => ({ + playerStore: { + isPlayerVisible: false, + isPlaying: false, + nowPlaying: null, + progress: 0, + duration: 0, + volume: 50, + currentQueueItem: null, + togglePlay: vi.fn(), + seekTo: vi.fn(), + setVolume: vi.fn(), + restoreSession: vi.fn(() => null) + } +})); +vi.mock('$lib/player/launchYouTubePlayback', () => ({ launchYouTubePlayback: vi.fn() })); +vi.mock('$lib/stores/playbackToast.svelte', () => ({ + playbackToast: { visible: false, message: '', type: 'info', show: vi.fn(), dismiss: vi.fn() } +})); +vi.mock('$lib/stores/scrobble.svelte', () => ({ + scrobbleManager: { init: vi.fn().mockResolvedValue(undefined) } +})); +vi.mock('$lib/utils/lazyImage', () => ({ cancelPendingImages: vi.fn() })); +vi.mock('$lib/utils/requestsApi', () => ({ fetchActiveRequestCount: vi.fn().mockResolvedValue(0) })); +vi.mock('$lib/utils/navigationProgress', () => ({ + createNavigationProgressController: vi.fn(() => ({ + start: vi.fn(), + finish: vi.fn(), + cleanup: vi.fn() + })) +})); +vi.mock('$lib/components/Player.svelte', () => { + const Comp = function() {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/SearchSuggestions.svelte', () => { + const Comp = function() {}; + Comp.prototype = {}; + return { default: Comp }; +}); +vi.mock('$lib/components/YouTubeIcon.svelte', () => { + const Comp = function() {}; + Comp.prototype = {}; + return { default: Comp }; +}); + +import Layout from './+layout.svelte'; + +type IntegrationState = { + lidarr: boolean; + jellyfin: boolean; + listenbrainz: boolean; + youtube: boolean; + localfiles: boolean; + lastfm: boolean; + loaded: boolean; +}; + +const integrationState: IntegrationState = { + lidarr: false, + jellyfin: false, + listenbrainz: false, + youtube: false, + localfiles: false, + lastfm: false, + loaded: true +}; + +const childrenSnippet = createRawSnippet(() => ({ + render: () => '
    Page
    ' +})); + +function renderLayout() { + return render(Layout, { + props: { children: childrenSnippet } as Record + } as Parameters>[1]); +} + +describe('+layout.svelte sidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + Object.assign(integrationState, { + lidarr: false, + jellyfin: false, + listenbrainz: false, + youtube: false, + localfiles: false, + lastfm: false, + loaded: true + }); + }); + + it('does not render "Playlists" link in the sidebar when Lidarr is unavailable', async () => { + renderLayout(); + await expect.element(page.getByText('Playlists')).not.toBeInTheDocument(); + }); + + it('renders "Playlists" link in the sidebar when Lidarr is available', async () => { + integrationState.lidarr = true; + renderLayout(); + await expect.element(page.getByText('Playlists')).toBeInTheDocument(); + }); + + it('Playlists link navigates to /playlists', async () => { + integrationState.lidarr = true; + renderLayout(); + const link = page.getByText('Playlists'); + const anchor = link.element().closest('a'); + expect(anchor).not.toBeNull(); + expect(anchor!.getAttribute('href')).toBe('/playlists'); + }); + + it('Playlists link has tooltip data attribute', async () => { + integrationState.lidarr = true; + renderLayout(); + const link = page.getByText('Playlists'); + const anchor = link.element().closest('a'); + expect(anchor!.getAttribute('data-tip')).toBe('Playlists'); + }); +}); diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte new file mode 100644 index 0000000..64519e8 --- /dev/null +++ b/frontend/src/routes/library/+page.svelte @@ -0,0 +1,442 @@ + + +
    + {#if error} +
    + + {error} +
    + + +
    +
    + {/if} + +
    +
    +

    Library

    +

    + {#if loadingStats} + + {:else} + {stats.artist_count} artists • {stats.album_count} albums • Last sync: {lastSyncText} + {/if} +

    +
    + +
    + + {#if !isSearching} +
    +

    Recently Added

    + {#if loadingRecentlyAdded} +
    + {#each Array(6) as _}
    {/each} +
    + {:else if recentlyAdded.artists.length > 0 || recentlyAdded.albums.length > 0} +
    + + {#each recentlyAdded.artists as artist} +
    + {/each} + {#each recentlyAdded.albums as album} +
    + {/each} +
    +
    + {:else} +
    +

    No recently added items

    +
    + {/if} +
    + {/if} + +
    +
    + + + {#if isSearching} + + {/if} +
    + +
    + {#if isSearching && !loadingAlbums} +

    + {albumsTotal} {albumsTotal === 1 ? 'album' : 'albums'} found +

    + {/if} + + {#if !isSearching} +
    + + {#if loadingArtists} +
    + {#each Array(8) as _}
    + +
    {/each} +
    + {:else if artists.length > 0} +
    + + {#each artists as artist (artist.mbid)} +
    + +
    + {/each} +
    +
    + {:else} +
    +

    No artists in library

    +
    + {/if} +
    + {/if} + +
    +
    + +

    Albums

    + +
    + {#if !loadingAlbums && totalAlbumPages > 1} + + {/if} +
    + {#if loadingAlbums} +
    + {#each Array(12) as _}{/each} +
    + {:else if albums.length > 0} +
    + {#each albums as album, index (album.foreignAlbumId || `${album.album}-${album.artist}-${index}`)} + + {/each} +
    + {#if totalAlbumPages > 1} +
    + +
    + {/if} + {:else} +
    +

    {isSearching ? 'No matching albums' : 'No albums in library'}

    +
    + {/if} +
    + + {#if !isSearching && !loadingArtists && !loadingAlbums && artists.length === 0 && albums.length === 0} +
    +
    📚
    +

    No items in library

    +

    + Your Lidarr library is empty or hasn't been synced yet. +

    + +
    + {/if} +
    diff --git a/frontend/src/routes/library/albums/+page.svelte b/frontend/src/routes/library/albums/+page.svelte new file mode 100644 index 0000000..863825e --- /dev/null +++ b/frontend/src/routes/library/albums/+page.svelte @@ -0,0 +1,225 @@ + + +
    +
    + +
    +

    All Albums

    +

    + {total} {total === 1 ? 'album' : 'albums'} +

    +
    +
    + +
    +
    + + + {#if searchQuery} + + {/if} +
    + +
    + + {#if error} +
    + {error} + +
    + {:else if loading} +
    + {#each Array(12) as _}{/each} +
    + {:else if albums.length === 0} +
    + +

    No albums found

    +

    + {searchQuery ? 'Try a different search term.' : "Your library doesn't contain any albums yet."} +

    +
    + {:else} +
    + {#each albums as album, index (album.foreignAlbumId || `${album.album}-${album.artist}-${index}`)} + + {/each} +
    + {#if hasMore} +
    + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/artists/+page.svelte b/frontend/src/routes/library/artists/+page.svelte new file mode 100644 index 0000000..8e2a87f --- /dev/null +++ b/frontend/src/routes/library/artists/+page.svelte @@ -0,0 +1,216 @@ + + +
    +
    + +
    +

    All Artists

    +

    + {total} {total === 1 ? 'artist' : 'artists'} +

    +
    +
    + +
    +
    + + + {#if searchQuery} + + {/if} +
    + +
    + + {#if error} +
    + {error} + +
    + {:else if loading} +
    + {#each Array(12) as _}{/each} +
    + {:else if artists.length === 0} +
    + +

    No artists found

    +

    + {searchQuery ? 'Try a different search term.' : "Your library doesn't contain any artists yet."} +

    +
    + {:else} +
    + {#each artists as artist (artist.mbid)} + + {/each} +
    + {#if hasMore} +
    + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/library/jellyfin/+page.svelte b/frontend/src/routes/library/jellyfin/+page.svelte new file mode 100644 index 0000000..b082302 --- /dev/null +++ b/frontend/src/routes/library/jellyfin/+page.svelte @@ -0,0 +1,168 @@ + + + + {#snippet headerIcon()} + + {/snippet} + + {#snippet cardTopLeftBadge(_album)} +
    + +
    + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/local/+page.svelte b/frontend/src/routes/library/local/+page.svelte new file mode 100644 index 0000000..189b53b --- /dev/null +++ b/frontend/src/routes/library/local/+page.svelte @@ -0,0 +1,231 @@ + + + + {#snippet headerIcon()} + + {/snippet} + + {#snippet statsPanel()} + {#if typedStats} +
    +
    +
    Tracks
    +
    {typedStats.total_tracks.toLocaleString()}
    +
    +
    +
    Artists
    +
    {typedStats.total_artists}
    +
    +
    +
    Total Size
    +
    {typedStats.total_size_human}
    +
    +
    +
    Disk Free
    +
    {typedStats.disk_free_human}
    +
    + {#if Object.keys(typedStats.format_breakdown).length > 0} +
    +
    Formats
    +
    + {#each Object.entries(typedStats.format_breakdown) as [fmt, info]} + {fmt}: {info.count} + {/each} +
    +
    + {/if} +
    + {/if} + {/snippet} + + {#snippet recentCardOverlay(album)} + {#if album.primary_format} +
    + {album.primary_format} +
    + {/if} + {/snippet} + + {#snippet cardTopLeftBadge(_album)} +
    + +
    + {/snippet} + + {#snippet cardTopRightExtra(album)} + {#if album.primary_format} +
    {album.primary_format}
    + {/if} + {/snippet} + + {#snippet cardBottomLeft(album)} + {#if album.year} +
    {album.year}
    + {/if} + {/snippet} + + {#snippet cardBodyExtra(album)} +
    +

    {album.artist_name}

    + {#if album.total_size_bytes > 0} + {formatSize(album.total_size_bytes)} + {/if} +
    + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/navidrome/+page.svelte b/frontend/src/routes/library/navidrome/+page.svelte new file mode 100644 index 0000000..0bc40fa --- /dev/null +++ b/frontend/src/routes/library/navidrome/+page.svelte @@ -0,0 +1,193 @@ + + + + {#snippet headerIcon()} + + + + {/snippet} + + {#snippet cardTopLeftBadge(album)} +
    + +
    + {#if !album.musicbrainz_id} +
    + ? +
    + {/if} + {/snippet} + + {#snippet emptyIcon()} + + {/snippet} +
    diff --git a/frontend/src/routes/library/youtube/+page.svelte b/frontend/src/routes/library/youtube/+page.svelte new file mode 100644 index 0000000..f3ecb9e --- /dev/null +++ b/frontend/src/routes/library/youtube/+page.svelte @@ -0,0 +1,323 @@ + + +
    +
    + +

    YouTube Links

    + {links.length} +
    + + {#if !$integrationStore.youtube} +
    + + YouTube is not enabled. Enable it in settings to use YouTube features. +
    + {:else if !$integrationStore.youtube_api} +
    + + YouTube API is not configured. You can add links manually, or enable the API in settings for auto-generation. +
    + {/if} + + {#if !loading && links.length > 0} + + {/if} + + {#if loading} +
    + {#each Array(12) as _} +
    +
    +
    +
    +
    +
    +
    + {/each} +
    + {:else} +
    + + + {#each filteredLinks as link (link.album_id)} +
    openDetail(link)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(link); } }} + role="button" + tabindex="0" + > +
    + +
    +
    + +
    +
    +
    + {#if link.video_id} +
    Full Video
    + {/if} + {#if link.track_count > 0} +
    {link.track_count} tracks
    + {/if} +
    + +
    { e.stopPropagation(); e.preventDefault(); }} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); } }} + > +
    + +
    +
    + +
    + + +
    +
    + +
    +

    {link.album_name}

    +

    {link.artist_name}

    +
    +
    + {/each} +
    + + {#if filteredLinks.length === 0 && links.length > 0} +
    +
    +

    No matching links

    +

    Try a different search term.

    +
    +
    + {:else if links.length === 0} +
    +
    + +

    No saved YouTube links

    +

    Generate links from album pages or add them manually.

    +
    +
    + {/if} + {/if} +
    + + + + diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts new file mode 100644 index 0000000..3c6adf3 --- /dev/null +++ b/frontend/src/routes/page.svelte.spec.ts @@ -0,0 +1,13 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Page from './+page.svelte'; + +describe('/+page.svelte', () => { + it('should render h1', async () => { + render(Page); + + const heading = page.getByRole('heading', { level: 1 }); + await expect.element(heading).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/playlists/+page.svelte b/frontend/src/routes/playlists/+page.svelte new file mode 100644 index 0000000..9dc971c --- /dev/null +++ b/frontend/src/routes/playlists/+page.svelte @@ -0,0 +1,157 @@ + + + + Playlists - Musicseerr + + +
    +
    +

    Playlists

    + +
    + + {#if showNewInput} +
    + + + +
    + {/if} + + {#if loading} +
    + {#each Array(8) as _} + + {/each} +
    + {:else if error} + + {:else if playlists.length === 0} +
    + +

    No playlists yet

    + +
    + {:else} +
    + {#each playlists as playlist (playlist.id)} + + {/each} +
    + {/if} +
    diff --git a/frontend/src/routes/playlists/[id]/+error.svelte b/frontend/src/routes/playlists/[id]/+error.svelte new file mode 100644 index 0000000..900ef31 --- /dev/null +++ b/frontend/src/routes/playlists/[id]/+error.svelte @@ -0,0 +1,31 @@ + + + + Playlist Error - Musicseerr + + +
    +
    + +

    Couldn't load this playlist

    +

    + {error?.message ?? 'Please try again'} + {#if status} + ({status}) + {/if} +

    +
    + + + Back to Playlists + + +
    +
    +
    diff --git a/frontend/src/routes/playlists/[id]/+page.svelte b/frontend/src/routes/playlists/[id]/+page.svelte new file mode 100644 index 0000000..c1619aa --- /dev/null +++ b/frontend/src/routes/playlists/[id]/+page.svelte @@ -0,0 +1,295 @@ + + + + {playlist?.name ?? 'Playlist'} - Musicseerr + + +
    +{#if loading} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {#each Array(4) as _} +
    + {/each} +
    +
    +{:else if loadError} +
    + +

    Couldn't load this playlist

    +

    {loadError}

    +
    + + +
    +
    +{:else if !playlist} +
    + +

    Playlist not found

    + +
    +{:else} +
    +
    +
    + +
    + +
    +
    + +
    + + deleteModal?.showModal()} + onplaylistupdate={handlePlaylistUpdate} + /> +
    +
    + + {}} + onsourcechange={handleSourceChange} + onplaytrack={playFromTrack} + /> +
    + + void confirmDelete()} + /> +{/if} +
    diff --git a/frontend/src/routes/playlists/[id]/+page.ts b/frontend/src/routes/playlists/[id]/+page.ts new file mode 100644 index 0000000..03de63d --- /dev/null +++ b/frontend/src/routes/playlists/[id]/+page.ts @@ -0,0 +1,7 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params }) => { + return { + playlistId: params.id + }; +}; diff --git a/frontend/src/routes/playlists/[id]/DeletePlaylistModal.svelte b/frontend/src/routes/playlists/[id]/DeletePlaylistModal.svelte new file mode 100644 index 0000000..7a58516 --- /dev/null +++ b/frontend/src/routes/playlists/[id]/DeletePlaylistModal.svelte @@ -0,0 +1,38 @@ + + + + + + diff --git a/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte new file mode 100644 index 0000000..638563e --- /dev/null +++ b/frontend/src/routes/playlists/[id]/PlaylistHeader.svelte @@ -0,0 +1,295 @@ + + +
    +
    + +
    + {#if uploading} +
    + +
    + {/if} +
    + + {#if playlist.custom_cover_url} + + {/if} +
    + +
    + +
    + + Playlist + + + {#if editingName} +
    + + + +
    + {:else} + + {/if} + +
    + {playlist.track_count} track{playlist.track_count === 1 ? '' : 's'} + {#if playlist.total_duration} + + {formatTotalDurationSec(playlist.total_duration)} + {/if} + + Created {formatRelativeTime(new Date(playlist.created_at))} +
    + +
    + + + +
    +
    +
    diff --git a/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte b/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte new file mode 100644 index 0000000..5c5c7e8 --- /dev/null +++ b/frontend/src/routes/playlists/[id]/PlaylistTrackList.svelte @@ -0,0 +1,502 @@ + + + + +{#if playlist.tracks.length === 0} +
    + +

    This playlist is empty

    +

    Add tracks from album pages using the context menu

    +
    +{:else} +
      + {#if playlist.tracks.length > 1} +
    • + +
    • + {/if} + {#each playlist.tracks as track, i (track.id)} + {@const isCurrentlyPlaying = playerStore.isPlaying && ( + playerStore.currentQueueItem?.playlistTrackId + ? playerStore.currentQueueItem.playlistTrackId === track.id + : playerStore.currentQueueItem?.trackSourceId === track.track_source_id && playerStore.currentQueueItem?.sourceType === track.source_type + )} +
    • handleDragStart(e, i)} + ondragover={(e) => handleDragOver(e, i)} + ondragleave={handleDragLeave} + ondrop={(e) => void handleDrop(e, i)} + ondragend={handleDragEnd} + role="listitem" + aria-roledescription="sortable" + > +
      + + + { + e.stopPropagation(); + toggleTrackSelection(track.id, i, e.shiftKey); + }} + aria-label="Select {track.track_name}" + /> + + {#if isCurrentlyPlaying} +
      + +
      + {:else} + {i + 1} + + {/if} + +
      + {#if track.cover_url} + + {:else} +
      + +
      + {/if} +
      + +
      + {#if track.album_id} + {track.track_name} + {:else} + {track.track_name} + {/if} + + {#if track.artist_id} + {track.artist_name} + {:else} + {track.artist_name} + {/if} + {#if track.album_name} + · + {#if track.album_id} + {track.album_name} + {:else} + {track.album_name} + {/if} + {/if} + +
      + + + {formatDurationSec(track.duration)} + + + void handleSourceChange(track, src)} + /> + +
      + +
      +
      +
    • + {/each} +
    + + {#if selectionMode} +
    + + {selectedIds.size} selected + + + +
    + {/if} +{/if} + +
    {liveMessage}
    diff --git a/frontend/src/routes/playlists/[id]/page.svelte.spec.ts b/frontend/src/routes/playlists/[id]/page.svelte.spec.ts new file mode 100644 index 0000000..45a5a93 --- /dev/null +++ b/frontend/src/routes/playlists/[id]/page.svelte.spec.ts @@ -0,0 +1,346 @@ +import { page, userEvent } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import type { PlaylistDetail, PlaylistTrack } from '$lib/api/playlists'; + +const mockFetchPlaylist = vi.fn(); +const mockDeletePlaylist = vi.fn(); +const mockRemoveTrackFromPlaylist = vi.fn(); +const mockRemoveTracksFromPlaylist = vi.fn(); +const mockUpdatePlaylist = vi.fn(); +const mockUpdatePlaylistTrack = vi.fn(); +const mockReorderPlaylistTrack = vi.fn(); +const mockUploadPlaylistCover = vi.fn(); +const mockDeletePlaylistCover = vi.fn(); +const mockResolvePlaylistSources = vi.fn(); + +vi.mock('$lib/api/playlists', () => ({ + fetchPlaylist: (...args: unknown[]) => mockFetchPlaylist(...args), + deletePlaylist: (...args: unknown[]) => mockDeletePlaylist(...args), + removeTrackFromPlaylist: (...args: unknown[]) => mockRemoveTrackFromPlaylist(...args), + removeTracksFromPlaylist: (...args: unknown[]) => mockRemoveTracksFromPlaylist(...args), + updatePlaylist: (...args: unknown[]) => mockUpdatePlaylist(...args), + updatePlaylistTrack: (...args: unknown[]) => mockUpdatePlaylistTrack(...args), + reorderPlaylistTrack: (...args: unknown[]) => mockReorderPlaylistTrack(...args), + uploadPlaylistCover: (...args: unknown[]) => mockUploadPlaylistCover(...args), + deletePlaylistCover: (...args: unknown[]) => mockDeletePlaylistCover(...args), + resolvePlaylistSources: (...args: unknown[]) => mockResolvePlaylistSources(...args), +})); + +const mockToastShow = vi.fn(); +vi.mock('$lib/stores/toast', () => ({ + toastStore: { show: (...args: unknown[]) => mockToastShow(...args) } +})); + +const mockPlayQueue = vi.fn(); +const mockAddToQueue = vi.fn(); +const mockPlayNext = vi.fn(); +vi.mock('$lib/stores/player.svelte', () => ({ + playerStore: { + playQueue: (...args: unknown[]) => mockPlayQueue(...args), + addToQueue: (...args: unknown[]) => mockAddToQueue(...args), + playNext: (...args: unknown[]) => mockPlayNext(...args), + } +})); + +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args) +})); + +vi.mock('$lib/stores/cacheTtl', () => ({ + getCacheTTL: () => 15 * 60 * 1000 +})); + +import DetailPage from './+page.svelte'; + +function renderDetail(playlistId = 'pl-1') { + return render(DetailPage, { + props: { data: { playlistId } } + } as Parameters>[1]); +} + +function makeTrack(overrides: Partial = {}): PlaylistTrack { + return { + id: 'trk-1', + position: 0, + track_name: 'Test Track', + artist_name: 'Test Artist', + album_name: 'Test Album', + album_id: 'alb-1', + artist_id: 'art-1', + track_source_id: 'vid-1', + cover_url: '/cover.jpg', + source_type: 'local', + available_sources: ['local'], + format: 'flac', + track_number: 1, + disc_number: null, + duration: 240, + created_at: '2026-01-01T00:00:00Z', + ...overrides + }; +} + +function makePlaylist(overrides: Partial = {}): PlaylistDetail { + return { + id: 'pl-1', + name: 'My Playlist', + track_count: 2, + total_duration: 480, + cover_urls: [], + custom_cover_url: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + tracks: [ + makeTrack({ id: 'trk-1', position: 0, track_name: 'First Track', duration: 240 }), + makeTrack({ id: 'trk-2', position: 1, track_name: 'Second Track', artist_name: 'Other Artist', duration: 240 }), + ], + ...overrides + }; +} + +describe('Playlist detail page', () => { + beforeEach(() => { + mockFetchPlaylist.mockReset(); + mockDeletePlaylist.mockReset(); + mockRemoveTrackFromPlaylist.mockReset(); + mockUpdatePlaylist.mockReset(); + mockUpdatePlaylistTrack.mockReset(); + mockReorderPlaylistTrack.mockReset(); + mockUploadPlaylistCover.mockReset(); + mockDeletePlaylistCover.mockReset(); + mockResolvePlaylistSources.mockReset(); + mockResolvePlaylistSources.mockResolvedValue({}); + mockToastShow.mockReset(); + mockPlayQueue.mockReset(); + mockAddToQueue.mockReset(); + mockPlayNext.mockReset(); + mockGoto.mockReset(); + try { localStorage.clear(); } catch { /* ignore in non-browser */ } + }); + + it('renders header with playlist name, track count, and duration', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + await expect.element(page.getByText(/2 tracks/)).toBeVisible(); + await expect.element(page.getByText(/8 min/)).toBeVisible(); + }); + + it('renders track rows with correct data', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByText('First Track')).toBeVisible(); + await expect.element(page.getByText('Second Track')).toBeVisible(); + await expect.element(page.getByText('Other Artist')).toBeVisible(); + }); + + it('shows error state when playlist is missing', async () => { + mockFetchPlaylist.mockRejectedValue(new Error('404 not found')); + renderDetail('pl-bad'); + + await expect.element(page.getByText("Couldn't load this playlist")).toBeVisible(); + await expect.element(page.getByText('Playlist not found')).toBeVisible(); + }); + + it('shows empty state when playlist has no tracks', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist({ tracks: [], track_count: 0 })); + renderDetail('pl-1'); + + await expect.element(page.getByText('This playlist is empty')).toBeVisible(); + }); + + it('Play All calls playQueue with all tracks', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + const playBtn = page.getByRole('button', { name: /Play All/ }); + await playBtn.click(); + + expect(mockPlayQueue).toHaveBeenCalledOnce(); + const [items, startIdx, shuffle] = mockPlayQueue.mock.calls[0]; + expect(items).toHaveLength(2); + expect(startIdx).toBe(0); + expect(shuffle).toBe(false); + }); + + it('Shuffle calls playQueue with shuffle=true', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + const shuffleBtn = page.getByRole('button', { name: /Shuffle/ }); + await shuffleBtn.click(); + + expect(mockPlayQueue).toHaveBeenCalledOnce(); + expect(mockPlayQueue.mock.calls[0][2]).toBe(true); + }); + + it('Play All is disabled when playlist has no tracks', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist({ tracks: [], track_count: 0 })); + renderDetail('pl-1'); + + await expect.element(page.getByText('This playlist is empty')).toBeVisible(); + const playBtn = page.getByRole('button', { name: /Play All/ }); + expect(await playBtn.element()).toBeDisabled(); + }); + + it('delete confirmation modal shows and cancel does not delete', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + await expect.element(page.getByText(/Delete "My Playlist"\?/)).not.toBeVisible(); + + expect(mockDeletePlaylist).not.toHaveBeenCalled(); + }); + + it('back button is visible when playlist loads', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + const backButton = page.getByRole('button', { name: /Go back/ }); + await expect.element(backButton).toBeVisible(); + }); + + it('inline name editing: clicking name shows input, Escape cancels', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + const editBtn = page.getByRole('button', { name: /Edit playlist name/ }); + await editBtn.click(); + + const nameInput = page.getByPlaceholder('Playlist name'); + await expect.element(nameInput).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + expect(mockUpdatePlaylist).not.toHaveBeenCalled(); + }); + + it('inline name editing: Enter saves new name', async () => { + mockUpdatePlaylist.mockResolvedValue({ name: 'Renamed', updated_at: '2026-01-03T00:00:00Z' }); + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + const editBtn = page.getByRole('button', { name: /Edit playlist name/ }); + await editBtn.click(); + + const nameInput = page.getByPlaceholder('Playlist name'); + await expect.element(nameInput).toBeVisible(); + await nameInput.clear(); + await nameInput.fill('Renamed'); + await userEvent.keyboard('{Enter}'); + + expect(mockUpdatePlaylist).toHaveBeenCalledOnce(); + expect(mockUpdatePlaylist.mock.calls[0][1]).toEqual({ name: 'Renamed' }); + }); + + it('inline name editing: save button click saves new name', async () => { + mockUpdatePlaylist.mockResolvedValue({ name: 'Renamed', updated_at: '2026-01-03T00:00:00Z' }); + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + await page.getByRole('button', { name: /Edit playlist name/ }).click(); + + const nameInput = page.getByPlaceholder('Playlist name'); + await expect.element(nameInput).toBeVisible(); + await nameInput.clear(); + await nameInput.fill('Renamed'); + await page.getByRole('button', { name: 'Save name' }).click(); + + await vi.waitFor(() => { + expect(mockUpdatePlaylist).toHaveBeenCalledOnce(); + }); + expect(mockUpdatePlaylist.mock.calls[0][1]).toEqual({ name: 'Renamed' }); + await expect.element(page.getByRole('heading', { name: 'Renamed', level: 1 })).toBeVisible(); + }); + + it('delete confirmation modal contains correct text and cancel button', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + + // Modal exists in DOM but is not visible until opened + await expect.element(page.getByText(/This will permanently remove/)).not.toBeVisible(); + expect(mockDeletePlaylist).not.toHaveBeenCalled(); + }); + + it('remove track shows toast and updates track list', async () => { + mockRemoveTrackFromPlaylist.mockResolvedValue(undefined); + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByText('First Track')).toBeVisible(); + await expect.element(page.getByText('Second Track')).toBeVisible(); + expect(mockRemoveTrackFromPlaylist).not.toHaveBeenCalled(); + }); + + it('calls resolvePlaylistSources after playlist loads', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + mockResolvePlaylistSources.mockResolvedValue({}); + renderDetail('pl-1'); + + await expect.element(page.getByRole('heading', { name: 'My Playlist', level: 1 })).toBeVisible(); + await vi.waitFor(() => { + expect(mockResolvePlaylistSources).toHaveBeenCalledWith('pl-1'); + }); + }); + + it('merges resolved sources into track available_sources', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + mockResolvePlaylistSources.mockResolvedValue({ + 'trk-1': ['local', 'jellyfin'], + 'trk-2': ['local'] + }); + renderDetail('pl-1'); + + await expect.element(page.getByText('First Track')).toBeVisible(); + await vi.waitFor(() => { + expect(mockResolvePlaylistSources).toHaveBeenCalledWith('pl-1'); + }); + }); + + it('shows play button on track hover with correct aria label', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByText('First Track')).toBeVisible(); + + const playBtn = page.getByRole('button', { name: 'Play First Track' }); + expect(playBtn.elements()).toHaveLength(1); + }); + + it('play button on track calls playQueue with correct start index', async () => { + mockFetchPlaylist.mockResolvedValue(makePlaylist()); + renderDetail('pl-1'); + + await expect.element(page.getByText('Second Track')).toBeVisible(); + + const playSecond = page.getByRole('button', { name: 'Play Second Track' }); + await playSecond.click(); + + expect(mockPlayQueue).toHaveBeenCalledOnce(); + const [items, startIdx, shuffle] = mockPlayQueue.mock.calls[0]; + expect(items).toHaveLength(2); + expect(startIdx).toBe(1); + expect(shuffle).toBe(false); + }); +}); diff --git a/frontend/src/routes/playlists/page.svelte.spec.ts b/frontend/src/routes/playlists/page.svelte.spec.ts new file mode 100644 index 0000000..04e2206 --- /dev/null +++ b/frontend/src/routes/playlists/page.svelte.spec.ts @@ -0,0 +1,109 @@ +import { page } from '@vitest/browser/context'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import type { PlaylistSummary } from '$lib/api/playlists'; + +const mockFetchPlaylists = vi.fn(); +const mockCreatePlaylist = vi.fn(); +const mockFetchPlaylist = vi.fn(); +const mockDeletePlaylist = vi.fn(); + +vi.mock('$lib/api/playlists', () => ({ + fetchPlaylists: (...args: unknown[]) => mockFetchPlaylists(...args), + createPlaylist: (...args: unknown[]) => mockCreatePlaylist(...args), + fetchPlaylist: (...args: unknown[]) => mockFetchPlaylist(...args), + deletePlaylist: (...args: unknown[]) => mockDeletePlaylist(...args), + resolvePlaylistSources: vi.fn().mockResolvedValue({}), +})); + +const mockToastShow = vi.fn(); +vi.mock('$lib/stores/toast', () => ({ + toastStore: { show: (...args: unknown[]) => mockToastShow(...args) }, +})); + +const mockGoto = vi.fn(); +vi.mock('$app/navigation', () => ({ + goto: (...args: unknown[]) => mockGoto(...args), +})); + +import PlaylistsPage from './+page.svelte'; + +function makePlaylist(overrides: Partial = {}): PlaylistSummary { + return { + id: 'pl-1', + name: 'Test Playlist', + track_count: 5, + total_duration: 900, + cover_urls: [], + custom_cover_url: null, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + ...overrides, + }; +} + +describe('Playlists list page', () => { + beforeEach(() => { + mockFetchPlaylists.mockReset(); + mockCreatePlaylist.mockReset(); + mockToastShow.mockReset(); + mockGoto.mockReset(); + }); + + it('renders playlist cards with correct data', async () => { + expect.assertions(3); + mockFetchPlaylists.mockResolvedValue([ + makePlaylist({ id: 'pl-1', name: 'Rock Mix', track_count: 10 }), + makePlaylist({ id: 'pl-2', name: 'Chill Vibes', track_count: 3 }), + ]); + + render(PlaylistsPage); + + await expect.element(page.getByText('Rock Mix')).toBeVisible(); + await expect.element(page.getByText('Chill Vibes')).toBeVisible(); + await expect.element(page.getByText(/10 tracks/)).toBeVisible(); + }); + + it('renders empty state when no playlists exist', async () => { + expect.assertions(2); + mockFetchPlaylists.mockResolvedValue([]); + + render(PlaylistsPage); + + await expect.element(page.getByText('No playlists yet')).toBeVisible(); + await expect.element(page.getByText('Create your first playlist')).toBeVisible(); + }); + + it('renders error state when fetch fails', async () => { + expect.assertions(2); + mockFetchPlaylists.mockRejectedValue(new Error('Server error')); + + render(PlaylistsPage); + + await expect.element(page.getByText('Server error')).toBeVisible(); + await expect.element(page.getByRole('button', { name: /Retry/ })).toBeVisible(); + }); + + it('shows new playlist input when clicking New Playlist', async () => { + expect.assertions(2); + mockFetchPlaylists.mockResolvedValue([]); + + render(PlaylistsPage); + + await expect.element(page.getByText('No playlists yet')).toBeVisible(); + + const newBtn = page.getByRole('button', { name: /New Playlist/ }).first(); + await newBtn.click(); + + await expect.element(page.getByPlaceholder('Playlist name...')).toBeVisible(); + }); + + it('page heading is visible', async () => { + expect.assertions(1); + mockFetchPlaylists.mockResolvedValue([]); + + render(PlaylistsPage); + + await expect.element(page.getByRole('heading', { name: 'Playlists' })).toBeVisible(); + }); +}); diff --git a/frontend/src/routes/popular/+page.svelte b/frontend/src/routes/popular/+page.svelte new file mode 100644 index 0000000..3157512 --- /dev/null +++ b/frontend/src/routes/popular/+page.svelte @@ -0,0 +1,39 @@ + + + + Popular Albums - Musicseerr + + +
    +
    + +
    + +
    diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte new file mode 100644 index 0000000..2c099ff --- /dev/null +++ b/frontend/src/routes/profile/+page.svelte @@ -0,0 +1,518 @@ + + + + Profile — MusicSeerr + + +
    +
    +
    +
    + +
    +
    + {#if loading} +
    +
    +
    +
    +
    +
    +
    + {:else if profile} +
    + + +
    + Profile + {#if editingName} +
    + + + +
    + {:else} + + {/if} +
    +
    + {:else if error} +
    + +

    Failed to load profile

    + +
    + {/if} +
    +
    +
    + + {#if !loading && profile} +
    +
    +
    +

    + + Connected Services +

    + +
    + + {#if profile.library_stats.length > 0} +
    +

    + + Your Libraries +

    +
    + {#each profile.library_stats as stats} + {@const SourceIcon = getSourceIcon(stats.source)} +
    +
    +
    + +
    + {stats.source} +
    +
    +
    +
    + + Songs +
    + {formatNumber(stats.total_tracks)} +
    +
    +
    + + Albums +
    + {formatNumber(stats.total_albums)} +
    +
    +
    + + Artists +
    + {formatNumber(stats.total_artists)} +
    +
    + {#if stats.total_size_human} +
    + + {stats.total_size_human} used +
    + {/if} +
    + {/each} +
    +
    + {/if} + +
    + + + Open Settings + +
    +
    +
    + {/if} +
    + + + + + diff --git a/frontend/src/routes/requests/+page.svelte b/frontend/src/routes/requests/+page.svelte new file mode 100644 index 0000000..f6f02a9 --- /dev/null +++ b/frontend/src/routes/requests/+page.svelte @@ -0,0 +1,511 @@ + + +
    +
    +
    +

    Requests

    +

    + Track your album requests and downloads +

    +
    + {#if activeCount > 0} +
    + {#if downloadingCount > 0} + + + {downloadingCount} downloading + + {/if} + {#if pendingCount > 0} + + + {pendingCount} searching + + {/if} +
    + {/if} +
    + +
    + + +
    + + {#if activeTab === 'active'} +
    + {#if activeError} +
    + + {activeError} + +
    + {/if} + + {#if activeLoading && activeItems.length === 0} +
    + {#each Array(3) as _, i} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {/each} +
    + {:else if activeItems.length === 0} +
    +
    + +
    +

    All clear

    +

    + No active downloads. Search for albums and request them to see progress here. +

    +
    + {:else} +
    + {#each activeItems as item, i (item.musicbrainz_id)} +
    + +
    + {/each} +
    + {/if} +
    + + {:else} +
    +
    + + + + +
    + + {#if historyTotalPages > 1} + + {/if} +
    + + {#if historyError} +
    + {historyError} + +
    + {/if} + + {#if historyLoading && historyItems.length === 0} +
    + {#each Array(5) as _, i} +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + {/each} +
    + {:else if historyItems.length === 0} +
    +
    + +
    +

    No history yet

    +

    + Completed and failed requests will appear here. +

    +
    + {:else} +
    + {#each historyItems as item (item.musicbrainz_id)} + + {/each} +
    + + {#if historyTotalPages > 1} +
    + +
    + {/if} + {/if} +
    + {/if} +
    + + + + diff --git a/frontend/src/routes/search/+page.svelte b/frontend/src/routes/search/+page.svelte new file mode 100644 index 0000000..d457f4c --- /dev/null +++ b/frontend/src/routes/search/+page.svelte @@ -0,0 +1,368 @@ + + +{#if hasSearched || isSearching} +
    +
    + + + +
    +
    +{/if} + +{#if isSearching && !hasResults} +
    +
    +

    Artists

    +
    +
    + {#each Array(6) as _, i} + + {/each} +
    +
    +
    + +
    +
    +

    Albums

    +
    +
    +
    + {#each Array(6) as _, i} + + {/each} +
    +
    +
    +
    +{:else if hasSearched} +
    + {#if hasTopResult && !isSearching} +
    + {#if topArtist} + + {/if} + {#if topAlbum} + + {/if} +
    + {/if} + +
    +

    Artists

    + {#if loadingArtists} +
    +
    + {#each Array(6) as _, i} + + {/each} +
    +
    + {:else if displayedArtists.length > 0} +
    +
    + + {#each displayedArtists.slice(0, 5) as artist (artist.musicbrainz_id)} + + {/each} +
    +
    + {:else} +
    No artists found
    + {/if} +
    + +
    +
    +

    Albums

    + {#if displayedAlbums.length > 0} + + View more + + {/if} +
    + {#if loadingAlbums} +
    +
    + {#each Array(6) as _, i} + + {/each} +
    +
    + {:else if displayedAlbums.length > 0} +
    +
    + + {#each displayedAlbums as album (album.musicbrainz_id)} + + {/each} +
    +
    + {:else} +
    No albums found
    + {/if} +
    +
    +{:else} +

    Enter a search query to get started.

    +{/if} + +{#if showToast} +
    +
    + + Added to Library +
    +
    +{/if} diff --git a/frontend/src/routes/search/+page.ts b/frontend/src/routes/search/+page.ts new file mode 100644 index 0000000..4b129ac --- /dev/null +++ b/frontend/src/routes/search/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url }) => { + const q = url.searchParams.get('q') ?? ''; + return { query: q }; +}; diff --git a/frontend/src/routes/search/albums/+page.svelte b/frontend/src/routes/search/albums/+page.svelte new file mode 100644 index 0000000..7c588f8 --- /dev/null +++ b/frontend/src/routes/search/albums/+page.svelte @@ -0,0 +1,291 @@ + + +
    +
    + + + +
    +
    + +
    + {#if !data.query} +

    Enter a search query to get started.

    + {:else if loading && albums.length === 0} +
    +
    + {#each Array(12) as _, i} + + {/each} +
    +
    + {:else if albums.length === 0 && !loading} +
    No albums found
    + {:else} + {#if topAlbum} +
    + +
    + {/if} +
    +
    + {#each topAlbum ? albums.filter((a) => a.musicbrainz_id !== topAlbum?.musicbrainz_id) : albums as album (album.musicbrainz_id)} + + {/each} +
    +
    + +
    + {#if loading} + + {:else if !hasMore} +

    No more results

    + {/if} +
    + {/if} +
    + +{#if showToast} +
    +
    + + Added to Library +
    +
    +{/if} diff --git a/frontend/src/routes/search/albums/+page.ts b/frontend/src/routes/search/albums/+page.ts new file mode 100644 index 0000000..8d1bbcc --- /dev/null +++ b/frontend/src/routes/search/albums/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ url }) => { + const query = url.searchParams.get('q') || ''; + return { + query + }; +}; diff --git a/frontend/src/routes/search/artists/+page.svelte b/frontend/src/routes/search/artists/+page.svelte new file mode 100644 index 0000000..e30f825 --- /dev/null +++ b/frontend/src/routes/search/artists/+page.svelte @@ -0,0 +1,269 @@ + + +
    +
    + + + +
    +
    + +
    + {#if !data.query} +

    Enter a search query to get started.

    + {:else if loading && artists.length === 0} +
    +
    + {#each Array(12) as _, i} + + {/each} +
    +
    + {:else if artists.length === 0 && !loading} +
    No artists found
    + {:else} + {#if topArtist} +
    + +
    + {/if} +
    +
    + {#each topArtist ? artists.filter((a) => a.musicbrainz_id !== topArtist?.musicbrainz_id) : artists as artist (artist.musicbrainz_id)} + + {/each} +
    +
    + +
    + {#if loading} + + {:else if !hasMore} +

    No more results

    + {/if} +
    + {/if} +
    diff --git a/frontend/src/routes/search/artists/+page.ts b/frontend/src/routes/search/artists/+page.ts new file mode 100644 index 0000000..8d1bbcc --- /dev/null +++ b/frontend/src/routes/search/artists/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ url }) => { + const query = url.searchParams.get('q') || ''; + return { + query + }; +}; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte new file mode 100644 index 0000000..bfb2807 --- /dev/null +++ b/frontend/src/routes/settings/+page.svelte @@ -0,0 +1,157 @@ + + +
    +
    +
    +

    Settings

    +

    Manage your preferences and application settings

    +
    + +
    + + +
    + {#if activeTab === 'settings'} + + {:else if activeTab === 'music-source'} + + {:else if activeTab === 'cache'} + + {:else if activeTab === 'lidarr-connection'} + + {:else if activeTab === 'lidarr'} + + {:else if activeTab === 'jellyfin'} + + {:else if activeTab === 'navidrome'} + + {:else if activeTab === 'listenbrainz'} + + {:else if activeTab === 'youtube'} + + {:else if activeTab === 'local-files'} + + {:else if activeTab === 'lastfm'} + + {:else if activeTab === 'scrobbling'} + + {:else if activeTab === 'advanced'} + + {/if} +
    +
    +
    +
    diff --git a/frontend/src/routes/trending/+page.svelte b/frontend/src/routes/trending/+page.svelte new file mode 100644 index 0000000..f2eb5b5 --- /dev/null +++ b/frontend/src/routes/trending/+page.svelte @@ -0,0 +1,39 @@ + + + + Trending Artists - Musicseerr + + +
    +
    + +
    + +
    diff --git a/frontend/src/routes/your-top/+page.svelte b/frontend/src/routes/your-top/+page.svelte new file mode 100644 index 0000000..d84e27d --- /dev/null +++ b/frontend/src/routes/your-top/+page.svelte @@ -0,0 +1,39 @@ + + + + Your Top Albums - Musicseerr + + +
    +
    + +
    + +
    diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000..05b49cd Binary files /dev/null and b/frontend/static/favicon.png differ diff --git a/frontend/static/img/album_bg.png b/frontend/static/img/album_bg.png new file mode 100644 index 0000000..f39c041 Binary files /dev/null and b/frontend/static/img/album_bg.png differ diff --git a/frontend/static/img/artist_bg.png b/frontend/static/img/artist_bg.png new file mode 100644 index 0000000..d665521 Binary files /dev/null and b/frontend/static/img/artist_bg.png differ diff --git a/frontend/static/img/cover-placeholder.jpg b/frontend/static/img/cover-placeholder.jpg new file mode 100644 index 0000000..b26cb66 Binary files /dev/null and b/frontend/static/img/cover-placeholder.jpg differ diff --git a/frontend/static/logo.png b/frontend/static/logo.png new file mode 100644 index 0000000..70df196 Binary files /dev/null and b/frontend/static/logo.png differ diff --git a/frontend/static/logo_wide.png b/frontend/static/logo_wide.png new file mode 100644 index 0000000..63d1f4e Binary files /dev/null and b/frontend/static/logo_wide.png differ diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..69ae9b4 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,22 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false + }), + paths: { + base: '' + }, + appDir: '_app' + } +}; + +export default config; diff --git a/frontend/tailwind.config.mjs b/frontend/tailwind.config.mjs new file mode 100644 index 0000000..e44cc96 --- /dev/null +++ b/frontend/tailwind.config.mjs @@ -0,0 +1,5 @@ +import daisyui from 'daisyui'; + +export default { + plugins: [daisyui] +}; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a5567ee --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d1cbcf5 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig } from 'vitest/config'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: true, + test: { + name: 'client', + environment: 'browser', + browser: { + enabled: true, + headless: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'], + setupFiles: ['./vitest-setup-client.ts'] + } + }, + { + extends: true, + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +}); diff --git a/frontend/vitest-setup-client.ts b/frontend/vitest-setup-client.ts new file mode 100644 index 0000000..570b9f0 --- /dev/null +++ b/frontend/vitest-setup-client.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5c876b1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[tool.black] +line-length = 100 +target-version = ["py313"] +skip-string-normalization = false +color = true + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +combine_as_imports = true +line_length = 100 +known_first_party = ["backend"] +default_section = "THIRDPARTY" + +[tool.ruff] +# Keep this aligned with Black/isort +target-version = "py313" +src = ["backend"] + +[tool.ruff.lint] +select = ["BLE001"]