diff --git a/.dockerignore b/.dockerignore index a2fff9fe..04fd9869 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,10 +9,15 @@ !backend/beets_flask !backend/pyproject.toml +!backend/uv.lock !backend/main.py !backend/launch_*.py !backend/generate_types.py +# DB migrations +!backend/alembic +!backend/alembic.ini + !configs/ !frontend/src/ diff --git a/.github/workflows/changelog_reminder.yml b/.github/workflows/changelog_reminder.yml index dd5cb4f9..1e5956bf 100644 --- a/.github/workflows/changelog_reminder.yml +++ b/.github/workflows/changelog_reminder.yml @@ -13,7 +13,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Get all updated Python files id: changed-files diff --git a/.github/workflows/docker_hub.yml b/.github/workflows/docker_hub.yml index b4dcfade..3ed568b7 100644 --- a/.github/workflows/docker_hub.yml +++ b/.github/workflows/docker_hub.yml @@ -37,7 +37,7 @@ jobs: type=raw,value=latest,enable=true - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml index d207df1a..a14befe1 100644 --- a/.github/workflows/javascript.yml +++ b/.github/workflows/javascript.yml @@ -2,12 +2,15 @@ name: Javascript checks on: push: - branches: ["main"] + branches: + - main + - release/** # all release branches paths: - frontend/** pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] + branches: + - main + - release/** # all release branches workflow_dispatch: jobs: @@ -23,7 +26,7 @@ jobs: node-version: ["22.20.0"] steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index cdf8c0e8..6c201ba2 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -2,47 +2,49 @@ name: Python checks on: push: - branches: ["main"] + branches: + - main + - release/** paths: - backend/** pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] + branches: + - main + - release/** jobs: python: name: Python checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install Python - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - python-version: "3.11" + python-version: "3.12" - name: Install dependencies run: | cd ./backend - python -m pip install --upgrade pip - pip install ruff - pip install .[typed,test] + uv sync --all-extras --dev - name: Check style with Ruff continue-on-error: true id: ruff run: | cd ./backend - ruff check --output-format=github . + uv run ruff check --output-format=github . - name: Check type hints with mypy continue-on-error: true id: mypy run: | cd ./backend - mypy --show-error-codes --check-untyped-defs --config-file ./pyproject.toml . + uv run mypy --show-error-codes --check-untyped-defs --config-file ./pyproject.toml . - name: Test with pytest env: PYTEST_ADDOPTS: "--color=yes" run: | cd ./backend - coverage run -m pytest -v + uv run coverage run -m pytest -v --benchmark-skip -W ignore::ResourceWarning - name: Check for failures if: steps.ruff.outcome == 'failure' || steps.mypy.outcome == 'failure' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d16f41..780f2203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - Upcoming + +### ⚠️ Breaking Changes ⚠️ + +- **Base image changed**: The container base image is now `python:3.12-slim` (previously `python:3.11-alpine`). If you use a custom `startup.sh`, please verify compatibility, as Alpine-specific tooling and shell behavior may differ [#212](https://github.com/pSpitzner/beets-flask/issues/212) +- Changed config option: `gui.inbox.folders.your_inbox.autotag` no longer accepts `false`, use `"off"` instead. (This was needed for consistency for the new config validation) + +### Added + +- Config validation. When loading config files we now check that specified options will work. If not, the frontend will show an error message with details on what's wrong. This applies to `gui` settings (i.e. our own ones, `beets-flask/config.yaml`) and very select ones from native beets (only those which we use directly). Hopefully, this will eventually cover all config options of beets native, but this is more of an upsream task. [#224](https://github.com/pSpitzner/beets-flask/pull/224). +- Upload Files via the WebUI. You can now drag-and-drop single files into an inbox. To upload whole albums, zip them on your host first (uploading of folders directly is not implemented, as it would require a secure context). +- New config option `gui.terminal.enabled` (default: true) [#254](https://github.com/pSpitzner/beets-flask/pull/224) +- You can now alternatively use `PUID` and `PGID` instead of `GROUP_ID` and `USER_ID` environment variables. Good for [yaml anchors](https://docs.docker.com/reference/compose-file/fragments/). [#260](https://github.com/pSpitzner/beets-flask/issues/260) +- Use an external redis server by setting the `REDIS_URL` environment variable. [#277](https://github.com/pSpitzner/beets-flask/pull/277) + +### Fixed + +- Missing library stats dont cause a crash on first launch anymore [#264](https://github.com/pSpitzner/beets-flask/issues/264) +- Fixed a potential memory leak when checking if files are archives. We now only check the file extension instead of trying to open the file, which should avoid the issue with `tarfile.is_tarfile` [#258](https://github.com/pSpitzner/beets-flask/issues/258) +- Fixed tmux terminal could not start in some environments if `SHELL` was not set currently. We now always start a bash shell [#282](https://github.com/pSpitzner/beets-flask/issues/282) +- Container user `beetle` now uses bash terminal by default and activates the uv environment automatically. + + +### Other (dev) + +- We now use `uv` to manage python dependencies and run scripts in CI/CD. This should improve dependency resolution and installation times. +- We now ship a static ffmpeg binary instead of installing ffmpeg via apt. This should reduce image size and improve compatibility across different host systems. +- Added a database migration setup using [Alembic](https://alembic.sqlalchemy.org/) for future database migrations. +- Upgraded `beets` from `v2.5.1` to `v2.6.1` +- Removed unnecessary `nest_asyncio` dependency. + ## [1.2.0] - 25-12-17 ### ⚠️ Important ⚠️ @@ -39,15 +70,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The default beets config now includes a `musicbrainz` section that enables fetching of external ids (like tidal). - Fixed typing issues in `./tests` folder and enabled mypy check for it. - Ruff now has the F401 (imported but unused) check enabled. -- Ruff now had the UP checks enabled to enforce modern python syntax. +- Ruff now has the UP checks enabled to enforce modern python syntax. - Unified coverart components in the frontend, we now use common styling for external and internal coverart. - Moved inbox metadata fetching into the library api routes. -- Frontend formatter prettier is now enforced via a CI/CD workflow step +- Frontend formatter prettier is now enforced via a CI/CD workflow step ### Dependencies -- Updated `uvicorn` to `0.36.0`. -- Updated `beets` from `2.3.1` over [`2.4.0`](https://github.com/beetbox/beets/releases/tag/v2.4.0) to [`2.5.0`](https://github.com/beetbox/beets/releases/tag/v2.5.0). See the two changelogs! +- Updated `uvicorn` to `0.36.0`. +- Updated `beets` from `2.3.1` over [`2.4.0`](https://github.com/beetbox/beets/releases/tag/v2.4.0) to [`2.5.0`](https://github.com/beetbox/beets/releases/tag/v2.5.0). See the two changelogs! - Updated a number of frontend dependencies, including `react-query`, `react-router`, `vite`, `typescript`, `eslint`, `prettier` and others. This partially required code changes due to breaking changes in these libraries. Should not affect normal usage tho. ## [1.1.3] - 25-09-18 @@ -64,20 +95,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Trailing slashes in configured inbox paths no longer cause crashes. [#182](https://github.com/pSpitzner/beets-flask/issues/182) - The container now sets the `EDITOR` environment variable to `vi` so that `beet edit` and `beet config -e` work out of the box. - ### Dependencies - Updated `beets` to version `2.3.1` - Updated `py2ts` to version `0.6.1`, now uses pypi distribution instead of github repo. - ## [1.1.2] - 25-08-29 ### Fixed - Updated refresh_config to scan all modules for config references and overwrite them as needed to ensure consistency [#188](https://github.com/pSpitzner/beets-flask/issues/188) - ## [1.1.1] - 25-08-15 ### Fixed @@ -98,49 +126,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Support for importing archives `zip` and `tar` files. Support for `rar` and `7z` files can be added via custom startup and requirements files. See the [FAQ](https://beets-flask.readthedocs.io/latest/faq.html) for more information. +- Support for importing archives `zip` and `tar` files. Support for `rar` and `7z` files can be added via custom startup and requirements files. See the [FAQ](https://beets-flask.readthedocs.io/latest/faq.html) for more information. ### Dependencies -- Updated `py2ts` to version `0.4.1` +- Updated `py2ts` to version `0.4.1` ## [1.0.3] - 25-07-29 ### Fixed -- Fixed search results not showing [#161](https://github.com/pSpitzner/beets-flask/issues/161)) -- Fixed search box not clickable on small screens [#162](https://github.com/pSpitzner/beets-flask/issues/162) +- Fixed search results not showing [#161](https://github.com/pSpitzner/beets-flask/issues/161)) +- Fixed search box not clickable on small screens [#162](https://github.com/pSpitzner/beets-flask/issues/162) ## [1.0.2] - 25-07-21 ### Fixed -- Artists separators were not regex escaped correctly, leading to issues with artists containing special characters. Additionally an empty list of separators was not handled correctly. [#159](https://github.com/pSpitzner/beets-flask/issues/159) - +- Artists separators were not regex escaped correctly, leading to issues with artists containing special characters. Additionally an empty list of separators was not handled correctly. [#159](https://github.com/pSpitzner/beets-flask/issues/159) ## [1.0.1] - 25-07-17 ### Added -- Configuration option for artist separator characters `gui.library.artist_separator` -- Docs subpage for configuration (including content) -- `typing_extensions` is now a dependency, to allow for more typing features -- The model api routes now allows for `DELETE` requests to delete resources by id. Not used yet but will be helpful for future features. +- Configuration option for artist separator characters `gui.library.artist_separator` +- Docs subpage for configuration (including content) +- `typing_extensions` is now a dependency, to allow for more typing features +- The model api routes now allows for `DELETE` requests to delete resources by id. Not used yet but will be helpful for future features. ### Fixed -- Styling of candidate overview (major changes were not colored) -- For bootlegs, display of track changes after import no longer broken -- Navigating from inbox into folder details no longer toggles selection. -- Padding issue where navbar could block content on mobile. -- Cache invalidation now triggers on delete folder in frontend [#138](https://github.com/pSpitzner/beets-flask/issues/138) -- In albums and items view the clicking on artists does not return any results if the contained a separator character (e.g. `&`) [#132](https://github.com/pSpitzner/beets-flask/issues/138) -- Cleanup old actions.tsx file, which included old unused code [#134](https://github.com/pSpitzner/beets-flask/issues/134) -- The `cli_exit` event is now triggered after the import task is finished. This adds compatibility with some plugins which expected this event to be triggered after the import task is done. [#154](https://github.com/pSpitzner/beets-flask/issues/154). +- Styling of candidate overview (major changes were not colored) +- For bootlegs, display of track changes after import no longer broken +- Navigating from inbox into folder details no longer toggles selection. +- Padding issue where navbar could block content on mobile. +- Cache invalidation now triggers on delete folder in frontend [#138](https://github.com/pSpitzner/beets-flask/issues/138) +- In albums and items view the clicking on artists does not return any results if the contained a separator character (e.g. `&`) [#132](https://github.com/pSpitzner/beets-flask/issues/138) +- Cleanup old actions.tsx file, which included old unused code [#134](https://github.com/pSpitzner/beets-flask/issues/134) +- The `cli_exit` event is now triggered after the import task is finished. This adds compatibility with some plugins which expected this event to be triggered after the import task is done. [#154](https://github.com/pSpitzner/beets-flask/issues/154). ### Changed -- Created `types.py` file to hold custom sqlalchemy types, and moved `IntDictType` there. +- Created `types.py` file to hold custom sqlalchemy types, and moved `IntDictType` there. ## [1.0.0] - 25-07-06 @@ -152,18 +179,18 @@ and the overall architecture. ### Changed -- Migrated backend to quart (the async version of flask) -- Reworked most of the frontend -- Removed interactive imports. We now store states for _any_ preview and import that is generated. Thus, sessions are resumable, and we can go back and forth seemlessly, to e.g. undo an import and pick a better candidate. -- Inbox types have changed. For now we only have `preview`, `auto` and `bootleg`. -- beets updated to version 2.2.0 -- Implemented our own async pipeline for beets, that is typed and handles our custom sessions (should become obsolete once upstream PRs are merged). -- Improved library view, and track preview / streaming. -- Improved candidate preview, including cover art and asis details (current metadata). -- Terminal now has a bit of scroll-back and history. -- Much better test coverage. -- Now using [py2ts](https://github.com/semohr/py2ts) to automatically generate frontend (typescript) types from their backend (python) equivalents. -- New and improved logo. +- Migrated backend to quart (the async version of flask) +- Reworked most of the frontend +- Removed interactive imports. We now store states for _any_ preview and import that is generated. Thus, sessions are resumable, and we can go back and forth seemlessly, to e.g. undo an import and pick a better candidate. +- Inbox types have changed. For now we only have `preview`, `auto` and `bootleg`. +- beets updated to version 2.2.0 +- Implemented our own async pipeline for beets, that is typed and handles our custom sessions (should become obsolete once upstream PRs are merged). +- Improved library view, and track preview / streaming. +- Improved candidate preview, including cover art and asis details (current metadata). +- Terminal now has a bit of scroll-back and history. +- Much better test coverage. +- Now using [py2ts](https://github.com/semohr/py2ts) to automatically generate frontend (typescript) types from their backend (python) equivalents. +- New and improved logo. ## [0.1.1] - 25-06-08 @@ -171,87 +198,86 @@ Small version bump with fixes before jumping to 1.0.0. ### Added -- Option to install beets plugins by placing either `requirements.txt` or `startup.sh` in /`config`. cf. [Readthedocs](https://beets-flask.readthedocs.io/en/latest/plugins.html) -- [Documentation](https://beets-flask.readthedocs.io/en/latest/?badge=latest) on readthedocs. -- Option to import Asis via right-click, or as inbox type. Good for Bootlegs that do not - have online meta data and you curate manually. Currently also applies `--group-albums`. +- Option to install beets plugins by placing either `requirements.txt` or `startup.sh` in /`config`. cf. [Readthedocs](https://beets-flask.readthedocs.io/en/latest/plugins.html) +- [Documentation](https://beets-flask.readthedocs.io/en/latest/?badge=latest) on readthedocs. +- Option to import Asis via right-click, or as inbox type. Good for Bootlegs that do not + have online meta data and you curate manually. Currently also applies `--group-albums`. ### Fixed -- Path escaping for right-click import via cli (#51) +- Path escaping for right-click import via cli (#51) ## [0.1.0] - 24-11-13 ### Fixed -- Renamed `kind` to `type` in search frontend code to be consistent with backend. - Using kind for tags (preview, import, auto), and types for search (album, track). +- Renamed `kind` to `type` in search frontend code to be consistent with backend. + Using kind for tags (preview, import, auto), and types for search (album, track). ### Changed -- Improved readme and onboarding experience -- Mountpoint to persist config files and databases changed to `/config` (was `/home/beetle/.config/beets/`) - We create the `/config/beets` and `/config/beets-flask` folders on startup if they do not exist. - Library files are placed there, and you can drop a `config.yaml` either or both of these folders. Settings in `/config/beets-flask/config.yaml` take precedence over `/config/beets/config.yaml`. - **You will need to update your docker-compose!** +- Improved readme and onboarding experience +- Mountpoint to persist config files and databases changed to `/config` (was `/home/beetle/.config/beets/`) + We create the `/config/beets` and `/config/beets-flask` folders on startup if they do not exist. + Library files are placed there, and you can drop a `config.yaml` either or both of these folders. Settings in `/config/beets-flask/config.yaml` take precedence over `/config/beets/config.yaml`. + **You will need to update your docker-compose!** ### Added -- Logo and favicon -- Image now on docker hub: `pspitzner/beets-flask:stable` -- Auto-import: automatically import folders that are added to the inbox if the match is good enough. - After a preview, import will start if the match quality is above the configured. - Enable via the config.yaml, set the `autotag` field of a configred inbox folders to `"auto"`. +- Logo and favicon +- Image now on docker hub: `pspitzner/beets-flask:stable` +- Auto-import: automatically import folders that are added to the inbox if the match is good enough. + After a preview, import will start if the match quality is above the configured. + Enable via the config.yaml, set the `autotag` field of a configred inbox folders to `"auto"`. ## [0.0.4] - 24-10-04 ### Fixed -- Config parsing should now work +- Config parsing should now work ### Added -- multi-disc albums are now supported -- Interactive import using a custom beets pipeline +- multi-disc albums are now supported +- Interactive import using a custom beets pipeline ### Changed -- Moved terminal to its own page, had to temporarily remove keyboard trigger -- Reworked the album folder detection algorithm, now uses more native beets code and is a bit faster -- Navbar styling and items overhaul +- Moved terminal to its own page, had to temporarily remove keyboard trigger +- Reworked the album folder detection algorithm, now uses more native beets code and is a bit faster +- Navbar styling and items overhaul ## [0.0.3] - 24-08-01 ### Fixed -- default config: mandatory fields cannot be set in the yaml, or they - might persist although the user sets them. moved to config loading in python. -- tmux session now restarts on page load if it is not alive. -- navbar, tags, inbox are now more friendly for mobile -- folder paths are now better escaped for terminal imports +- default config: mandatory fields cannot be set in the yaml, or they + might persist although the user sets them. moved to config loading in python. +- tmux session now restarts on page load if it is not alive. +- navbar, tags, inbox are now more friendly for mobile +- folder paths are now better escaped for terminal imports ### Added -- Backend to get cover art from metadata of music files. -- Impoved library view (mobile friendly, and a browser header component) -- Library search +- Backend to get cover art from metadata of music files. +- Impoved library view (mobile friendly, and a browser header component) +- Library search ### Changed -- Simplified folder structure of frontend -- Removed `include_paths` option from config and library backend (most of the frontend needs some form of file paths. thus, the option was not / could not be respected consistently) +- Simplified folder structure of frontend +- Removed `include_paths` option from config and library backend (most of the frontend needs some form of file paths. thus, the option was not / could not be respected consistently) ## [0.0.2] - 24-07-16 ### Fixed -- ESLint errors and Github action -- Now loading the default config +- ESLint errors and Github action +- Now loading the default config ## 0.0.1 - 24-05-22 -- initial commit - +- initial commit [Unreleased]: https://github.com/pSpitzner/beets-flask/compare/v1.2.0...HEAD [1.2.0]: https://github.com/pSpitzner/beets-flask/compare/v1.1.3...v1.2.0 diff --git a/README.md b/README.md index 9e2648aa..551ba0d8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - Undo imports - Web-Terminal - Monitor multiple inboxes +- Drag-and-drop files to upload into inboxes - Library view and search diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..594d0a7c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,14 @@ +# Alembic Configuration +# Docs: https://alembic.sqlalchemy.org/en/latest/index.html + +[alembic] +script_location = %(here)s/alembic +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +prepend_sys_path = . +path_separator = os + +[post_write_hooks] +hooks = ruff +ruff.type = module +ruff.module = ruff +ruff.options = check --fix REVISION_SCRIPT_FILENAME diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..f1f9afbb --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,95 @@ +"""Alembic environment configuration for beets-flask database migrations. + +This module configures Alembic to use the beets-flask database configuration +for both autogenerate support and runtime migrations. +""" + +from alembic import context + +# Import beets_flask database components +from beets_flask.config.flask_config import get_flask_config +from beets_flask.database.models.base import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +# add your model's MetaData object here +# for 'autogenerate' support +# This is crucial for autogenerate to detect model changes +target_metadata = Base.metadata + + +def get_url() -> str: + """Get the database URL from beets-flask configuration. + + Returns + ------- + str: The database connection URI. + + """ + flask_config = get_flask_config() + return flask_config["DATABASE_URI"] + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + from sqlalchemy import engine_from_config, pool + + # Get the database URL from beets-flask config + url = get_url() + + # Create engine configuration with our URL + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = url + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..51179c5b --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,31 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py b/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py new file mode 100644 index 00000000..6e6dc1ae --- /dev/null +++ b/backend/alembic/versions/2026_04_08_1846-a986c03d9ba3_initial.py @@ -0,0 +1,168 @@ +"""initial + +Revision ID: a986c03d9ba3 +Revises: +Create Date: 2026-04-08 18:46:00.556681 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a986c03d9ba3" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + import beets_flask.database.models.types + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "folder", + sa.Column("full_path", sa.String(), nullable=False), + sa.Column("is_album", sa.Boolean(), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("full_path", "id"), + ) + op.create_index( + op.f("ix_folder_created_at"), "folder", ["created_at"], unique=False + ) + op.create_index(op.f("ix_folder_full_path"), "folder", ["full_path"], unique=False) + op.create_table( + "session", + sa.Column("folder_hash", sa.String(), nullable=False), + sa.Column("folder_revision", sa.Integer(), nullable=False), + sa.Column( + "progress", + sa.Enum( + "NOT_STARTED", + "READING_FILES", + "GROUPING_ALBUMS", + "LOOKING_UP_CANDIDATES", + "IDENTIFYING_DUPLICATES", + "PREVIEW_COMPLETED", + "DELETION_COMPLETED", + "OFFERING_MATCHES", + "MATCH_THRESHOLD", + "WAITING_FOR_USER_SELECTION", + "EARLY_IMPORTING", + "IMPORTING", + "MANIPULATING_FILES", + "IMPORT_COMPLETED", + "DELETING", + name="progress", + ), + nullable=False, + ), + sa.Column("exc", sa.LargeBinary(), nullable=True), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["folder_hash"], + ["folder.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "folder_hash", "folder_revision", name="uq_folder_hash_revision" + ), + ) + op.create_index( + op.f("ix_session_created_at"), "session", ["created_at"], unique=False + ) + op.create_table( + "task", + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("chosen_candidate_id", sa.String(), nullable=True), + sa.Column("toppath", sa.LargeBinary(), nullable=True), + sa.Column("paths", sa.LargeBinary(), nullable=False), + sa.Column("old_paths", sa.LargeBinary(), nullable=True), + sa.Column("items", sa.LargeBinary(), nullable=False), + sa.Column( + "choice_flag", + sa.Enum( + "SKIP", "ASIS", "TRACKS", "APPLY", "ALBUMS", "RETAG", name="action" + ), + nullable=True, + ), + sa.Column("cur_artist", sa.String(), nullable=True), + sa.Column("cur_album", sa.String(), nullable=True), + sa.Column( + "progress", + sa.Enum( + "NOT_STARTED", + "READING_FILES", + "GROUPING_ALBUMS", + "LOOKING_UP_CANDIDATES", + "IDENTIFYING_DUPLICATES", + "PREVIEW_COMPLETED", + "DELETION_COMPLETED", + "OFFERING_MATCHES", + "MATCH_THRESHOLD", + "WAITING_FOR_USER_SELECTION", + "EARLY_IMPORTING", + "IMPORTING", + "MANIPULATING_FILES", + "IMPORT_COMPLETED", + "DELETING", + name="progress", + ), + nullable=False, + ), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["chosen_candidate_id"], ["candidate.id"], use_alter=True + ), + sa.ForeignKeyConstraint( + ["session_id"], + ["session.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_task_created_at"), "task", ["created_at"], unique=False) + op.create_table( + "candidate", + sa.Column("task_id", sa.String(), nullable=False), + sa.Column("match", sa.LargeBinary(), nullable=False), + sa.Column("duplicate_ids", sa.String(), nullable=False), + sa.Column( + "mapping", beets_flask.database.models.types.IntDictType(), nullable=False + ), + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["task_id"], + ["task.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_candidate_created_at"), "candidate", ["created_at"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_candidate_created_at"), table_name="candidate") + op.drop_table("candidate") + op.drop_index(op.f("ix_task_created_at"), table_name="task") + op.drop_table("task") + op.drop_index(op.f("ix_session_created_at"), table_name="session") + op.drop_table("session") + op.drop_index(op.f("ix_folder_full_path"), table_name="folder") + op.drop_index(op.f("ix_folder_created_at"), table_name="folder") + op.drop_table("folder") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/2026_04_12_1847-925cf8989fbc_item_pending.py b/backend/alembic/versions/2026_04_12_1847-925cf8989fbc_item_pending.py new file mode 100644 index 00000000..fdbfbd17 --- /dev/null +++ b/backend/alembic/versions/2026_04_12_1847-925cf8989fbc_item_pending.py @@ -0,0 +1,270 @@ +"""item pending + +Revision ID: 925cf8989fbc +Revises: a986c03d9ba3 +Create Date: 2026-04-12 18:47:43.218344 + +README: +Historically, task state items were stored as binary (pickle) blobs in the database. +This approach has proven to be brittle and difficult to maintain. In particular, +changes and upgrades in beets break deserialization, requiring manual +intervention to recover or migrate data. + +For the unpickling to work, we would rely on beets class definitions – which are likely +to change over time. Thus, we have a custom unpickler, and mocked beets classes, which +will give the right structures, even past beets 2.5.1. Beware, this also holds for +our own classes (like BeetsItemType) which we will need to make copies of once we +change them. +""" + +from __future__ import annotations +import base64 +from collections.abc import Sequence +from datetime import datetime +import io +import pickle +from typing import TypeVar +from uuid import uuid4 + +import sqlalchemy as sa +from beets_flask import log +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "925cf8989fbc" +down_revision: str | Sequence[str] | None = "a986c03d9ba3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + + op.create_table( + "items", + sa.Column("id", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("flex_values", sa.JSON(), nullable=False), + sa.Column("fixed_values", sa.JSON(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_items_created_at"), + "items", + ["created_at"], + unique=False, + ) + + op.create_table( + "tasks_items", + sa.Column("id", sa.String(), nullable=False), + sa.Column("task_id", sa.String(), nullable=False), + sa.Column("item_id", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["task_id"], + ["task.id"], + ), + sa.ForeignKeyConstraint( + ["item_id"], + ["items.id"], + ), + ) + op.create_index( + op.f("ix_tasks_items_created_at"), + "tasks_items", + ["created_at"], + unique=False, + ) + + migrate_data() + + op.drop_column("task", "items") + + +def downgrade() -> None: + """Downgrade schema.""" + op.add_column("task", sa.Column("items", sa.BLOB(), nullable=False)) + op.drop_table("tasks_items") + op.drop_table("items") + + +T = TypeVar("T", bound=dict) + + +def _encode(v): + if isinstance(v, bytes): + return { + "__type__": "bytes", + "data": base64.b64encode(v).decode("ascii"), + } + + if isinstance(v, dict): + return {str(k): _encode(val) for k, val in v.items()} + + if isinstance(v, list): + return [_encode(x) for x in v] + + return v + + +def migrate_data(): + conn = op.get_bind() + meta = sa.MetaData() + + tasks_items_table = sa.Table("tasks_items", meta, autoload_with=conn) + items_table = sa.Table("items", meta, autoload_with=conn) + result = conn.execute(sa.text("SELECT id, items FROM task WHERE items IS NOT NULL")) + for row in result: + task_id = row[0] + items_blob = row[1] + + try: + items = load_items(items_blob) + except Exception as e: + log.error(f"Failed to unpickle task {task_id}: {e}") + continue + + item_rows = [] + tasks_items_rows = [] + now = datetime.utcnow() + for stub in items: + item_id = str(uuid4()) + item_rows.append( + { + "id": item_id, + "created_at": now, + "updated_at": now, + "fixed_values": _encode(dict(stub._values_fixed.items())), + "flex_values": _encode(dict(stub._values_flex.items())), + } + ) + tasks_items_rows.append( + { + "id": str(uuid4()), + "created_at": now, + "updated_at": now, + "task_id": task_id, + "item_id": item_id, + } + ) + + if item_rows and tasks_items_rows: + conn.execute( + items_table.insert(), + item_rows, + ) + conn.execute( + tasks_items_table.insert(), + tasks_items_rows, + ) + + +def load_items(blob: bytes) -> list[ModelStub]: + return ItemsUnpickler(io.BytesIO(blob)).load() + + +# --------------------------- Mocked Beets Classes --------------------------- # + + +class ModelStub: + def __init__(self, *args, **kwargs): + self._values_fixed = {} + self._values_flex = {} + self._db = None + + def __setstate__(self, state): + self.__dict__.update(state) + self._model_cls = None # must be reattached externally + + def __getstate__(self): + return self.__dict__ + + # --- mimic beets resolution --- + def __getitem__(self, key): + if "_values_fixed" in self.__dict__ and key in self._values_fixed: + return self._values_fixed[key] + + if "_values_flex" in self.__dict__ and key in self._values_flex: + return self._values_flex[key] + + if key in self.__dict__: + return self.__dict__[key] + + raise KeyError(key) + + def __getattr__(self, name): + try: + return self[name] + except KeyError: + raise AttributeError(name) + + def __setattr__(self, name, value): + # keep internal structure intact + if name in ("_values_fixed", "_values_flex", "_db"): + self.__dict__[name] = value + else: + self.__dict__[name] = value + + +class LazyConvertDictStub: + def __init__(self, *args, **kwargs): + self._data = {} + self._converted = {} + self.model_cls = None # Don't enforce model_cls, keep it flexible + + def __setstate__(self, state): + self.__dict__.update(state) + self.model_cls = None + + def __getstate__(self): + return self.__dict__ + + def __getitem__(self, key): + if key in self._converted: + return self._converted[key] + if key in self._data: + return self._data[key] + raise KeyError(key) + + def __setitem__(self, key, value): + self._converted[key] = value + + def __contains__(self, key): + return key in self._converted or key in self._data + + def keys(self): + return list(self._converted.keys()) + list(self._data.keys()) + + def __iter__(self): + return iter(self.keys()) + + def items(self): + for key in self: + yield key, self[key] + + +class ItemsUnpickler(pickle.Unpickler): + CLASS_MAP = { + ("beets.dbcore.db", "LazyConvertDict"): LazyConvertDictStub, + ("beets.library", "Item"): ModelStub, + ("beets.library.models", "Item"): ModelStub, + } + + def find_class(self, module, name): + """Override the find_class method to redirect Distance class references.""" + key = (module, name) + if key not in self.CLASS_MAP: + log.warning( + "Unknown class not in migration map during item unpickling: %s.%s", + module, + name, + ) + raise pickle.UnpicklingError( + f"Unknown class not in migration map: {module}.{name}" + ) + return self.CLASS_MAP[key] diff --git a/backend/alembic/versions/2026_04_12_2038-f06e470b3d1e_match.py b/backend/alembic/versions/2026_04_12_2038-f06e470b3d1e_match.py new file mode 100644 index 00000000..93f0e7d9 --- /dev/null +++ b/backend/alembic/versions/2026_04_12_2038-f06e470b3d1e_match.py @@ -0,0 +1,372 @@ +"""match + +Revision ID: f06e470b3d1e +Revises: 925cf8989fbc +Create Date: 2026-04-12 20:38:28.263069 + +README: +Historically, candidate states included a pickled match item. This approach has proven +to be brittle and difficult to maintain. This migration implements a more refined +database schema for matches. +""" + +from __future__ import annotations +from collections.abc import Sequence +import importlib.util +import io +from pathlib import Path +import pickle +from typing import Any, NamedTuple + +import sqlalchemy as sa +from sqlalchemy.orm import Session +from beets_flask.logger import logging +from beets_flask.database.models import types +from alembic import op + +# We depend on other migrations (no other easy way to import) +BASE_DIR = Path(__file__).resolve().parent +path = BASE_DIR / "2026_04_12_1847-925cf8989fbc_item_pending.py" +spec = importlib.util.spec_from_file_location("item_pending_migration", path) +if not spec or not spec.loader: + raise ImportError +item_migration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(item_migration) + +# revision identifiers, used by Alembic. +revision: str = "f06e470b3d1e" +down_revision: str | Sequence[str] | None = "925cf8989fbc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +log = logging.getLogger("alembic.runtime.migration") + + +def upgrade() -> None: + """Upgrade schema.""" + # core info table + op.create_table( + "album_info", + sa.Column("data", sa.JSON(), nullable=False), + sa.Column("id", sa.String(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_album_info_created_at", "album_info", ["created_at"]) + + op.create_table( + "track_info", + sa.Column("album_id", sa.String(), sa.ForeignKey("album_info.id")), + sa.Column("data", sa.JSON(), nullable=False), + sa.Column("id", sa.String(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_track_info_created_at", "track_info", ["created_at"]) + + # distance graph + op.create_table( + "distances", + sa.Column("track_info_id", sa.String(), sa.ForeignKey("track_info.id")), + sa.Column("parent_distance_id", sa.String(), sa.ForeignKey("distances.id")), + sa.Column("raw_distance", sa.Float(), nullable=False), + sa.Column("max_distance", sa.Float(), nullable=False), + sa.Column("id", sa.String(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_distances_created_at", "distances", ["created_at"]) + + # matches + op.create_table( + "matches", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("type", sa.String(), nullable=False), + sa.Column( + "distance_id", sa.String(), sa.ForeignKey("distances.id"), nullable=False + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_table( + "matches_album", + sa.Column("id", sa.String(), sa.ForeignKey("matches.id"), primary_key=True), + sa.Column( + "info_id", sa.String(), sa.ForeignKey("album_info.id"), nullable=False + ), + ) + op.create_table( + "matches_track", + sa.Column("id", sa.String(), sa.ForeignKey("matches.id"), primary_key=True), + sa.Column( + "info_id", sa.String(), sa.ForeignKey("track_info.id"), nullable=False + ), + ) + op.create_index("ix_matches_created_at", "matches", ["created_at"]) + + # mappings + op.create_table( + "album_match_track_mappings", + sa.Column( + "album_match_id", + sa.String(), + sa.ForeignKey("matches_album.id"), + nullable=False, + ), + sa.Column("track_info_id", sa.String(), sa.ForeignKey("track_info.id")), + sa.Column("item_id", sa.String(), sa.ForeignKey("items.id")), + sa.Column("id", sa.String(), primary_key=True, nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index( + "ix_album_match_track_mappings_created_at", + "album_match_track_mappings", + ["created_at"], + ) + + # penalties + op.create_table( + "penalties", + sa.Column("key", sa.String(), nullable=False), + sa.Column("value", types.FloatListType(), nullable=False), + sa.Column( + "distance_id", sa.String(), sa.ForeignKey("distances.id"), nullable=False + ), + sa.Column("id", sa.String(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_penalties_created_at", "penalties", ["created_at"]) + op.create_index("ix_penalties_key", "penalties", ["key"]) + + # Migrate candidate table + with op.batch_alter_table("candidate") as batch_op: + batch_op.add_column(sa.Column("match_id", sa.String(), nullable=True)) + + migrate_data() + + with op.batch_alter_table("candidate") as batch_op: + batch_op.drop_column("match") + batch_op.alter_column("match_id", nullable=False) + batch_op.create_foreign_key( + "fk_candidate_match", + "matches", + ["match_id"], + ["id"], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + + # candidate table (SQLite-safe) + with op.batch_alter_table("candidate") as batch_op: + batch_op.drop_constraint( + "fk_candidate_match", + type_="foreignkey", + ) + batch_op.add_column(sa.Column("match", sa.BLOB(), nullable=True)) + batch_op.drop_column("match_id") + + # independent tables + op.drop_table("matches_track") + op.drop_table("matches_album") + op.drop_table("album_match_track_mappings") + + op.drop_table("penalties") + op.drop_table("matches") + op.drop_table("distances") + op.drop_table("track_info") + op.drop_table("album_info") + + +def migrate_data(): + from beets_flask.database.mapper.match import ( + AlbumMatchMapper, + TrackMatchMapper, + Context, + ) + + conn = op.get_bind() + session = Session(bind=conn) + + result = conn.execution_options(stream_results=True).execute( + sa.text("SELECT id, match FROM candidate WHERE match IS NOT NULL") + ) + total = conn.execute( + sa.text("SELECT COUNT(*) FROM candidate WHERE match IS NOT NULL") + ).scalar() + for i, row in enumerate(result, start=1): + if i % 100 == 0: + log.info("Migrating matches %d / %d rows", i, total) + + candidate_id = row[0] + match_blob = row[1] + + if not match_blob: + continue + + try: + beets_match = load_match(match_blob) + + # A bit of an anti patter here but easiest way out: + # We depend on our mappers here and hope they do not change in the future + db_match: Any + if isinstance(beets_match, AlbumMatchStub): + db_match = AlbumMatchMapper().from_beets( + beets_match, # type: ignore[arg-type] + Context(), + ) + + else: + db_match = TrackMatchMapper().from_beets( + beets_match, # type: ignore[arg-type] + Context(), + ) + + session.add(db_match) + session.flush() # gets db_match.id + + conn.execute( + sa.text("UPDATE candidate SET match_id = :match_id WHERE id = :id"), + {"match_id": db_match.id, "id": candidate_id}, + ) + + except Exception: + log.exception("Failed to migrate candidate %s", candidate_id) + raise + + log.info("Migrated %d / %d matches!", total, total) + + +def load_match(blob: bytes) -> AlbumMatchStub | TrackMatchStub: + return MatchUnpickler(io.BytesIO(blob)).load() + + +# --------------------------- Mocked Beets Classes --------------------------- # + + +class AttributeDictStub: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def __getstate__(self): + return self.__dict__.copy() + + def __setstate__(self, state): + self.__dict__.update(state) + + def __setitem__(self, key, value): + self.__dict__[key] = value + + def __getitem__(self, key): + return self.__dict__[key] + + def keys(self): + return self.__dict__.keys() + + def values(self): + return self.__dict__.values() + + def items(self): + return self.__dict__.items() + + +class DistanceStub: + def __init__(self): + self._penalties = {} + self.tracks = {} + self._raw_distance = 0.0 # Use private backing field + self._max_distance = 0.0 + + @property + def raw_distance(self) -> float: + return self._raw_distance + + @raw_distance.setter + def raw_distance(self, value: float): + self._raw_distance = value + + @property + def max_distance(self) -> float: + return self._max_distance + + @max_distance.setter + def max_distance(self, value: float): + self._max_distance = value + + def __getstate__(self): + return { + "_penalties": self._penalties, + "tracks": self.tracks, + "_raw_distance": self._raw_distance, + "_max_distance": self._max_distance, + } + + def __setstate__(self, state): + self._penalties = state.get("_penalties", {}) + self.tracks = state.get("tracks", {}) + self._raw_distance = state.get("_raw_distance", 0.0) + self._max_distance = state.get("_max_distance", 0.0) + + +class AlbumMatchStub(NamedTuple): + distance: DistanceStub + info: AttributeDictStub + mapping: dict[Any, AttributeDictStub] # Any = item_migration.ModelStub + extra_items: list[Any] + extra_tracks: list[AttributeDictStub] + + +class TrackMatchStub(NamedTuple): + distance: DistanceStub + info: AttributeDictStub + + +class MatchUnpickler(pickle.Unpickler): + CLASS_MAP = { + ("beets.dbcore.db", "LazyConvertDict"): item_migration.LazyConvertDictStub, + ("beets.library", "Item"): item_migration.ModelStub, + ("beets.library.models", "Item"): item_migration.ModelStub, + ("beets.autotag.hooks", "AlbumMatch"): AlbumMatchStub, + ("beets.autotag.hooks", "Distance"): DistanceStub, + ("beets.autotag.hooks", "TrackInfo"): AttributeDictStub, + ("beets.autotag.hooks", "AlbumInfo"): AttributeDictStub, + ("beets.autotag.distance", "Distance"): DistanceStub, + ("beetsplug.discogs", "IntermediateTrackInfo"): AttributeDictStub, + } + + def find_class(self, module, name): + """Override the find_class method to redirect Distance class references.""" + key = (module, name) + if key not in self.CLASS_MAP: + print(f"WARNING: Unknown class not in migration map: {module}.{name}") + return dict # Fallback for unknown classes + return self.CLASS_MAP[key] + + def load(self) -> Any: + object = super().load() + if isinstance(object, DistanceStub): + self._normalize(object) + + if isinstance(object, AlbumMatchStub): + self._normalize(object.distance) + + return object + + def _normalize(self, obj): + if isinstance(obj, DistanceStub): + return self._normalize_distance(obj) + return obj + + def _normalize_distance(self, distance: DistanceStub) -> DistanceStub: + # Beets had a rename at some point which we need to handle here. + if "source" in distance._penalties: + distance._penalties["data_source"] = distance._penalties.pop("source") + + for _, child in distance.tracks.items(): + self._normalize_distance(child) + + return distance diff --git a/backend/beets_flask/config/beets_config.py b/backend/beets_flask/config/beets_config.py index 196640ea..859ea7dd 100644 --- a/backend/beets_flask/config/beets_config.py +++ b/backend/beets_flask/config/beets_config.py @@ -1,173 +1,268 @@ -"""Overload for beets configuration. - -We support setting config values either via your beets config file, under the `gui` section, or via environment variables in the Docker compose. - -Use double underscore to separate nested values: -https://confuse.readthedocs.io/en/latest/usage.html#environment-variables - -We prefix all environment variables with `IB` to avoid conflicts with other services. - -To set a custom file path to a yaml that gets inserted into (and overwrites) the -beets config, set the `IB_GUI_CONFIGPATH` environment variable. -Note that this does not remove list keys from the lower priority default config -(e.g. if you configure different inbox folders in your beets config, and the ib config, -all of them will be added). - -# Example: - -```bash -export IB_GUI__TAGS=first -``` - -```python -from beets_flask.beets_config.config import config -print(config["gui"]["tags"].get(default="default_value")) -``` -""" +from __future__ import annotations import os +import shutil import sys -from typing import cast +from pathlib import Path +from typing import Literal, Self, cast -from beets import IncludeLazyConfig as BeetsConfig +import beets +import yaml from beets.plugins import _instances as plugin_instances from beets.plugins import get_plugin_names, load_plugins -from confuse import YamlSource +from eyconf import ConfigExtra +from eyconf.asdict import asdict_with_aliases +from eyconf.validation import ConfigurationError, MultiConfigurationError from beets_flask.logger import log +from beets_flask.utility import deprecation_warning +from .schema import BeetsSchema -def _copy_file(src, dest): - with open(src) as src_file, open(dest, "w") as dest_file: - dest_file.write(src_file.read()) +_BEETS_EXAMPLE_PATH = Path(os.path.dirname(__file__)) / "config_b_example.yaml" +_BF_EXAMPLE_PATH = Path(os.path.dirname(__file__)) / "config_bf_example.yaml" -class Singleton(type): - _instances: dict = {} +class BeetsFlaskConfig(ConfigExtra[BeetsSchema]): + """Base config class with extra fields support.""" - def __call__(cls, *args, **kwargs): - """Singleton pattern implementation.""" - if cls not in cls._instances: - cls._instances[cls] = super().__call__(*args, **kwargs) - return cls._instances[cls] + def __init__(self): + """Initialize the config object with the default values.""" + super().__init__(schema=BeetsSchema, data=BeetsSchema()) + BeetsFlaskConfig.write_examples_as_user_defaults() + self.reload() + self.commit_to_beets() + + @classmethod + def get_beets_flask_config_path(cls) -> Path: + """Get the path to the beets-flask config file.""" + bf_folder = os.getenv( + "BEETSFLASKDIR", os.path.expanduser("~/.config/beets-flask") + ) + return Path(bf_folder) / "config.yaml" + @classmethod + def get_beets_config_path(cls) -> Path: + """Get the path to the beets config file.""" + beets_folder = os.getenv("BEETSDIR", os.path.expanduser("~/.config/beets")) + return Path(beets_folder) / "config.yaml" -class InteractiveBeetsConfig(BeetsConfig, metaclass=Singleton): - """Singleton class to handle the beets config. + def reload(self, extra_yaml_path: str | Path | None = None) -> Self: + """Reset the config to default values. - This class is a subclass of the beets config and adds some - interactive beets specific functionality. - """ + This loads the user config from yaml files after resetting to defaults. - def __init__(self): - """Initialize the config object with the default values. + The `extra_yaml_path` argument is mainly for testing purposes, to add a last + yaml layer with high priority. + """ + log.debug("Resetting/Reloading config") + super().reset() + + # There are 3 potential sources + + # 1. beets defaults + # We do not load them into _out_ config. + # They are still available in the beets_config property. + # But: we want to encourage user to add fields that are accessed + # from _our_ config into the schema. + # Thus only porting requirement: copy the relevant beets default into the schema + + # 2. beets user config + if self.get_beets_config_path().exists(): + with open(self.get_beets_config_path()) as f: + loaded = yaml.safe_load(f) + if not isinstance(loaded, dict): + raise ValueError("Beets config is not a valid YAML dictionary.") + # EYConfs update method also validates against the schema + self.update(loaded) + + # 3. beets-flask user config + if self.get_beets_flask_config_path().exists(): + with open(self.get_beets_flask_config_path()) as f: + loaded = yaml.safe_load(f) + if not isinstance(loaded, dict): + raise ValueError( + "Beets flask config is not a valid YAML dictionary." + ) + self.update(loaded) + + # extra + if extra_yaml_path is not None: + with open(extra_yaml_path) as f: + loaded = yaml.safe_load(f) + if not isinstance(loaded, dict): + raise ValueError("Extra config is not a valid YAML dictionary.") + self.update(loaded) + + self.validate() + return self + + def commit_to_beets(self) -> None: + """ + Insert the current state of self into the native beets config. + + Resets the beets config before inserting the new values. - Loads config and some interactive beets specific tweaks. + Only call manually when needed, i.e. after modifying the bf config. + This is somewhat slow. """ - super().__init__("beets", "beets") - self.reset() - @staticmethod - def get_beets_flask_config_path() -> str: - """Get the path to the beets-flask config file.""" + beets.config.clear() + beets.config.read() - ib_folder = os.getenv("BEETSFLASKDIR") - if ib_folder is None: - ib_folder = os.path.expanduser("~/.config/beets-flask") - os.makedirs(ib_folder, exist_ok=True) - ib_config_path = os.path.join(ib_folder, "config.yaml") - return ib_config_path + # Put our defaults that come from schema at lowest priority + beets.config.add(asdict_with_aliases(BeetsSchema())) - @staticmethod - def get_beets_config_path() -> str: - """Get the path to the beets config file.""" - # TODO: maybe there is a beets function to get the path - beets_folder = os.getenv("BEETSDIR") - if beets_folder is None: - beets_folder = os.path.expanduser("~/.config/beets") - beets_config_path = os.path.join(beets_folder, "config.yaml") - return beets_config_path + # Inserts user config into confuse + beets.config.set(self.to_dict(extra_fields=True)) - def reset(self): - """Recreate the config object. + # Hack: We have to manually load the plugins as this + # is normally done by beets. Clear the list to force + # actual reload. + plugin_instances.clear() + load_plugins() + log.debug(f"Loading plugins: {get_plugin_names()}") - As if the app was just started. - """ - # vanilla beets reset - log.debug(f"Reading beets config from default location") - self.clear() - self.read() - - # read the default config just in case the user config is missing - # or malformed - ib_defaults_path = os.path.join( - os.path.dirname(__file__), "config_bf_default.yaml" - ) - log.debug(f"Reading IB config defaults from {ib_defaults_path}") - default_source = YamlSource(ib_defaults_path, default=True) - self.add(default_source) # .add inserts with lowest priority + # Beets config "Singleton" is not a real singleton, there might be copies + # in different submodules - we need to update all of them. + # TODO: Can we remove this? PS 2025-11-02: I dont think so, because we still do + # not know if plugins make a copy of the beets config when they are initialized. + for module_name, mod in list(sys.modules.items()): + if mod is None: + continue + + if not ( + module_name.startswith("beets") # includes beets and beetsplug + ): + continue + + for attr_name in dir(mod): + try: + if getattr(mod, attr_name) is beets.config: + setattr(mod, attr_name, beets.config) + log.debug(f"Updated config in {module_name}.{attr_name}") + except Exception as e: + log.debug(f"Could not check {module_name}.{attr_name}", exc_info=e) + continue + + def validate(self): + """Validate and sanitize the config data. - # then apply our needed tweaks - # enable env variables - self.set_env(prefix="IB") + We apply some light transformations to make things more convenient. + """ + super().validate() + # make sure to remove trailing slashes from user configured inbox paths + # we could also fix this in the frontend, but this was easier. + missing_folder_errors = [] + for key in self.data.gui.inbox.folders.keys(): + folder = self.data.gui.inbox.folders[key] + if folder.path.endswith("/"): + folder.path = folder.path.rstrip("/") + log.warning(f"Removed trailing slash from inbox path: {folder.path}") + + # Allow more convenient yaml, so users can use the heading instead of name + if folder.name == "_use_heading": + folder.name = key + + # Since we changed the autotag type from False to "off" with v1.2.0, + # Let's fix old configs and warn + if folder.autotag is False: + folder.autotag = "off" + deprecation_warning( + "The inbox autotag setting 'False'", + alt_text="Update your configuration and use 'off' instead.", + ) + + if ( + not Path(folder.path).exists() + and not str(folder.path).startswith( + "/music/beets_flask_config_example/" + ) + # prevent validation errors on our user examples and default value + ): + missing_folder_errors.append( + ConfigurationError( + f"Inbox folder path does not exist: {folder.path}", + section="gui.inbox.folders", + ) + ) + + if len(missing_folder_errors) > 1: + raise MultiConfigurationError(missing_folder_errors) + elif len(missing_folder_errors) == 1: + raise missing_folder_errors[0] + + @classmethod + def write_examples_as_user_defaults(cls): + """Write example config files if they do not exist yet. + + Note that we also place an opinionated example for beets, + because it does not do that itself. + """ # Load config from default location (set via env var) # if it is set otherwise use the default location - ib_config_path = self.get_beets_flask_config_path() + bf_config_path = cls.get_beets_flask_config_path() # Check if the user config exists # if not, copy the example config to the user config location - if not os.path.exists(ib_config_path): + did_copy = False + if not os.path.exists(bf_config_path): + did_copy = True + os.makedirs(os.path.dirname(bf_config_path), exist_ok=True) # Copy the default config to the user config location - log.debug(f"Beets-flask config not found at {ib_config_path}") - log.debug(f"Copying default config to {ib_config_path}") - ib_example_path = os.path.join( - os.path.dirname(__file__), "config_bf_example.yaml" - ) - _copy_file(ib_example_path, ib_config_path) + log.info(f"Beets-flask config not found at {bf_config_path}") + log.info(f"Copying default config to {bf_config_path}") + shutil.copy2(_BF_EXAMPLE_PATH, bf_config_path) # Same check for beets config and copy our default # if it does not exist - beets_config_path = self.get_beets_config_path() + beets_config_path = cls.get_beets_config_path() if not os.path.exists(beets_config_path): - log.debug(f"Beets config not found at {beets_config_path}") - log.debug(f"Copying default config to {beets_config_path}") - beets_example_path = os.path.join( - os.path.dirname(__file__), "config_b_example.yaml" - ) - _copy_file(beets_example_path, beets_config_path) - - # Inserts user config at highest priority - log.debug(f"Reading beets-flask config from {ib_config_path}") - self.set(YamlSource(ib_config_path, default=False)) - - # add placeholders for required keys if they are not configured, - # so the docker container starts and can show some help. - - # beets does not create a config file automatically for the user. Customizations are added as extra layers on the config. - sources = [s for s in beets.config["directory"].resolve()] - if len(sources) == 1: - log.debug( - "Beets is not using a user config. Overwriting the default `directory`." - ) - self["directory"] = "/music/imported" - - # TODO: would be nice to have this in the default config, - # and simply remove it here if other inbox folders have been configured. - # but: I do not know how to remove (some) elements from a confuse config. - if len(self["gui"]["inbox"]["folders"].keys()) == 0: - self["gui"]["inbox"]["folders"]["Placeholder"] = { - "name": "Please check your config!", - "path": "/music/inbox", - "autotag": False, - } + did_copy = True + os.makedirs(os.path.dirname(beets_config_path), exist_ok=True) + log.info(f"Beets config not found at {beets_config_path}") + log.info(f"Copying default config to {beets_config_path}") + shutil.copy2(_BEETS_EXAMPLE_PATH, beets_config_path) + + # To pass validation checks, we also need the folders shown in the config demo + # to be present. Otherwise the frontend wont be usable on first start. + if did_copy: + log.info(f"Creating demo inboxes at /music/beets_flask_config_example/") + for dir in [ + "/music/beets_flask_config_example/imported", + "/music/beets_flask_config_example/inbox_off", + "/music/beets_flask_config_example/inbox_auto", + "/music/beets_flask_config_example/inbox_preview", + ]: + try: + os.makedirs(dir, exist_ok=True) + except OSError: + log.info( + "Could not create beets_flask_config_example directories, " + + "likely because this was not run inside the docker container." + ) + + # ------------------------------ Utility getters ----------------------------- # - # make sure to remove trailing slashes from user configured inbox paths - for folder in self["gui"]["inbox"]["folders"].values(): - fp: str = folder["path"].as_str() # type: ignore - if fp.endswith("/"): - folder["path"] = fp.rstrip("/") - log.debug(f"Removed trailing slash from inbox path: {folder['path']}") + @property + def beets_config(self) -> beets.IncludeLazyConfig: + """Convenience property to get the native beets config.""" + # Aavoid calling refresh_confuse here. We often access the beets config, + # and updating it every time makes things very slow. + return beets.config + + @property + def beets_version(self) -> str: + """Get the current beets version.""" + return beets.__version__ + + @property + def beets_metadata_sources(self) -> list[str]: + """Get the list of enabled metadata source plugins.""" + from beets.metadata_plugins import find_metadata_source_plugins + + return [p.data_source for p in find_metadata_source_plugins()] @property def ignore_globs(self) -> list[str]: @@ -177,69 +272,17 @@ def ignore_globs(self) -> list[str]: If user does not set this in their beets flask config, we use whats in beets. (We do this via a placeholder string "_use_beets_ignore") If the user sets an empty list [], that means no files are ignored. - """ - gui_globs: list[str] | str = get_config()["gui"]["inbox"]["ignore"].get() # type: ignore + gui_globs: list[str] | Literal["_use_beets_ignore"] = self.data.gui.inbox.ignore if gui_globs is None or gui_globs == "_use_beets_ignore": - gui_globs: list[str] = self["ignore"].as_str_seq() # type: ignore - elif isinstance(gui_globs, str): - gui_globs = [gui_globs] - elif isinstance(gui_globs, list): - gui_globs = gui_globs + gui_globs = self.data.ignore return cast(list[str], gui_globs) -# Monkey patch the beets config -import beets - -config: InteractiveBeetsConfig | None = None - - -def refresh_config(): - """Refresh the config object. +config: BeetsFlaskConfig | None = None - This is useful if you want to reload the config after it has been changed. - """ - global config - # Keep reference to old config - old_config = getattr(beets, "config", None) - - config = InteractiveBeetsConfig() - - beets.config = config - sys.modules["beets"].config = config # type: ignore - - # Hack: We have to manually load the plugins as this - # is normally done by beets. Clear the list to force - # actual reload. - plugin_instances.clear() - load_plugins() - log.debug(f"Loading plugins: {get_plugin_names()}") - - # Update any existing references in other modules - for module_name, mod in list(sys.modules.items()): - if mod is None: - continue - - if not ( - module_name.startswith("beets") # includes beets and beetsplug - ): - continue - - for attr_name in dir(mod): - try: - if getattr(mod, attr_name) is old_config: - setattr(mod, attr_name, config) - log.debug(f"Updated config in {module_name}.{attr_name}") - except Exception as e: - log.debug(f"Could not check {module_name}.{attr_name}", exc_info=e) - continue - - return config - - -def get_config(force_refresh=False) -> InteractiveBeetsConfig: +def get_config(force_reload=False, commit_to_beets=False) -> BeetsFlaskConfig: """Get the config object. This is useful if you want to access the config from another module. @@ -248,18 +291,17 @@ def get_config(force_refresh=False) -> InteractiveBeetsConfig: Parameters ---------- - force_refresh : bool - Force a refresh of the config object. - This is useful if you want to be sure that the config is up to date, - should normally only be called if the config was changed in another process. + force_reload : bool + Force a refresh of the config object, including the global beets config. + """ global config - if config is None or force_refresh: - return refresh_config() + if config is None: + config = BeetsFlaskConfig() + return config + if force_reload: + config.reload() + if commit_to_beets: + config.commit_to_beets() return config - - -__all__ = ["refresh_config", "get_config"] - -# raise NotImplementedError("This module should not be imported.") diff --git a/backend/beets_flask/config/config_b_example.yaml b/backend/beets_flask/config/config_b_example.yaml index f55d5d49..fece2d60 100644 --- a/backend/beets_flask/config/config_b_example.yaml +++ b/backend/beets_flask/config/config_b_example.yaml @@ -27,14 +27,14 @@ plugins: [ musicbrainz, # needs to be enabled explicitly since beets 2.4.0 ] -directory: /music/imported +directory: /music/beets_flask_config_example/imported # library: /config/beets/library.db # default location in the container import: move: no copy: yes write: yes - log: /music/last_beets_imports.log + log: /music/beets_flask_config_example/last_beets_imports.log quiet_fallback: skip detail: yes duplicate_action: ask # ask|skip|merge|keep|remove diff --git a/backend/beets_flask/config/config_bf_default.yaml b/backend/beets_flask/config/config_bf_default.yaml deleted file mode 100644 index aae84399..00000000 --- a/backend/beets_flask/config/config_bf_default.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# ------------------------------------------------------------------------------------ # -# DO NOT EDIT THIS FILE # -# ------------------------------------------------------------------------------------ # -# these are the defaults for the gui. -# you must provide your own config and map it to /config/beets-flask/config.yaml -# to get started, see the auto-generated examples in /config/beets-flask/ - -gui: - num_preview_workers: 4 - - library: - readonly: no - artist_separators: [",", ";", "&"] - - terminal: - start_path: "/repo" - - inbox: - ignore: "_use_beets_ignore" - debounce_before_autotag: 30 - concat_nested_folders: yes - expand_files: no diff --git a/backend/beets_flask/config/config_bf_example.yaml b/backend/beets_flask/config/config_bf_example.yaml index be92d1ac..d7b5eb78 100644 --- a/backend/beets_flask/config/config_bf_example.yaml +++ b/backend/beets_flask/config/config_bf_example.yaml @@ -26,12 +26,12 @@ gui: Inbox1: name: "Dummy inbox" - path: "/music/dummy" - autotag: no + path: "/music/beets_flask_config_example/inbox_off" + autotag: "off" # do not automatically trigger tagging and do not automatically import Inbox2: name: "Auto Inbox" - path: "/music/inbox_auto" + path: "/music/beets_flask_config_example/inbox_auto" autotag: "auto" # trigger tag and import if a good match is found based on `auto_threshold` auto_threshold: null @@ -40,6 +40,6 @@ gui: # matches with 90% similarity or better. Inbox3: name: "An Inbox that only generates the previews" - path: "/music/inbox_preview" + path: "/music/beets_flask_config_example/inbox_preview" autotag: "preview" # trigger tag but do not import, recommended for most control diff --git a/backend/beets_flask/config/flask_config.py b/backend/beets_flask/config/flask_config.py index 4895525a..7a313e22 100644 --- a/backend/beets_flask/config/flask_config.py +++ b/backend/beets_flask/config/flask_config.py @@ -38,6 +38,9 @@ class ServerConfig: # FIXME: 2025-06-04 likely we only need this in production. FRONTEND_DIST_DIR = "../frontend/dist/" + # For file uploads + MAX_CONTENT_LENGTH = 2 * 1024 * 1024 * 1024 # 2 GB + # Not sure if this is even used! SECRET_KEY = "secret" diff --git a/backend/beets_flask/config/schema.py b/backend/beets_flask/config/schema.py new file mode 100644 index 00000000..5518e2b5 --- /dev/null +++ b/backend/beets_flask/config/schema.py @@ -0,0 +1,128 @@ +"""We maintain the configuration schema for beets-flask here. + +This also includes schemas for beets sections that we need to access in +beets-flask. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + + +@dataclass +class BeetsSchema: + gui: BeetsFlaskSchema = field(default_factory=lambda: BeetsFlaskSchema()) + + # Besides the beets-flask specific config, we want to ensure type safety + # for those fields of the native beets config that we use ourself. + directory: str = field(default="/music/imported") + ignore: list[str] = field( + default_factory=lambda: [".*", "*~", "System Volume Information", "lost+found"] + ) + plugins: list[str] = field(default_factory=lambda: ["musicbrainz"]) + + # `import` is a reserved keyword in Python. Eyconf's workaround is an alias. + import_: ImportSection = field( + default_factory=lambda: ImportSection(), + metadata={"alias": "import"}, # the alias is used in yaml and dict-style access + ) + match: MatchSectionSchema = field(default_factory=lambda: MatchSectionSchema()) + + +# ---------------------------------------------------------------------------- # +# Beets # +# ---------------------------------------------------------------------------- # + + +@dataclass +class ImportSection: + duplicate_action: Literal["ask", "skip", "merge", "keep", "remove"] = "remove" + move: Literal[False] = False # beets-flask does not support the move option + copy: Literal[True] = True # let's shape expectations via config + duplicate_keys: ImportDuplicateKeys = field( + default_factory=lambda: ImportDuplicateKeys() + ) + + +@dataclass +class ImportDuplicateKeys: + # legacy compatibility, beets uses a space-delimited str sequence + # we could make a PR in beets to change the defaults + # in confuse, when using .str_seq both syntaxes in yaml work + # but `.get` will give different results + album: str | list[str] = field(default_factory=lambda: ["albumartist", "album"]) + item: str | list[str] = field(default_factory=lambda: ["artist", "title"]) + + +@dataclass +class MatchSectionSchema: + strong_rec_thresh: float = field(default=0.04) + medium_rec_thresh: float = field(default=0.10) + + +# ---------------------------------------------------------------------------- # +# Beets Flask # +# ---------------------------------------------------------------------------- # + + +@dataclass +class BeetsFlaskSchema: + """Beets-flask specific configuration schema.""" + + inbox: InboxSectionSchema = field(default_factory=lambda: InboxSectionSchema()) + library: LibrarySectionSchema = field( + default_factory=lambda: LibrarySectionSchema() + ) + terminal: TerminalSectionSchema = field( + default_factory=lambda: TerminalSectionSchema() + ) + num_preview_workers: int = field(default=4) + + +# ----------------------------------- Inbox ---------------------------------- # + + +@dataclass +class InboxSectionSchema: + ignore: list[str] | Literal["_use_beets_ignore"] = "_use_beets_ignore" + # File patterns to ignore when scanning the inbox folders. + # Useful to exclude temporary files from being shown in the inbox. + # To show all files (independent of which files beets will copy) set to [] + debounce_before_autotag: int = 30 + temp_dir: str = field(default="/tmp/beets-flask/upload") + folders: dict[str, InboxFolderSchema] = field( + default_factory=lambda: { + "placeholder": InboxFolderSchema( + name="Please check your config!", + path="/music/beets_flask_config_example/inbox_placeholder", + autotag="off", + ) + } + ) + + +@dataclass +class InboxFolderSchema: + path: str + name: str = "_use_heading" + auto_threshold: float | None = None + autotag: Literal["auto", "preview", "bootleg", "off"] = "off" + + +# ---------------------------------- Library --------------------------------- # + + +@dataclass +class LibrarySectionSchema: + readonly: bool = False + artist_separators: list[str] = field(default_factory=lambda: [",", ";", "&"]) + + +# --------------------------------- Terminal --------------------------------- # + + +@dataclass +class TerminalSectionSchema: + enabled: bool = True + start_path: str = "/repo" diff --git a/backend/beets_flask/database/__init__.py b/backend/beets_flask/database/__init__.py index 78d3b6a7..7cb26efc 100644 --- a/backend/beets_flask/database/__init__.py +++ b/backend/beets_flask/database/__init__.py @@ -1,7 +1,6 @@ -from .setup import db_session_factory, setup_database, with_db_session +from .setup import db_session_factory, setup_database __all__ = [ "setup_database", "db_session_factory", - "with_db_session", ] diff --git a/backend/beets_flask/database/mapper/base.py b/backend/beets_flask/database/mapper/base.py new file mode 100644 index 00000000..23dfe10a --- /dev/null +++ b/backend/beets_flask/database/mapper/base.py @@ -0,0 +1,63 @@ +from typing import Any, Protocol, TypeVar + +B = TypeVar("B") # beets type +M = TypeVar("M") # model type + + +class Context: + """Shared mapping context used during bidirectional conversion. + + This context provides identity-based caching to avoid duplicate + object reconstruction and to preserve reference consistency + during recursive mappings. + """ + + def __init__(self): + self.from_cache: dict[int, Any] = {} + self.to_cache: dict[int, Any] = {} + + +class BeetsMapper(Protocol[B, M]): + """Protocol for bidirectional mapping between Beets objects and models. + + This mapper provides cached conversion in both directions: + - Beets → Model via `from_beets` + - Model → Beets via `to_beets` + + Identity-based caching (via `id()`) ensures: + - stable object graphs during recursive mapping + - prevention of infinite recursion + - consistent reuse of already-mapped instances + + Subclasses must implement: + - `_from_beets` + - `_to_beets` + """ + + def from_beets(self, obj: B, ctx: Context) -> M: + """Convert a Beets object into a model instance with caching.""" + key = id(obj) + if key in ctx.from_cache: + return ctx.from_cache[key] + + result = self._from_beets(obj, ctx) + ctx.from_cache[key] = result + return result + + def to_beets(self, model: M, ctx: Context) -> B: + """Convert a model instance back into a Beets object with caching.""" + key = id(model) + if key in ctx.to_cache: + return ctx.to_cache[key] + + result = self._to_beets(model, ctx) + ctx.to_cache[key] = result + return result + + def _from_beets(self, obj: B, ctx: Context) -> M: + """Implement Beets → model conversion.""" + raise NotImplementedError + + def _to_beets(self, model: M, ctx: Context) -> B: + """Implement model → Beets conversion.""" + raise NotImplementedError diff --git a/backend/beets_flask/database/mapper/match.py b/backend/beets_flask/database/mapper/match.py new file mode 100644 index 00000000..6e222abc --- /dev/null +++ b/backend/beets_flask/database/mapper/match.py @@ -0,0 +1,265 @@ +"""Converts beets objects to beetsflask database objects. + +Historically beets objects have quite some cross references which tend to +be difficult to map to a structured database. To avoid drilling and handle +deduplication we use mapper classes with a shared context. +""" + +from __future__ import annotations + +import base64 + +from beets_flask.database.models.pending import Item +from beets_flask.importer.types import ( + BeetsAlbumInfo, + BeetsAlbumMatch, + BeetsDistance, + BeetsItem, + BeetsTrackInfo, + BeetsTrackMatch, +) + +from ..models.match import ( + AlbumInfo, + AlbumMatch, + AlbumMatchTrackMapping, + Distance, + Match, + Penalty, + TrackInfo, + TrackMatch, +) +from .base import BeetsMapper, Context + + +class MatchMapper(BeetsMapper[BeetsAlbumMatch | BeetsTrackMatch, Match]): + def __init__(self): + self.album_mapper = AlbumMatchMapper() + self.track_mapper = TrackMatchMapper() + + def _from_beets( + self, obj: BeetsAlbumMatch | BeetsTrackMatch, ctx: Context + ) -> Match: + if isinstance(obj, BeetsAlbumMatch): + return self.album_mapper.from_beets(obj, ctx) + + if isinstance(obj, BeetsTrackMatch): + return self.track_mapper.from_beets(obj, ctx) + + raise TypeError(f"Unsupported beets obj type: {type(obj)}") + + def _to_beets( + self, model: Match, ctx: Context + ) -> BeetsAlbumMatch | BeetsTrackMatch: + if isinstance(model, AlbumMatch): + return self.album_mapper.to_beets(model, ctx) + + if isinstance(model, TrackMatch): + return self.track_mapper.to_beets(model, ctx) + + raise TypeError(f"Unsupported model type: {type(model)}") + + +# ----------------------------------- Info ----------------------------------- # + + +class TrackInfoMapper(BeetsMapper[BeetsTrackInfo, TrackInfo]): + def _from_beets(self, obj: BeetsTrackInfo, ctx: Context) -> TrackInfo: + data = {k: v for k, v in obj.items() if not k.startswith("_")} + model = TrackInfo(data=data) + return model + + def _to_beets(self, model: TrackInfo, ctx: Context) -> BeetsTrackInfo: + beets_obj = BeetsTrackInfo(**model.data) + return beets_obj + + +class AlbumInfoMapper(BeetsMapper[BeetsAlbumInfo, AlbumInfo]): + def __init__(self): + self.track_mapper = TrackInfoMapper() + + def _from_beets(self, obj: BeetsAlbumInfo, ctx: Context) -> AlbumInfo: + data = {k: v for k, v in obj.items()} + data.pop("tracks", None) + return AlbumInfo( + tracks=[self.track_mapper.from_beets(t, ctx) for t in obj.tracks], + data=data, + ) + + def _to_beets(self, model: AlbumInfo, ctx: Context) -> BeetsAlbumInfo: + data = dict(model.data) + data.pop("tracks", None) + return BeetsAlbumInfo( + tracks=[self.track_mapper.to_beets(t, ctx) for t in model.tracks], + **data, + ) + + +class DistanceMapper(BeetsMapper[BeetsDistance, Distance]): + def __init__(self): + self.track_mapper = TrackInfoMapper() + + def _from_beets(self, obj: BeetsDistance, ctx: Context) -> Distance: + penalties = [Penalty(key=k, value=v) for k, v in obj._penalties.items()] + + track_distances: list[Distance] = [] + for beets_track_info, track_distance in obj.tracks.items(): + child = self.from_beets(track_distance, ctx) + child.track_info = self.track_mapper.from_beets(beets_track_info, ctx) + track_distances.append(child) + + return Distance( + raw_distance=obj.raw_distance, + max_distance=obj.max_distance, + penalties=penalties, + track_distances=track_distances, + ) + + def _to_beets(self, model: Distance, ctx: Context) -> BeetsDistance: + distance = BeetsDistance() + + for penalty in model.penalties: + for value in penalty.value: + distance.add(penalty.key, value) + + for track_distance in model.track_distances: + if track_distance.track_info is not None: + distance.tracks[ + self.track_mapper.to_beets(track_distance.track_info, ctx) + ] = self.to_beets(track_distance, ctx) + + return distance + + +# ---------------------------------- Matches --------------------------------- # + + +class TrackMatchMapper(BeetsMapper[BeetsTrackMatch, TrackMatch]): + def __init__(self): + self.track_info_mapper = TrackInfoMapper() + self.distance_mapper = DistanceMapper() + + def _from_beets(self, obj: BeetsTrackMatch, ctx: Context) -> TrackMatch: + return TrackMatch( + info=self.track_info_mapper.from_beets(obj.info, ctx), + distance=self.distance_mapper.from_beets(obj.distance, ctx), + ) + + def _to_beets(self, model: TrackMatch, ctx: Context) -> BeetsTrackMatch: + return BeetsTrackMatch( + info=self.track_info_mapper.to_beets(model.info, ctx), + distance=self.distance_mapper.to_beets(model.distance, ctx), + ) + + +class AlbumMatchMapper(BeetsMapper[BeetsAlbumMatch, AlbumMatch]): + def __init__(self): + self.album_info_mapper = AlbumInfoMapper() + self.distance_mapper = DistanceMapper() + self.track_info_mapper = TrackInfoMapper() + self.item_mapper = ItemMapper() + + def _from_beets(self, obj: BeetsAlbumMatch, ctx: Context) -> AlbumMatch: + model = AlbumMatch( + info=self.album_info_mapper.from_beets(obj.info, ctx), + distance=self.distance_mapper.from_beets(obj.distance, ctx), + ) + + # extra tracks + for extra_track in obj.extra_tracks: + model.track_mappings.append( + AlbumMatchTrackMapping( + track_info=self.track_info_mapper.from_beets(extra_track, ctx), + item=None, + ) + ) + + # extra items + for extra_item in obj.extra_items: + model.track_mappings.append( + AlbumMatchTrackMapping( + track_info=None, + item=self.item_mapper.from_beets(extra_item, ctx), + ) + ) + + # pairs + for item, track in obj.mapping.items(): + model.track_mappings.append( + AlbumMatchTrackMapping( + track_info=self.track_info_mapper.from_beets(track, ctx), + item=self.item_mapper.from_beets(item, ctx), + ) + ) + + return model + + def _to_beets(self, model: AlbumMatch, ctx: Context) -> BeetsAlbumMatch: + mapping: dict[BeetsItem, BeetsTrackInfo] = {} + extra_items: list[BeetsItem] = [] + extra_tracks: list[BeetsTrackInfo] = [] + + for tm in model.track_mappings: + # pairs + if tm.track_info is not None and tm.item is not None: + item = self.item_mapper.to_beets(tm.item, ctx) + track_info = self.track_info_mapper.to_beets(tm.track_info, ctx) + mapping[item] = track_info + + # extra track + elif tm.track_info is not None: + extra_tracks.append(self.track_info_mapper.to_beets(tm.track_info, ctx)) + + # extra item + elif tm.item is not None: + extra_items.append(self.item_mapper.to_beets(tm.item, ctx)) + + return BeetsAlbumMatch( + distance=self.distance_mapper.to_beets(model.distance, ctx), + info=self.album_info_mapper.to_beets(model.info, ctx), + mapping=mapping, + extra_items=extra_items, + extra_tracks=extra_tracks, + ) + + +class ItemMapper(BeetsMapper[BeetsItem, Item]): + def _to_beets(self, model: Item, ctx) -> BeetsItem: + return BeetsItem._awaken( + fixed_values={k: self._decode(v) for k, v in model.fixed_values.items()}, + flex_values={k: self._decode(v) for k, v in model.flex_values.items()}, + ) + + def _from_beets(self, obj: BeetsItem, ctx) -> Item: + return Item( + fixed_values={k: self._encode(v) for k, v in obj._values_fixed.items()}, + flex_values={k: self._encode(v) for k, v in obj._values_flex.items()}, + ) + + @classmethod + def _encode(cls, v): + if isinstance(v, bytes): + return { + "__type__": "bytes", + "data": base64.b64encode(v).decode("ascii"), + } + + if isinstance(v, dict): + return {str(k): cls._encode(val) for k, val in v.items()} + + if isinstance(v, list): + return [cls._encode(x) for x in v] + + return v + + @classmethod + def _decode(cls, v): + if isinstance(v, dict): + if v.get("__type__") == "bytes": + return base64.b64decode(v["data"]) + return {k: cls._decode(val) for k, val in v.items()} + + if isinstance(v, list): + return [cls._decode(x) for x in v] + + return v diff --git a/backend/beets_flask/database/migration.py b/backend/beets_flask/database/migration.py new file mode 100644 index 00000000..538fb6dd --- /dev/null +++ b/backend/beets_flask/database/migration.py @@ -0,0 +1,120 @@ +""" +Scaffold for initial alembic setup and all future migrations. + +Introduced for migration from beets-flask v1.2.1 to v2.0. We use a python wrapper here +instead of the alembic cli, as this way we get configs and env vars in our usual way. +""" + +import shutil +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import urlparse + +from alembic import command +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy import Engine, create_engine, text + +from beets_flask.config.flask_config import get_flask_config +from beets_flask.logger import log + + +def run_migrations() -> None: + """Run all pending database migrations.""" + + alembic_config = Config("alembic.ini") + db_url = get_flask_config()["DATABASE_URI"] + engine = create_engine(db_url) + + if not _db_has_tables(engine): + # Completely empty database - run full migrations to create tables + log.info("Database empty, running initial migration...") + upgrade(alembic_config, db_url, engine) + elif not _alembic_initialized(engine): + # Has tables but no alembic tracking - stamp then upgrade + log.info("Database has no alembic tracking yet") + stamp_initial(alembic_config) + upgrade(alembic_config, db_url, engine) + else: + # Already tracked - just run pending migrations + log.info("Running database migrations...") + upgrade(alembic_config, db_url, engine) + + log.info("Database migrations complete.") + + +def stamp_initial(config: Config) -> str | None: + """Stamp the database with the initial migration. + + Use this for existing databases that should be considered up-to-date + at the initial migration, without running any schema changes. + """ + base_rev = "a986c03d9ba3" # a986c03d9ba3 == initial + log.info(f"Stamping database with base migration: {base_rev}...") + command.stamp(config, base_rev) + log.info(f"Database stamped with {base_rev}.") + return base_rev + + +def _alembic_initialized(engine: Engine) -> bool: + """Check if alembic_version table exists and has content.""" + with engine.connect() as c: + result = c.execute(text("PRAGMA table_info(alembic_version)")) + if not result.fetchall(): + return False # Table doesn't exis + + # Check if has content + result = c.execute(text("SELECT EXISTS(SELECT 1 FROM alembic_version)")) + return bool(result.scalar()) + + +def _db_has_tables(engine: Engine) -> bool: + """Check if any tables exist in the database.""" + with engine.connect() as c: + result = c.execute( + text("SELECT COUNT(*) FROM sqlite_master WHERE type='table'") + ) + count = result.scalar() or 0 + return count > 0 + + +def upgrade(alembic_config: Config, db_url: str, engine: Engine): + """Light wrapper around the alembic upgrade command. + + Adds backups and runs a cleanup after migrations. + """ + if not _needs_migration(alembic_config, engine): + log.info("No pending migrations. Skipping.") + return # No backup, no upgrade + + db_path = urlparse(db_url).path + ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + backup_path = Path(db_path).with_suffix(f".backup_{ts}.db") + shutil.copy2(db_path, backup_path) + log.info(f"SQLite backup created at {backup_path}") + + try: + command.upgrade(alembic_config, "head") + + with engine.begin() as conn: + conn.exec_driver_sql("PRAGMA wal_checkpoint(FULL);") + conn.exec_driver_sql("ANALYZE;") + conn.exec_driver_sql("REINDEX;") + conn.exec_driver_sql("VACUUM;") + result = conn.exec_driver_sql("PRAGMA integrity_check;").scalar() + if result != "ok": + raise RuntimeError(f"Integrity check failed: {result}") + except Exception: + log.exception("Migration failed! Please report this!") + raise + + +def _needs_migration(config: Config, engine: Engine) -> bool: + """Check if any migrations are pending.""" + script = ScriptDirectory.from_config(config) + with engine.connect() as conn: + ctx = MigrationContext.configure(conn) + current_rev = ctx.get_current_revision() + head_rev = script.get_current_head() + return current_rev != head_rev diff --git a/backend/beets_flask/database/models/base.py b/backend/beets_flask/database/models/base.py index 02ab64fb..ffe1a44b 100644 --- a/backend/beets_flask/database/models/base.py +++ b/backend/beets_flask/database/models/base.py @@ -1,11 +1,10 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from datetime import datetime +from datetime import UTC, datetime from typing import Any, Self from uuid import uuid4 -import pytz from sqlalchemy import LargeBinary, select from sqlalchemy.orm import ( DeclarativeBase, @@ -19,7 +18,7 @@ from beets_flask.logger import log -from .types import DictType, IntDictType, StrDictType +from .types import DictType, FloatListType, IntDictType, StrDictType class Base(DeclarativeBase): @@ -31,6 +30,7 @@ class Base(DeclarativeBase): dict[int, int]: IntDictType, dict[str, str]: StrDictType, dict[str, Any]: DictType, + list[float]: FloatListType, } ) @@ -115,6 +115,6 @@ def _sqlalchemy_reconstructor(self): # Seems a bit hacky but is the only way to ensure that # datetime objects are timezone-aware after deserialization if self.created_at and self.created_at.tzinfo is None: - self.created_at = self.created_at.replace(tzinfo=pytz.UTC) + self.created_at = self.created_at.replace(tzinfo=UTC) if self.updated_at and self.updated_at.tzinfo is None: - self.updated_at = self.updated_at.replace(tzinfo=pytz.UTC) + self.updated_at = self.updated_at.replace(tzinfo=UTC) diff --git a/backend/beets_flask/database/models/match.py b/backend/beets_flask/database/models/match.py new file mode 100644 index 00000000..49c8398a --- /dev/null +++ b/backend/beets_flask/database/models/match.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from .base import Base +from .pending import Item + +# --------------------------------- Distance --------------------------------- # + + +class Distance(Base): + __tablename__ = "distances" + + track_info_id: Mapped[str | None] = mapped_column(ForeignKey("track_info.id")) + parent_distance_id: Mapped[str | None] = mapped_column(ForeignKey("distances.id")) + + # FK columns auto-created from relationships + track_info: Mapped[TrackInfo | None] = relationship() + parent_distance: Mapped[Distance | None] = relationship( + remote_side="Distance.id", + back_populates="track_distances", + ) + + penalties: Mapped[list[Penalty]] = relationship( + back_populates="distance", + cascade="all, delete-orphan", + ) + track_distances: Mapped[list[Distance]] = relationship( + back_populates="parent_distance", + cascade="all, delete-orphan", + ) + + raw_distance: Mapped[float] = mapped_column(default=0.0) + max_distance: Mapped[float] = mapped_column(default=0.0) + + def __init__( + self, + raw_distance: float = 0.0, + max_distance: float = 0.0, + penalties: list[Penalty] | None = None, + track_distances: list[Distance] | None = None, + id: str | None = None, + ): + super().__init__(id) + self.raw_distance = raw_distance + self.max_distance = max_distance + self.penalties = penalties or [] + self.track_distances = track_distances or [] + + +class Penalty(Base): + """Individual penalty entries.""" + + __tablename__ = "penalties" + + key: Mapped[str] = mapped_column(index=True) + value: Mapped[list[float]] + distance_id: Mapped[int] = mapped_column(ForeignKey("distances.id")) + + # Derived + distance: Mapped[Distance] = relationship(back_populates="penalties") + + def __init__( + self, + key: str, + value: list[float], + id: str | None = None, + ): + super().__init__(id) + self.key = key + self.value = value + + +# ----------------------------------- Info ----------------------------------- # + + +class TrackInfo(Base): + __tablename__ = "track_info" + + album_id: Mapped[str | None] = mapped_column(ForeignKey("album_info.id")) + album: Mapped[AlbumInfo] = relationship(back_populates="tracks") + data: Mapped[dict[str, Any]] = mapped_column(JSON(), default=dict) + + def __init__( + self, + *, + data: dict[str, Any] | None = None, + id: str | None = None, + ): + super().__init__(id) + self.data = data or {} + + +class AlbumInfo(Base): + __tablename__ = "album_info" + + tracks: Mapped[list[TrackInfo]] = relationship( + back_populates="album", + cascade="all, delete-orphan", + ) + data: Mapped[dict[str, Any]] = mapped_column(JSON(), default=dict) + + def __init__( + self, + data: dict[str, Any] | None = None, + tracks: list[TrackInfo] | None = None, + id: str | None = None, + ): + super().__init__(id) + self.data = data or {} + self.tracks = tracks or [] + + +# ----------------------------------- Match ---------------------------------- # + + +class Match(Base): + """ + Matches are polymorphic — can be album or track matches. + + This requires us to keep two extra tables. + """ + + __tablename__ = "matches" + + # Needed for polymorphic + id: Mapped[str] = mapped_column(primary_key=True) + type: Mapped[str] = mapped_column() + + distance_id: Mapped[str] = mapped_column(ForeignKey("distances.id")) + distance: Mapped[Distance] = relationship() + + __mapper_args__ = { + "polymorphic_on": "type", + "polymorphic_identity": "matches", + } + + +class AlbumMatch(Match): + __tablename__ = "matches_album" + + id: Mapped[str] = mapped_column(ForeignKey("matches.id"), primary_key=True) + + info_id: Mapped[str] = mapped_column(ForeignKey("album_info.id")) + info: Mapped[AlbumInfo] = relationship() + + track_mappings: Mapped[list[AlbumMatchTrackMapping]] = relationship( + back_populates="album_match", + cascade="all, delete-orphan", + ) + + __mapper_args__ = { + "polymorphic_identity": "album", + } + + def __init__( + self, + info: AlbumInfo, + distance: Distance, + id: str | None = None, + ) -> None: + super().__init__(id) + self.info = info + self.distance = distance + + +class TrackMatch(Match): + __tablename__ = "matches_track" + + id: Mapped[str] = mapped_column(ForeignKey("matches.id"), primary_key=True) + + info_id: Mapped[str] = mapped_column(ForeignKey("track_info.id")) + info: Mapped[TrackInfo] = relationship() + + __mapper_args__ = { + "polymorphic_identity": "track", + } + + def __init__( + self, + info: TrackInfo, + distance: Distance, + id: str | None = None, + ) -> None: + self.info = info + self.distance = distance + super().__init__(id) + + +class AlbumMatchTrackMapping(Base): + """Maps items to track_info for an album_match. + + Filter by album_match_id: + - extra_tracks: track_info is not None and item_id is None + - extra_items: track_info is None and item_id is not None + - mapping: both are set + """ + + __tablename__ = "album_match_track_mappings" + + album_match_id: Mapped[str] = mapped_column(ForeignKey("matches_album.id")) + track_info_id: Mapped[str | None] = mapped_column(ForeignKey("track_info.id")) + item_id: Mapped[str | None] = mapped_column(ForeignKey("items.id")) + + # ID of the beets library Item (not our model, just the raw ID) + album_match: Mapped[AlbumMatch] = relationship(back_populates="track_mappings") + track_info: Mapped[TrackInfo | None] = relationship() + item: Mapped[Item | None] = relationship() + + def __init__( + self, + item: Item | None = None, + track_info: TrackInfo | None = None, + id: str | None = None, + ): + self.track_info = track_info + self.item = item + super().__init__(id) diff --git a/backend/beets_flask/database/models/pending.py b/backend/beets_flask/database/models/pending.py new file mode 100644 index 00000000..2c5c8f94 --- /dev/null +++ b/backend/beets_flask/database/models/pending.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import JSON, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + +if TYPE_CHECKING: + from .states import TaskStateInDb + + +class Item(Base): + __tablename__ = "items" + + # items table in beets db + fixed_values: Mapped[dict[str, Any]] = mapped_column(JSON) + + # item_attributes table in beets db + flex_values: Mapped[dict[str, Any]] = mapped_column(JSON) + + def __init__( + self, + fixed_values: dict[str, Any], + flex_values: dict[str, Any], + id: str | None = None, + ): + super().__init__(id) + self.fixed_values = fixed_values + self.flex_values = flex_values + + +class TaskItem(Base): + __tablename__ = "tasks_items" + + task_id: Mapped[str] = mapped_column(ForeignKey("task.id")) + task: Mapped[TaskStateInDb] = relationship(back_populates="pending_items") + item_id: Mapped[str] = mapped_column(ForeignKey("items.id")) + item: Mapped[Item] = relationship() + + def __init__(self, item: Item, id: str | None = None): + super().__init__(id) + self.item = item diff --git a/backend/beets_flask/database/models/states.py b/backend/beets_flask/database/models/states.py index a4798d07..82bff105 100644 --- a/backend/beets_flask/database/models/states.py +++ b/backend/beets_flask/database/models/states.py @@ -13,15 +13,10 @@ from __future__ import annotations -import io import pickle from pathlib import Path -from typing import Any -from beets.autotag import AlbumMatch -from beets.autotag.distance import Distance from beets.importer import Action, ImportTask -from beets.library.models import Item as LibraryItem from sqlalchemy import ( ForeignKey, UniqueConstraint, @@ -34,7 +29,10 @@ relationship, ) +from beets_flask.database.mapper.base import Context +from beets_flask.database.mapper.match import ItemMapper, MatchMapper from beets_flask.database.models.base import Base +from beets_flask.database.models.match import Match from beets_flask.disk import Archive, Folder from beets_flask.importer.progress import Progress from beets_flask.importer.states import ( @@ -45,10 +43,12 @@ SessionState, TaskState, ) -from beets_flask.importer.types import BeetsAlbumMatch, BeetsTrackMatch +from beets_flask.importer.types import BeetsItem from beets_flask.logger import log from beets_flask.server.exceptions import SerializedException +from .pending import TaskItem + class FolderInDb(Base): """Represents a folder on disk, to keep track of changes. @@ -347,7 +347,10 @@ class TaskStateInDb(Base): cascade="all, delete-orphan", ) # Set at the end of the import session - chosen_candidate_id: Mapped[str | None] = mapped_column(ForeignKey("candidate.id")) + # use_alter=True to break circular FK with candidate.task_id + chosen_candidate_id: Mapped[str | None] = mapped_column( + ForeignKey("candidate.id", use_alter=True) + ) chosen_candidate: Mapped[CandidateStateInDb | None] = relationship( back_populates="task", foreign_keys=[chosen_candidate_id], @@ -360,7 +363,10 @@ class TaskStateInDb(Base): old_paths: Mapped[bytes | None] # old_paths contain original file paths, but are only set when files are moved. # (which breaks some deep links that before were identical to paths, but no more!) - items: Mapped[bytes] + pending_items: Mapped[list[TaskItem]] = relationship( + back_populates="task", + cascade="all, delete-orphan", + ) choice_flag: Mapped[Action | None] # To allow for continue we need to store the current artist and album @@ -371,13 +377,19 @@ class TaskStateInDb(Base): progress: Mapped[Progress] + @property + def items(self) -> list[BeetsItem]: + ctx = Context() + mapper = ItemMapper() + return [mapper.to_beets(row.item, ctx) for row in self.pending_items] + def __init__( self, id: str | None = None, toppath: bytes | None = None, paths: list[bytes] = [], old_paths: list[bytes] | None = None, - items: list[LibraryItem] = [], + pending_items: list[TaskItem] = [], candidates: list[CandidateStateInDb] = [], chosen_candidate_id: str | None = None, progress: Progress = Progress.NOT_STARTED, @@ -390,12 +402,7 @@ def __init__( self.paths = pickle.dumps(paths) self.old_paths = pickle.dumps(old_paths) if old_paths else None - for item in items: - # Remove db from all items as it can't be pickled - item._db = None - item._Item__album = None - - self.items = pickle.dumps(items) + self.pending_items = pending_items self.candidates = candidates self.chosen_candidate_id = chosen_candidate_id self.progress = progress @@ -411,11 +418,16 @@ def from_live_state(cls, state: TaskState) -> TaskStateInDb: else: old_paths = None + ctx = Context() + mapper = ItemMapper() + task = cls( id=state.id, toppath=str(state.toppath).encode("utf-8") if state.toppath else None, paths=state.task.paths, - items=state.task.items, + pending_items=[ + TaskItem(item=mapper.from_beets(item, ctx)) for item in state.items + ], candidates=[ CandidateStateInDb.from_live_state(c) for c in state.candidate_states ], @@ -435,7 +447,7 @@ def to_live_state(self, session_state: SessionState | None = None) -> TaskState: beets_task = ImportTask( toppath=self.toppath, paths=pickle.loads(self.paths), - items=pickle.loads(self.items), + items=self.items, ) beets_task.choice_flag = self.choice_flag beets_task.cur_artist = self.cur_artist @@ -480,8 +492,8 @@ class CandidateStateInDb(Base): ) # Should deserialize to AlbumMatch|TrackMatch - # ~4kb per match - match: Mapped[bytes] + match_id: Mapped[str] = mapped_column(ForeignKey("matches.id")) + match: Mapped[Match] = relationship() # Duplicate ids (if any) (beets_id) duplicate_ids: Mapped[str] @@ -491,25 +503,14 @@ class CandidateStateInDb(Base): def __init__( self, - match: BeetsAlbumMatch | BeetsTrackMatch, + match: Match, mapping: dict[int, int], duplicate_ids: list[str] = [], id: str | None = None, ): super().__init__(id) - # Remove db from all items as it can't be pickled - # FIXME: this should go into beets __getstate__ method - # see https://github.com/beetbox/beets/pull/5641 - if isinstance(match, BeetsAlbumMatch): - for item in match.mapping.keys(): - item._db = None - item._Item__album = None - for item in match.extra_items: - item._db = None - item._Item__album = None - - self.match = pickle.dumps(match) + self.match = match self.duplicate_ids = ";".join(map(str, duplicate_ids)) self.mapping = mapping @@ -518,7 +519,7 @@ def from_live_state(cls, state: CandidateState) -> CandidateStateInDb: """Create the DB representation of a live CandidateState.""" return cls( id=state.id, - match=state.match, + match=MatchMapper().from_beets(state.match, Context()), duplicate_ids=state.duplicate_ids, mapping=state._mapping, ) @@ -528,7 +529,7 @@ def to_live_state(self, task_state: TaskState | None) -> CandidateState: if task_state is None: task_state = self.task.to_live_state() live_state = CandidateState( - CustomUnpickler(io.BytesIO(self.match)).load(), + MatchMapper().to_beets(self.match, Context()), task_state, mapping=self.mapping, ) @@ -545,43 +546,4 @@ def to_dict(self) -> SerializedCandidateState: return self.to_live_state(self.task.to_live_state()).serialize() -# Hotfix for match unpickler to resolve beets distance moved -# This is needed because various beets updates changed class implementations -# and we want to rebuild the newer versions of some beets classes from old pickles. -# TODO: We should fix this in general and not pickle beets objects -class CustomUnpickler(pickle.Unpickler): - def find_class(self, module, name): - """Override the find_class method to redirect Distance class references.""" - # Redirect Distance class from beets.autotag.hooks to beets.distance (2.4.0) - if module == "beets.autotag.hooks" and name == "Distance": - return Distance - - # For all other classes, use the default lookup mechanism - return super().find_class(module, name) - - def load(self) -> Any: - object = super().load() - if isinstance(object, Distance): - self.patch_distance(object) - - if isinstance(object, AlbumMatch): - self.patch_distance(object.distance) - - return object - - def patch_distance(self, distance: Distance) -> Distance: - # Rewrite "source" penalty to "data_source" penalty (2.5.0) - if "source" in distance._penalties: - log.debug( - "Converting old distance.source to distance.data_source (changed in beets 2.5.0)" - ) - distance._penalties["data_source"] = distance._penalties["source"] - del distance._penalties["source"] - - # Potential infinite recursion, ah well - for track, d in distance.tracks.items(): - self.patch_distance(d) - return distance - - __all__ = ["SessionStateInDb", "TaskStateInDb", "CandidateStateInDb"] diff --git a/backend/beets_flask/database/models/types.py b/backend/beets_flask/database/models/types.py index 4d558a60..63207b72 100644 --- a/backend/beets_flask/database/models/types.py +++ b/backend/beets_flask/database/models/types.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import json +from array import array from typing import Any from sqlalchemy import types @@ -65,3 +68,34 @@ class StrDictType(DictType): allowed_keys_types = (str,) allowed_values_types = (str,) + + +class FloatListType(types.TypeDecorator): + """Stores a list[float] as binary using array.array ('d' = float64).""" + + impl = types.LargeBinary + cache_ok = True + + def process_bind_param( + self, value: list[float] | None, dialect: Any + ) -> bytes | None: + if value is None: + return None + if not isinstance(value, list): + raise ValueError("Value must be a list") + if not all(isinstance(v, int | float) for v in value): + raise ValueError(f"All values must be float, got: {value}") + arr = array("d", value) + return arr.tobytes() + + def process_result_value( + self, value: bytes | None, dialect: Any + ) -> list[float] | None: + if value is None: + return None + arr = array("d") + arr.frombytes(value) + return arr.tolist() + + def copy(self, **kw: Any) -> FloatListType: + return self.__class__() diff --git a/backend/beets_flask/database/setup.py b/backend/beets_flask/database/setup.py index 09a3bb63..a46d86e4 100644 --- a/backend/beets_flask/database/setup.py +++ b/backend/beets_flask/database/setup.py @@ -1,5 +1,4 @@ from contextlib import contextmanager -from functools import wraps from quart import Quart from sqlalchemy import Engine, create_engine @@ -34,8 +33,6 @@ def setup_database(app: Quart | None = None) -> None: log.warning("Resetting database due to RESET_DB=True in config") _reset_database() - _create_tables(engine) - if app is not None: # Gracefully shutdown the database session, if launched # from within a Flask app context. @@ -94,35 +91,6 @@ def db_session_factory(session: Session | None = None): session.close() # type: ignore -def with_db_session(func): - """Decorate a function with a db session as a keyword argument to the function. - - Example - ``` - @with_db_session - def my_function(session=None): - tag.foo = "bar" - session.merge(tag) - return tag.to_dict() - ``` - """ - - @wraps(func) - def wrapper(*args, **kwargs): - with db_session_factory() as session: - kwargs.setdefault("session", session) - return func(*args, **kwargs) - - return wrapper - - -def _create_tables(engine) -> None: - Base.metadata.create_all(bind=engine) - - def _reset_database(): - # Removes all data from the database but keeps schema - for t in reversed(Base.metadata.sorted_tables): - with db_session_factory() as session: - session.execute(t.delete()) - session.commit() + Base.metadata.drop_all(bind=engine) # type: ignore + Base.metadata.create_all(bind=engine) # type: ignore diff --git a/backend/beets_flask/disk.py b/backend/beets_flask/disk.py index 3e71e31f..557dc67d 100644 --- a/backend/beets_flask/disk.py +++ b/backend/beets_flask/disk.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import os import re import subprocess @@ -7,14 +8,12 @@ from collections.abc import Iterator, Sequence from dataclasses import dataclass from fnmatch import fnmatch +from functools import cache from pathlib import Path from typing import ( Literal, ) -from beets.importer import ( - ArchiveImportTask, -) from beets.importer.tasks import ( MULTIDISC_MARKERS, MULTIDISC_PAT_FMT, @@ -222,9 +221,28 @@ def from_path( ) +@cache +def allowed_archive_extensions() -> list[str]: + # Tar files + ext = [".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz"] + # Zip files + ext += [".zip"] + if importlib.util.find_spec("rarfile") is not None: + ext += [".rar"] + if importlib.util.find_spec("py7zr") is not None: + ext += [".7z"] + return ext + + def is_archive_file(path: Path | str) -> bool: - """Check if a file is an archive file based on its extension.""" - return ArchiveImportTask.is_archive(str(path)) + """Check if a file is an archive file based on its extension. + + It seems like there is a memory issue with `tarfile.is_tarfile` + (see https://github.com/pSpitzner/beets-flask/issues/258). We try + to avoid this by only checking the file extension here, and not trying to open the file. + """ + allowed_extensions = allowed_archive_extensions() + return Path(path).suffix.lower() in allowed_extensions @dataclass diff --git a/backend/beets_flask/importer/session.py b/backend/beets_flask/importer/session.py index 8cad55ac..3387f281 100644 --- a/backend/beets_flask/importer/session.py +++ b/backend/beets_flask/importer/session.py @@ -35,8 +35,8 @@ from pathlib import Path from typing import Any, Literal, TypedDict, TypeGuard, TypeVar -import nest_asyncio -from beets import autotag, importer, plugins +from beets import autotag, plugins +from beets.importer import ImportAbortError from beets.ui import UserError, _open_library from beets.util import bytestring_path from deprecated import deprecated @@ -46,6 +46,9 @@ from beets_flask.importer.progress import Progress, ProgressState from beets_flask.importer.types import ( BeetsAlbum, + BeetsImportAction, + BeetsImportSession, + BeetsImportTask, BeetsLibrary, DuplicateAction, ) @@ -75,8 +78,6 @@ ) from .states import ProgressState, SessionState -nest_asyncio.apply() - # ---------------------------------------------------------------------------- # # Types and helpers # # ---------------------------------------------------------------------------- # @@ -150,7 +151,7 @@ class Search(TypedDict): search_ids: list[str] search_artist: str | None - search_album: str | None + search_name: str | None def _is_search(d: Any) -> TypeGuard[Search]: @@ -168,7 +169,7 @@ def _is_search(d: Any) -> TypeGuard[Search]: # ---------------------------------------------------------------------------- # -class BaseSession(importer.ImportSession, ABC): +class BaseSession(BeetsImportSession, ABC): """Base class for our GUI-based ImportSessions. Operates on single Albums / files. @@ -191,11 +192,10 @@ class BaseSession(importer.ImportSession, ABC): # are contained in the associated SessionState -> TaskState -> CandidateStates state: SessionState - pipeline: AsyncPipeline[importer.ImportTask, Any] | None = None + pipeline: AsyncPipeline[BeetsImportTask, Any] | None = None config_overlay: dict - # FIXME: only for typehint until we update beets - lib: BeetsLibrary # type: ignore + lib: BeetsLibrary def __init__( self, @@ -214,7 +214,7 @@ def __init__( # We do not want to pollute a global config object every time a session runs. # This is fine for the cli tool, where each run creates only one session # but not for our long-running webserver. - config = get_config() + config = get_config().beets_config if isinstance(config_overlay, dict): config.set_args(config_overlay) @@ -255,6 +255,10 @@ def run_and_capture_output(self) -> tuple[str, str]: def get_config_value(self, key: str, type_func: Callable | None = None) -> Any: """Get a config value from the overlay or default. + TODO: remove or rework, this is finicky, and we should be able to come up + with something better now that we have our own config class. This should + allow us to remove the type ignores below too. + Use dots to separate levels. """ @@ -271,16 +275,16 @@ def get_config_value(self, key: str, type_func: Callable | None = None) -> Any: # get settings from user settings, this is not a dict, but confuse config # the confuse config views do not throw key errors, and their .get() is not # the same as dict.get(), but rather resolves the value. - default = get_config() + default = get_config().beets_config for p in path: - default = default[p] - default = default.get(type_func) if type_func else default.get() + default = default[p] # type: ignore[assignment] + default = default.get(type_func) if type_func else default.get() # type: ignore return default # -------------------------- State handling helpers -------------------------- # def set_task_progress( - self, task: importer.ImportTask, progress: ProgressState | Progress | str + self, task: BeetsImportTask, progress: ProgressState | Progress | str ): """Set the progress for a task belonging to the session. @@ -293,7 +297,7 @@ def set_task_progress( task_state.set_progress(progress) - def get_task_progress(self, task: importer.ImportTask) -> ProgressState | None: + def get_task_progress(self, task: BeetsImportTask) -> ProgressState | None: """Get the progress of the task, via this sessions state.""" task_state = self.state.get_task_state_for_task(task) return task_state.progress if task_state else None @@ -309,7 +313,7 @@ def stages(self) -> StageOrder: """ raise NotImplementedError("Implement in subclass") - def resolve_duplicate(self, task: importer.ImportTask, found_duplicates): + def resolve_duplicate(self, task: BeetsImportTask, found_duplicates): """Overload default resolve duplicate and skip it. This basically skips this stage. @@ -318,15 +322,15 @@ def resolve_duplicate(self, task: importer.ImportTask, found_duplicates): "Skipping duplicate resolution. " + f"Your session should implement this! -> {self.__class__.__name__}" ) - task.set_choice(importer.Action.SKIP) + task.set_choice(BeetsImportAction.SKIP) - def choose_item(self, task: importer.ImportTask): + def choose_item(self, task: BeetsImportTask): """Overload default choose item and skip it. This session should not reach this stage. """ self.logger.debug(f"skipping choose_item {task}") - return importer.Action.SKIP + return BeetsImportAction.SKIP def should_resume(self, path): """Overload default should_resume and skip it. @@ -337,7 +341,7 @@ def should_resume(self, path): self.logger.debug(f"skipping should_resume {path}") return False - def identify_duplicates(self, task: importer.ImportTask): + def identify_duplicates(self, task: BeetsImportTask): """For all candidates, check if they have duplicates in the library. This stage should only be run for preview sessions, but we still have @@ -347,7 +351,7 @@ def identify_duplicates(self, task: importer.ImportTask): f"This session should not reach this stage. {self.__class__.__name__}" ) - def lookup_candidates(self, task: importer.ImportTask): + def lookup_candidates(self, task: BeetsImportTask): """Lookup candidates for the task. This stage should only be run for preview sessions, but we still have @@ -357,8 +361,12 @@ def lookup_candidates(self, task: importer.ImportTask): f"This session should not reach this stage. {self.__class__.__name__}" ) - def finalize(self, task: importer.ImportTask): + def finalize(self, task: BeetsImportTask): """Last stage called and customizable any session.""" + if len(self.config_overlay) > 0: + # make sure we dont leave overlays in beets + # but for multi-task sessions, this might break overlays for remaining tasks + get_config().commit_to_beets() self.logger.debug(f"Finalized {self} {task}") # ---------------------------------- Run --------------------------------- # @@ -374,9 +382,9 @@ async def run_async(self) -> SessionState: Take care of this in subclasses. """ # For now, until we improve the upstream beets config logic, - # adhere to importer.ImportSession convention and create a local copy + # adhere to BeetsImportSession convention and create a local copy # of the config. - config = get_config() + config = get_config().beets_config self.set_config(config["import"]) # TODO: check some config values. that are not compatible with our code. @@ -396,7 +404,7 @@ async def run_async(self) -> SessionState: try: assert self.pipeline is not None await self.pipeline.run_async() - except importer.ImportAbortError: + except ImportAbortError: log.debug(f"Interactive import session aborted by user") except ApiException as e: if e.persist_in_db: @@ -469,7 +477,7 @@ def stages(self) -> StageOrder: # --------------------------- Stage Definitions -------------------------- # - def identify_duplicates(self, task: importer.ImportTask): + def identify_duplicates(self, task: BeetsImportTask): """For all candidates, check if they have duplicates in the library.""" task_state = self.state.get_task_state_for_task_raise(task) @@ -482,7 +490,7 @@ def identify_duplicates(self, task: importer.ImportTask): if len(duplicates) > 0: log.debug(f"Found duplicates for {cs.id=}: {duplicates}") - def lookup_candidates(self, task: importer.ImportTask): + def lookup_candidates(self, task: BeetsImportTask): """Lookup candidates for the task.""" search_ids = self.config["search_ids"].as_str_seq() # might be an empty list @@ -535,7 +543,7 @@ def __init__( if s != "skip": task.set_progress(Progress.LOOKING_UP_CANDIDATES - 1) - def lookup_candidates(self, task: importer.ImportTask): + def lookup_candidates(self, task: BeetsImportTask): """Amend the found candidate to the already existing candidates (if any).""" # see ref in lookup_candidates in beets/importer.py @@ -553,44 +561,20 @@ def lookup_candidates(self, task: importer.ImportTask): and search["search_artist"].strip() == "" ): search["search_artist"] = None - if search["search_album"] is not None and search["search_album"].strip() == "": - search["search_album"] = None + if search["search_name"] is not None and search["search_name"].strip() == "": + search["search_name"] = None search["search_ids"] = list( filter(lambda x: x.strip() != "", search["search_ids"]) ) log.debug(f"Using {search=} for {task_state.id=}, {task_state.paths=}") - try: - _, _, prop = autotag.tag_album( - task.items, - search_ids=search["search_ids"], - search_album=search["search_album"], - search_artist=search["search_artist"], - ) - except Exception as e: - # TODO: With beets 2.6.0 this should be revisited - # since beets should than be able to handle these exceptions - # gracefully upstream. - # https://github.com/beetbox/beets/pull/5965 - from beetsplug.musicbrainz import MusicBrainzAPIError - from beetsplug.spotify import APIError as SpotifyAPIError - - if isinstance(e, MusicBrainzAPIError): - raise NoCandidatesFoundException( - f"Failed to contact Musicbrainz API: {e.get_message()}", - persist_in_db=False, - ) - elif isinstance(e, SpotifyAPIError): - raise NoCandidatesFoundException( - f"Failed to contact Spotify API: {e}", - persist_in_db=False, - ) - else: - raise NoCandidatesFoundException( - f"Failed to contact online APIs.", - persist_in_db=False, - ) + _, _, prop = autotag.tag_album( + task.items, + search_ids=search["search_ids"], + search_name=search["search_name"], + search_artist=search["search_artist"], + ) task_state.add_candidates(prop.candidates) @@ -603,8 +587,8 @@ def lookup_candidates(self, task: importer.ImportTask): error_text += f"ids: {', '.join(search['search_ids'])}; " if search["search_artist"]: error_text += f"artist: {search['search_artist']}; " - if search["search_album"]: - error_text += f"album: {search['search_album']}; " + if search["search_name"]: + error_text += f"album: {search['search_name']}; " error_text += NoCandidatesFoundException.metadata_plugin_info() raise NoCandidatesFoundException( error_text, @@ -620,7 +604,7 @@ def lookup_candidates(self, task: importer.ImportTask): ) self.state.exc = None - def finalize(self, task: importer.ImportTask): + def finalize(self, task: BeetsImportTask): """Restore initial taks and session states.""" task_state = self.state.get_task_state_for_task_raise(task) @@ -632,7 +616,7 @@ def finalize(self, task: importer.ImportTask): + "Cannot restore previous progress." ) - self.logger.debug(f"Finalized {self} {task}") + super().finalize(task) class ImportSession(BaseSession): @@ -762,7 +746,7 @@ def stages(self): return stages - def finalize(self, task: importer.ImportTask): + def finalize(self, task: BeetsImportTask): """ Reset previous match threshold exceptions. @@ -785,7 +769,7 @@ def finalize(self, task: importer.ImportTask): # --------------------------- Stage Definitions -------------------------- # - def choose_match(self, task: importer.ImportTask): + def choose_match(self, task: BeetsImportTask): self.logger.setLevel(logging.DEBUG) self.logger.debug(f"choose_match {task}") @@ -826,12 +810,12 @@ def choose_match(self, task: importer.ImportTask): # ASIS if candidate_state.id == task_state.asis_candidate.id: log.debug(f"Importing {task} as-is") - return importer.Action.ASIS + return BeetsImportAction.ASIS return candidate_state.match def resolve_duplicate( - self, task: importer.ImportTask, found_duplicates: list[BeetsAlbum] + self, task: BeetsImportTask, found_duplicates: list[BeetsAlbum] ): log.debug( f"Resolving duplicates for {task} with action {self.duplicate_actions}" @@ -846,7 +830,7 @@ def resolve_duplicate( task_state.duplicate_action = task_duplicate_action match task_duplicate_action: case "skip": - task.set_choice(importer.Action.SKIP) + task.set_choice(BeetsImportAction.SKIP) case "keep": pass case "remove": @@ -854,7 +838,7 @@ def resolve_duplicate( case "merge": task.should_merge_duplicates = True case "ask": - # task.set_choice(importer.action.SKIP) + # task.set_choice(BeetsImportAction.SKIP) raise DuplicateException( "You have set the duplicate action to 'ask' in your beets config." ) @@ -978,13 +962,13 @@ def stages(self): stages.insert(before="user_query", stage=match_threshold(self)) return stages - def match_threshold(self, task: importer.ImportTask): + def match_threshold(self, task: BeetsImportTask): """Check if the match quality is good enough to import. Returns true if candidates were found, and the match quality is better than threshlold. - Note: What stops the pipeline is that we set task.choice to importer.action.SKIP, + Note: What stops the pipeline is that we set task.choice to BeetsImportAction.SKIP, or raise an exception. Currently raising, as we do not have a dedicated progress for "not imported". @@ -1011,7 +995,7 @@ def match_threshold(self, task: importer.ImportTask): t = (1 - self.import_threshold) * 100 raise NotImportedException(f"Match below threshold ({d:.0f}% < {t:.0f}%)") # beets would handle this via the task action: - task.set_choice(importer.action.SKIP) + task.set_choice(BeetsImportAction.SKIP) else: log.info( f"Best candidate was better than threshold, importing to library. {distance=} {self.import_threshold=}" diff --git a/backend/beets_flask/importer/stages.py b/backend/beets_flask/importer/stages.py index ab499b2d..88fa308f 100644 --- a/backend/beets_flask/importer/stages.py +++ b/backend/beets_flask/importer/stages.py @@ -690,5 +690,4 @@ def _apply_choice(session: ImportSession, task: ImportTask): "user_query", "plugin_stage", "manipulate_files", - "mark_tasks_completed", ] diff --git a/backend/beets_flask/importer/states.py b/backend/beets_flask/importer/states.py index 1c1d48ce..6b55cc6c 100644 --- a/backend/beets_flask/importer/states.py +++ b/backend/beets_flask/importer/states.py @@ -10,9 +10,9 @@ from typing import Literal, NotRequired, TypedDict, cast from uuid import uuid4 as uuid -import beets.ui.commands as uicommands from beets import importer from beets.ui import _open_library +from beets.ui.commands.import_.display import show_change from beets.util import bytestring_path, get_most_common_tags from deprecated import deprecated @@ -484,7 +484,7 @@ def type(self) -> Literal["album", "track"]: def diff_preview(self) -> str: """Diff preview of the match to the current meta data.""" out, err, _ = capture_stdout_stderr( - uicommands.show_change, + show_change, self.task_state.task.cur_artist, self.task_state.task.cur_album, self.match, @@ -675,7 +675,7 @@ def identify_duplicates(self, lib: BeetsLibrary | None = None) -> list[BeetsAlbu # FIXME: Tracks are not checked for duplicates. Tbh noone cares about tracks anyways """ if lib is None: - lib = _open_library(get_config()) + lib = _open_library(get_config().beets_config) info = self.match.info.copy() info["albumartist"] = info["artist"] @@ -689,7 +689,7 @@ def identify_duplicates(self, lib: BeetsLibrary | None = None) -> list[BeetsAlbu tmp_album = BeetsAlbum(lib, **info) keys: list[str] = cast( list[str], - get_config()["import"]["duplicate_keys"]["album"].as_str_seq() or [], + get_config().data.import_.duplicate_keys.album or [], ) dup_query = tmp_album.duplicates_query(keys) @@ -796,7 +796,6 @@ def _index_mapping( tdxs.append(found_tdx) if None in idxs or None in tdxs: - # breakpoint() raise ValueError( f"Index mapping failed: {idxs=} {tdxs=} {len(items)=} {len(tracks)=}" ) diff --git a/backend/beets_flask/importer/types.py b/backend/beets_flask/importer/types.py index be11f51f..73636ee7 100644 --- a/backend/beets_flask/importer/types.py +++ b/backend/beets_flask/importer/types.py @@ -21,6 +21,8 @@ from beets.autotag.hooks import AlbumMatch as BeetsAlbumMatch from beets.autotag.hooks import TrackInfo as BeetsTrackInfo from beets.autotag.hooks import TrackMatch as BeetsTrackMatch +from beets.importer import Action as BeetsImportAction +from beets.importer import ImportSession as BeetsImportSession from beets.importer import ImportTask as BeetsImportTask from beets.library import Album as BeetsAlbum from beets.library import Item as BeetsItem @@ -42,7 +44,9 @@ "BeetsTrackMatch", "BeetsLibrary", "BeetsDistance", + "BeetsImportAction", "BeetsImportTask", + "BeetsImportSession", ] # to be consistent with beets, here we do not use an enum. diff --git a/backend/beets_flask/invoker/enqueue.py b/backend/beets_flask/invoker/enqueue.py index 531e13e3..cda010b8 100644 --- a/backend/beets_flask/invoker/enqueue.py +++ b/backend/beets_flask/invoker/enqueue.py @@ -209,7 +209,7 @@ def enqueue_preview(hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs) -> def enqueue_preview_add_candidates( hash: str, path: str, extra_meta: ExtraJobMeta, **kwargs ) -> Job: - # May contain search_ids, search_artist, search_album + # May contain search_ids, search_artist, search_name # As always to allow task mapping search: TaskIdMappingArg[Search | Literal["skip"]] = kwargs.pop("search", None) @@ -655,6 +655,6 @@ def _get_live_state_by_folder( def delete_items(task_ids: list[str], delete_files: bool = True): - lib = _open_library(get_config()) + lib = _open_library(get_config().beets_config) for task_id in task_ids: delete_from_beets(task_id, delete_files=delete_files, lib=lib) diff --git a/backend/beets_flask/logger.py b/backend/beets_flask/logger.py index d47290d6..4c3f7499 100644 --- a/backend/beets_flask/logger.py +++ b/backend/beets_flask/logger.py @@ -41,6 +41,14 @@ "level": os.getenv("LOG_LEVEL_BEETSFLASK", logging.INFO), "propagate": False, }, + "alembic.runtime.migration": { + "level": logging.INFO, + "propagate": True, + }, + "uvicorn": { + "level": logging.WARNING, + "propagate": True, + }, }, } diff --git a/backend/beets_flask/redis.py b/backend/beets_flask/redis.py index 1f368ca8..29066b50 100644 --- a/backend/beets_flask/redis.py +++ b/backend/beets_flask/redis.py @@ -1,13 +1,17 @@ import asyncio +import os import time from concurrent.futures import ThreadPoolExecutor -from redis import Redis +import redis from rq import Queue from rq.job import Job # Setup redis connection -redis_conn = Redis() +if os.environ.get("REDIS_URL"): + redis_conn = redis.from_url(os.environ["REDIS_URL"]) +else: + redis_conn = redis.Redis() # Init our different queues preview_queue = Queue("preview", connection=redis_conn, default_timeout=600) diff --git a/backend/beets_flask/server/app.py b/backend/beets_flask/server/app.py index 49a4d101..dcdfd87b 100644 --- a/backend/beets_flask/server/app.py +++ b/backend/beets_flask/server/app.py @@ -4,6 +4,7 @@ import os from dataclasses import asdict, is_dataclass from datetime import date, datetime +from pathlib import Path from typing import TYPE_CHECKING, Any from quart import Quart @@ -83,4 +84,8 @@ def default(self, o): if isinstance(o, Enum): return o.value + # Path to string + if isinstance(o, Path): + return str(o) + return json.JSONEncoder.default(self, o) diff --git a/backend/beets_flask/server/routes/__init__.py b/backend/beets_flask/server/routes/__init__.py index f0843e2c..5c54b0a6 100644 --- a/backend/beets_flask/server/routes/__init__.py +++ b/backend/beets_flask/server/routes/__init__.py @@ -4,6 +4,7 @@ from .config import config_bp from .db_models import register_state_models from .exception import error_bp +from .file_upload import file_upload_bp from .frontend import frontend_bp from .inbox import inbox_bp from .library import library_bp @@ -15,6 +16,7 @@ backend_bp.register_blueprint(art_blueprint) backend_bp.register_blueprint(config_bp) backend_bp.register_blueprint(error_bp) +backend_bp.register_blueprint(file_upload_bp) backend_bp.register_blueprint(frontend_bp) backend_bp.register_blueprint(inbox_bp) backend_bp.register_blueprint(library_bp) diff --git a/backend/beets_flask/server/routes/config.py b/backend/beets_flask/server/routes/config.py index 42f54b6b..812b1577 100644 --- a/backend/beets_flask/server/routes/config.py +++ b/backend/beets_flask/server/routes/config.py @@ -4,7 +4,6 @@ fetch settings once from the backend on first page-load. """ -from beets import __version__ as beets_version from quart import Blueprint, jsonify from beets_flask.config import get_config @@ -16,46 +15,20 @@ async def get_all(): """Get nested dict representing the full (but redacted) beets config.""" config = get_config() - return jsonify(_serializable(config.flatten(redact=True))) + return jsonify(_serializable(config.beets_config.flatten(redact=True))) @config_bp.route("/", methods=["GET"]) async def get_basic(): """Get the config settings needed for the gui.""" config = get_config() - plugins = config["plugins"].as_str_seq() - from beets.metadata_plugins import find_metadata_source_plugins - - data_sources: list[str] = [ - p.__class__.data_source for p in find_metadata_source_plugins() - ] return jsonify( { - "gui": _serializable(config["gui"].flatten(redact=True)), - "import": { - k: config["import"][k].get() - for k in [ - "duplicate_action", - ] - }, - "match": { - k: config["match"][k].get() - for k in [ - "strong_rec_thresh", - "medium_rec_thresh", - ] - } - | { - k: config["match"][k].as_str_seq() - for k in [ - "album_disambig_fields", - "singleton_disambig_fields", - ] - }, - "plugins": config["plugins"].as_str_seq(), - "data_sources": data_sources, - "beets_version": beets_version, + **config.to_dict(extra_fields=False), + # the following ones are not part of the schema + "beets_metadata_sources": config.beets_metadata_sources, + "beets_version": config.beets_version, } ) @@ -91,9 +64,10 @@ async def refresh(): curl -X POST http://localhost:5001/api_v1/config/refresh ``` """ - from beets_flask.config.beets_config import refresh_config + from beets_flask.config import get_config - refresh_config() + config = get_config() + config.reload() return jsonify({"status": "ok"}) diff --git a/backend/beets_flask/server/routes/file_upload.py b/backend/beets_flask/server/routes/file_upload.py new file mode 100644 index 00000000..39ba36d8 --- /dev/null +++ b/backend/beets_flask/server/routes/file_upload.py @@ -0,0 +1,91 @@ +"""Handle file uploads.""" + +import shutil +from asyncio import timeout +from pathlib import Path +from urllib.parse import unquote_plus + +import aiofiles +from quart import Blueprint, jsonify, request + +from beets_flask.config import get_config +from beets_flask.logger import log +from beets_flask.server.exceptions import InvalidUsageException +from beets_flask.watchdog.inbox import get_inbox_folders + +file_upload_bp = Blueprint("file_upload", __name__, url_prefix="/file_upload") + + +@file_upload_bp.route("/validate", methods=["POST"]) +async def validate(): + """Pre-validate headers for upload. + + This is needed as xmlhttprequest does not handle responses gracefully if + the upload (request body) is not consumed. Tldr: Upload still in progress, + cant raise exceptions, as returning any response at this point will reset + the network connection. + """ + _get_filename_and_dir() + + return jsonify({"status": "ok"}), 200 + + +@file_upload_bp.route("/", methods=["POST"]) +async def upload(): + """Handle file upload. + + Intended to be called after /validate (which ensures headers are correct, + and raises if not). + If used without the validation step, the upload will still fail, because + the backend still raises, but we have no way to let the frontend know. + """ + # validate + filename, filedir = _get_filename_and_dir() + log.info(f"Uploading file '{filename}' to '{filedir}' ...") + + temp_path: Path = Path(get_config().data.gui.inbox.temp_dir) + temp_path.mkdir(parents=True, exist_ok=True) + + # upload to temp location with 1 hour timeout + async with timeout(60 * 60): + async with aiofiles.open(temp_path / filename, "wb") as f: + async for chunk in request.body: + await f.write(chunk) + + # move to final location + filedir.mkdir(parents=True, exist_ok=True) + shutil.move(temp_path / filename, filedir / filename) + + log.info(f"Uploading file {filename} to {filedir} done!") + return {"status": "ok"} + + +def _get_filename_and_dir() -> tuple[str, Path]: + filename = request.headers.get("X-Filename") + filedir = request.headers.get("X-File-Target-Dir") + + if not filename or not filedir: + raise InvalidUsageException( + "Missing header: X-Filename and X-File-Target-Dir are required" + ) + + # Assert filename does not contain path separators + filename = unquote_plus(filename) + if "/" in filename or "\\" in filename: + raise InvalidUsageException( + "Invalid filename, must not contain path separators." + ) + + filedir = unquote_plus(filedir) + filedir = Path(filedir).expanduser().resolve() + is_valid_filepath = False + for inbox in get_inbox_folders(): + if filedir.is_relative_to(inbox): + is_valid_filepath = True + break + + if not is_valid_filepath: + log.error(f"Invalid target path {filedir}, must be within an inbox.") + raise InvalidUsageException("Invalid target path, must be within an inbox.") + + return filename, filedir diff --git a/backend/beets_flask/server/routes/inbox.py b/backend/beets_flask/server/routes/inbox.py index da3539a3..fa8e6b49 100644 --- a/backend/beets_flask/server/routes/inbox.py +++ b/backend/beets_flask/server/routes/inbox.py @@ -282,8 +282,8 @@ def compute_stats(folder: str): last_created = session.execute(stmt).scalars().first() ret_map: InboxStats = { - "name": inbox["name"], - "path": inbox["path"], + "name": inbox.name, + "path": inbox.path, "nFiles": dir_files(p), "size": dir_size(p), "tagged_via_gui": n_tagged, diff --git a/backend/beets_flask/server/routes/library/__init__.py b/backend/beets_flask/server/routes/library/__init__.py index 01dc54ac..4d6539b5 100644 --- a/backend/beets_flask/server/routes/library/__init__.py +++ b/backend/beets_flask/server/routes/library/__init__.py @@ -41,7 +41,7 @@ async def attach_library(): This allows to reuse an open library for each request in the same thread. """ - config = get_config() + config = get_config().beets_config # we will need to see if keeping the db open from each thread is what we want, # the importer may want to write. if not hasattr(g, "lib") or g.lib is None: diff --git a/backend/beets_flask/server/routes/library/artists.py b/backend/beets_flask/server/routes/library/artists.py index 39a4ae6e..db468a76 100644 --- a/backend/beets_flask/server/routes/library/artists.py +++ b/backend/beets_flask/server/routes/library/artists.py @@ -6,7 +6,7 @@ import re from typing import TYPE_CHECKING -import pandas as pd +import polars as pl from quart import Blueprint, Response, g from beets_flask.config import get_config @@ -22,23 +22,17 @@ # Currently artist_sort is completely ignored. Im not even sure what it is supposed to do. # Also artistids are not used, but they are in the database. -# Note: I wanted to use polars first but it does not support alpine images yet, so we use pandas instead. +def artist_separators() -> list[str]: + return get_config().data.gui.library.artist_separators -ARTIST_SEPARATORS: list[str] = get_config()["gui"]["library"][ - "artist_separators" -].as_str_seq() - -def _split_pattern(separators: list[str]) -> str: +def split_pattern(separators: list[str]) -> str: return "|".join(map(re.escape, separators)) -split_pattern_artists = _split_pattern(ARTIST_SEPARATORS) - - -def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame: - """Get all artists from the database using pandas. +def get_artists_polars(table: str, artist: str | None = None) -> pl.LazyFrame: + """Get all artists from the database using polars. Returns ------- @@ -57,7 +51,7 @@ def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame: SELECT albumartist AS artist, added - + FROM albums """ @@ -66,8 +60,10 @@ def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame: # Split the artist string by the specified separators artists: list[str] | None - if len(ARTIST_SEPARATORS) > 0 and artist is not None: - artists = [a.strip() for a in re.split(split_pattern_artists, artist)] + if len(artist_separators()) > 0 and artist is not None: + artists = [ + a.strip() for a in re.split(split_pattern(artist_separators()), artist) + ] elif artist is not None: artists = [artist.strip()] else: @@ -84,43 +80,58 @@ def get_artists_pandas(table: str, artist: str | None = None) -> pd.DataFrame: with g.lib.transaction() as tx: rows = tx.query(query, artists) if artists else tx.query(query) - # Read from the database - df = pd.DataFrame(rows, columns=["artist", "added"]) + if not rows: + return pl.LazyFrame( + schema={ + "artist": pl.Utf8, + "count": pl.Int64, + "last_added": pl.Int64, + "first_added": pl.Int64, + } + ) + + # Read from the database rows + # TODO: We might be able to optimize the row loading to be lazy + # could minimize the ram usage + df = pl.LazyFrame(rows, schema=["artist", "added"], orient="row") + + # Convert added timestamps (beets stores as seconds, convert to milliseconds) + df = df.with_columns((pl.col("added") * 1000).alias("added")) # Split artist strings into lists and explode into separate rows - if len(ARTIST_SEPARATORS) > 0: - df["artist"] = df["artist"].str.split(split_pattern_artists) - df = df.explode("artist") - - # Strip whitespace - df["artist"] = df["artist"].str.strip() - df["added"] = df["added"] * 1000 - - # Group by artist and aggregate - result = ( - df.groupby("artist") - .agg( - count=("artist", "size"), - last_added=("added", "max"), - first_added=("added", "min"), - ) - .reset_index() + if len(artist_separators()) > 0: + # split does not yet support regex... + # see https://github.com/pola-rs/polars/issues/4819 + # we do a replace workaround + df = df.with_columns( + pl.col("artist") + .str.replace_all("\uffff", "") + .str.replace_all(split_pattern(artist_separators()), "\uffff") + .str.split("\uffff") + ).explode("artist") + + # Strip whitespace and process + df = df.with_columns(pl.col("artist").str.strip_chars(), pl.col("added") * 1000) + + # Group by artist and aggregate using lazy operations + df = df.group_by("artist").agg( + pl.len().alias("count"), + pl.col("added").max().alias("last_added"), + pl.col("added").min().alias("first_added"), ) if artists is not None: - # If an artist is specified, filter the result (respect the separator and resolve as or) - result = result[ - result["artist"].str.contains( - _split_pattern(artists), case=False, regex=True - ) - ] + # If an artist is specified, filter the result + pattern = split_pattern(artists) + df = df.filter(pl.col("artist").str.contains(pattern, literal=False)) # Overwrite if there are multiple artists (i.e. joined by a separator) - if len(artists) > 1 and not result.empty: - result["artist"] = artist + if len(artists) > 1: + df = df.with_columns(pl.lit(artist).alias("artist")) - return result + return df +# TODO: Pagination strategy @artists_bp.route("/artists/", methods=["GET"]) @artists_bp.route("/artists", methods=["GET"], defaults={"artist_name": None}) async def all_artists(artist_name: str | None = None): @@ -129,42 +140,50 @@ async def all_artists(artist_name: str | None = None): This endpoint retrieves all artists from the database, splits them by specified separators and aggregates the data to count the number of items. """ - artists_albums = ( - get_artists_pandas("albums", artist_name) - .rename( - columns={ - "count": "album_count", - "last_added": "last_album_added", - "first_added": "first_album_added", - } - ) - .set_index("artist") + # Get lazy frames + + artists_albums_lazy = get_artists_polars("albums", artist_name) + artists_items_lazy = get_artists_polars("items", artist_name) + + # Rename columns in lazy frames + artists_albums_lazy = artists_albums_lazy.rename( + { + "count": "album_count", + "last_added": "last_album_added", + "first_added": "first_album_added", + } ) - artists_items = ( - get_artists_pandas("items", artist_name) - .rename( - columns={ - "count": "item_count", - "last_added": "last_item_added", - "first_added": "first_item_added", - } - ) - .set_index("artist") + + artists_items_lazy = artists_items_lazy.rename( + { + "count": "item_count", + "last_added": "last_item_added", + "first_added": "first_item_added", + } ) - # Join the two DataFrames on artist name and count the number of items and albums - artists = artists_albums.join( - artists_items, - how="outer", - ).reset_index() - # Fill n_albums and n_items with 0 if they are NaN - artists["album_count"] = artists["album_count"].fillna(0).astype(int) - artists["item_count"] = artists["item_count"].fillna(0).astype(int) + # Join lazy frames + artists_lazy = artists_albums_lazy.join( + artists_items_lazy, + left_on="artist", + right_on="artist", + how="full", + coalesce=True, + ) + + # Fill nulls and cast to int + artists_lazy = artists_lazy.with_columns( + pl.col("album_count").fill_null(0).cast(pl.Int64), + pl.col("item_count").fill_null(0).cast(pl.Int64), + ) + + # Collect the result + artists = artists_lazy.collect() if artist_name is not None: - if artists.empty: + if artists.is_empty(): raise NotFoundException(f"Artist '{artist_name}' not found.") else: - return Response(artists.iloc[0].to_json(), mimetype="application/json") - - return Response(artists.to_json(orient="records"), mimetype="application/json") + return artists.row(0, named=True), 200 + # TODO: We serialize as records here it might be better to have a different structure as we send quite a bit of data + return Response(artists.write_json(), mimetype="application/json") diff --git a/backend/beets_flask/server/routes/library/resources.py b/backend/beets_flask/server/routes/library/resources.py index c385d096..a0912e20 100644 --- a/backend/beets_flask/server/routes/library/resources.py +++ b/backend/beets_flask/server/routes/library/resources.py @@ -382,7 +382,7 @@ async def items_by_artist(artist_name: str): def delete_entities(entities: Sequence[Item | Album], delete_files=False) -> None: """Helper function to delete entities.""" - if get_config()["gui"]["library"]["readonly"].get(bool): + if get_config().data.gui.library.readonly: raise ValueError("Library is read-only") # Remove @@ -391,7 +391,7 @@ def delete_entities(entities: Sequence[Item | Album], delete_files=False) -> Non def update_entities(entities: Sequence[T], data: dict) -> Sequence[T]: """Helper function to update entities.""" - if get_config()["gui"]["library"]["readonly"].get(bool): + if get_config().data.gui.library.readonly: raise ValueError("Library is read-only") # Update diff --git a/backend/beets_flask/server/routes/library/stats.py b/backend/beets_flask/server/routes/library/stats.py index ea53ccce..598b7305 100644 --- a/backend/beets_flask/server/routes/library/stats.py +++ b/backend/beets_flask/server/routes/library/stats.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import TYPE_CHECKING, TypedDict, cast +from typing import TYPE_CHECKING, TypedDict from quart import Blueprint, g, jsonify @@ -33,7 +33,7 @@ class LibraryStats(TypedDict): async def stats(): """Get library statistics.""" - config = get_config() + config_dir = get_config().data.directory with g.lib.transaction() as tx: album_stats = tx.query( @@ -43,23 +43,21 @@ async def stats(): "SELECT COUNT(*), MAX(added), MAX(mtime), SUM(length) FROM items" ) - lib_path = cast(str, config["directory"].get(str)) - ret: LibraryStats = { - "libraryPath": str(config["directory"].as_str()), + "libraryPath": str(config_dir), "items": items_stats[0][0], "albums": album_stats[0][0], "artists": album_stats[0][3], "genres": album_stats[0][1], "labels": album_stats[0][2], - "size": dir_size(Path(lib_path)), + "size": dir_size(Path(config_dir)), "lastItemAdded": ( round(items_stats[0][1] * 1000) if items_stats[0][1] is not None else None ), "lastItemModified": ( round(items_stats[0][2] * 1000) if items_stats[0][2] is not None else None ), - "runtime": items_stats[0][3] or 0, + "runtime": items_stats[0][3] if items_stats[0][3] is not None else 0, } return jsonify(ret) diff --git a/backend/beets_flask/server/websocket/__init__.py b/backend/beets_flask/server/websocket/__init__.py index fcc63027..a5cd0e3a 100644 --- a/backend/beets_flask/server/websocket/__init__.py +++ b/backend/beets_flask/server/websocket/__init__.py @@ -3,6 +3,10 @@ from typing import cast import socketio +from eyconf.validation import ConfigurationError, MultiConfigurationError + +from beets_flask.config import get_config +from beets_flask.logger import log old_on = socketio.AsyncServer.on @@ -15,7 +19,10 @@ def on(self, event: str, namespace: str | None = None) -> Callable: ... # type: if os.environ.get("PYTEST_CURRENT_TEST", ""): client_manager = None else: - client_manager = socketio.AsyncRedisManager("redis://") + client_manager = socketio.AsyncRedisManager( + os.environ.get("REDIS_URL", "redis://"), + redis_options={"socket_timeout": None}, + ) sio: TypedAsyncServer = cast( TypedAsyncServer, @@ -34,7 +41,20 @@ def register_socketio(app): # Register all socketio namespaces from .status import register_status - from .terminal import register_tmux - register_tmux() register_status() + + terminal_enabled = True + try: + terminal_enabled = get_config().data.gui.terminal.enabled + except (MultiConfigurationError, ConfigurationError): + # We don't want to let the exception propagate here as it won't reach the frontend. + log.debug("Encountered config error. Will raise on next call to get_config()") + + if terminal_enabled: + log.info("Setting up Web-Terminal") + from .terminal import register_tmux + + register_tmux() + else: + log.info("Web-Terminal is disabled, skipping setup") diff --git a/backend/beets_flask/server/websocket/terminal.py b/backend/beets_flask/server/websocket/terminal.py index a39cc7dd..b7b62165 100644 --- a/backend/beets_flask/server/websocket/terminal.py +++ b/backend/beets_flask/server/websocket/terminal.py @@ -42,16 +42,18 @@ def register_tmux(): global session, window, pane, server if server is None: - server = libtmux.Server() + server = libtmux.Server(socket_name="beets-flask") try: - abs_path_lib = str(get_config()["gui"]["terminal"]["start_path"].as_str()) + abs_path_lib = get_config().data.gui.terminal.start_path except: abs_path_lib = "/repo" try: session = server.new_session( - session_name="beets-socket-term", start_directory=abs_path_lib + session_name="beets-socket-term", + start_directory=abs_path_lib, + window_command="/usr/bin/bash", ) except LibTmuxException: # DuplicateSessionName session = server.sessions.get(session_name="beets-socket-term") # type: ignore diff --git a/backend/beets_flask/utility.py b/backend/beets_flask/utility.py index 68f09acf..7a6a8346 100644 --- a/backend/beets_flask/utility.py +++ b/backend/beets_flask/utility.py @@ -73,3 +73,15 @@ class DummyObject: def __getattr__(self, name): """Return None for any attribute accessed.""" return None + + +# -------------------------------- Deprecation ------------------------------- # + + +def deprecation_warning(msg: str, alt_text: str | None = None): + """Raises a deprecation warning in the logs.""" + + msg = msg + " is deprecated and will not be supported from the v2.0.0 release." + if alt_text: + msg += " " + alt_text + log.warning(msg) diff --git a/backend/beets_flask/watchdog/inbox.py b/backend/beets_flask/watchdog/inbox.py index c86ddfe0..c0f90b9c 100644 --- a/backend/beets_flask/watchdog/inbox.py +++ b/backend/beets_flask/watchdog/inbox.py @@ -1,14 +1,14 @@ import asyncio import os -import signal -from collections import OrderedDict from pathlib import Path +from typing import Any, Literal from watchdog.events import FileMovedEvent, FileSystemEvent from watchdog.observers.polling import PollingObserver from beets_flask import invoker from beets_flask.config import get_config +from beets_flask.config.schema import InboxFolderSchema from beets_flask.database.models.states import SessionStateInDb from beets_flask.disk import ( album_folders_from_track_paths, @@ -58,7 +58,7 @@ def register_inboxes(timeout: float = 2.5, debounce: float = 30) -> AIOWatchdog return None log.info( f"Registering watchdog with debounce of {debounce} seconds for " - + f"inboxes: {[i['path'] for i in _inboxes]}" + + f"inboxes: {[i.path for i in _inboxes]}" ) # One observer for all inboxes. @@ -67,29 +67,23 @@ def register_inboxes(timeout: float = 2.5, debounce: float = 30) -> AIOWatchdog # timeout/debounce in seconds watchdog = AIOWatchdog( - paths=[Path(i["path"]) for i in _inboxes], + paths=[Path(i.path) for i in _inboxes], handler=handler, observer=observer, ) watchdog.start() - # Stop watchdog on exit signals. - signal.signal(signal.SIGINT, lambda s, f: watchdog.stop()) - signal.signal(signal.SIGHUP, lambda s, f: watchdog.stop()) - signal.signal(signal.SIGTERM, lambda s, f: watchdog.stop()) - signal.signal(signal.SIGQUIT, lambda s, f: watchdog.stop()) - # user would expect autotagging inboxes to automatically scan on first launch async def auto_tag_wait_for_workers(f: Path): # HACK: checking if redis is ready was not trivial enough, so we just wait a bit. await asyncio.sleep(10) await auto_tag(f) - auto_inboxes = [i for i in _inboxes if i.get("autotag", None)] + auto_inboxes = [i for i in _inboxes if i.autotag not in (False, "off")] for inbox in auto_inboxes: - album_folders = all_album_folders(inbox["path"]) + album_folders = all_album_folders(inbox.path) for f in album_folders: asyncio.create_task(auto_tag_wait_for_workers(f)) @@ -144,7 +138,10 @@ async def task_func(self, album_folder: Path): log.exception(f"Error in inbox handler task for {album_folder}", e) -async def auto_tag(folder_path: Path, inbox_kind: str | None = None): +async def auto_tag( + folder_path: Path, + inbox_kind: Literal["auto", "preview", "bootleg", "off"] | None = None, +): """Retag a (taggable) folder. Parameters @@ -160,20 +157,20 @@ async def auto_tag(folder_path: Path, inbox_kind: str | None = None): return if inbox_kind is None: - inbox_kind = inbox.get("autotag", None) + inbox_kind = inbox.autotag # Infer enqueue kind from inbox kind enq_kind: invoker.EnqueueKind - enq_kwargs = {} + enq_kwargs: dict[str, Any] = {} match inbox_kind: case "preview": enq_kind = invoker.EnqueueKind.PREVIEW case "auto": enq_kind = invoker.EnqueueKind.IMPORT_AUTO - enq_kwargs["import_threshold"] = inbox.get("auto_threshold", None) + enq_kwargs["import_threshold"] = inbox.auto_threshold case "bootleg": enq_kind = invoker.EnqueueKind.IMPORT_BOOTLEG - case False | None: + case "off" | False | None: log.debug(f"Autotagging disabled for {folder_path}, skipping.") return case _: @@ -209,12 +206,12 @@ async def auto_tag(folder_path: Path, inbox_kind: str | None = None): # ------------------------------------------------------------------------------------ # -def get_inbox_for_path(path: str | Path): +def get_inbox_for_path(path: str | Path) -> InboxFolderSchema | None: if isinstance(path, str): path = Path(path) inbox = None for i in get_inboxes(): - ipath = Path(i["path"]) + ipath = Path(i.path) if path.is_relative_to(ipath) or path == ipath: inbox = i break @@ -222,12 +219,12 @@ def get_inbox_for_path(path: str | Path): def get_inbox_folders() -> list[str]: - return [i["path"] for i in get_inboxes()] + return [i.path for i in get_inboxes()] def is_inbox_folder(path: str) -> bool: return path in get_inbox_folders() -def get_inboxes() -> list[OrderedDict]: - return get_config()["gui"]["inbox"]["folders"].flatten().values() # type: ignore +def get_inboxes() -> list[InboxFolderSchema]: + return list(get_config().data.gui.inbox.folders.values()) diff --git a/backend/generate_types.py b/backend/generate_types.py index 369894c8..f9880404 100644 --- a/backend/generate_types.py +++ b/backend/generate_types.py @@ -1,6 +1,7 @@ from py2ts.builder import TSBuilder from py2ts.config import CONFIG +from beets_flask.config.schema import BeetsSchema from beets_flask.disk import Archive, File, FileSystemItem, Folder from beets_flask.importer.session import CandidateChoiceFallback from beets_flask.importer.states import ( @@ -80,5 +81,9 @@ builder.add(FileSystemUpdate) +# ---------------------------------- Config ---------------------------------- # + +builder.add(BeetsSchema) + builder.save_file("../frontend/src/pythonTypes.ts") print("✅ Typescript types generated successfully!") diff --git a/backend/launch_db_init.py b/backend/launch_db_init.py deleted file mode 100644 index cb59148e..00000000 --- a/backend/launch_db_init.py +++ /dev/null @@ -1,21 +0,0 @@ -import os - -# dirty workaround, we pretend this is a rq worker so we get the logger to create -# a child log with pid -os.environ.setdefault("RQ_JOB_ID", "dbin") - -from beets.ui import _open_library - -from beets_flask.config.beets_config import get_config -from beets_flask.database import setup_database -from beets_flask.logger import log - -if __name__ == "__main__": - log.debug("Launching database init worker") - - # ensue beets own db is created - config = get_config() - _open_library(config) - - # ensure beets-flask db is created - setup_database() diff --git a/backend/launch_redis_workers.py b/backend/launch_redis_workers.py index ef0ae168..e66b5df7 100644 --- a/backend/launch_redis_workers.py +++ b/backend/launch_redis_workers.py @@ -5,7 +5,7 @@ num_preview_workers: int = 1 # Default value try: - num_preview_workers = get_config()["gui"]["num_preview_workers"].get(int) # type: ignore + num_preview_workers = get_config().data.gui.num_preview_workers log.debug(f"Got num_preview_workers from config: {num_preview_workers}") except: pass diff --git a/backend/launch_server.py b/backend/launch_server.py new file mode 100644 index 00000000..bec539e4 --- /dev/null +++ b/backend/launch_server.py @@ -0,0 +1,16 @@ +import uvicorn + +from beets_flask.logger import log + +if __name__ == "__main__": + log.info("Starting uvicorn server") + log.info("Server running on http://0.0.0.0:5001") + uvicorn.run( + "beets_flask.server.app:create_app", + factory=True, + host="0.0.0.0", + port=5001, + workers=4, + log_config=None, # Disable default uvicorn logging config + access_log=False, + ) diff --git a/backend/launch_watchdog_worker.py b/backend/launch_watchdog_worker.py index 3e2ae3c4..06cb9443 100644 --- a/backend/launch_watchdog_worker.py +++ b/backend/launch_watchdog_worker.py @@ -1,5 +1,6 @@ import asyncio import os +import signal # dirty workaround, we pretend this is a rq worker so we get the logger to create # a child log with pid @@ -11,14 +12,25 @@ async def main(): - log.debug(f"Launching inbox watchdog worker") - config = get_config() - debounce = int( - config["gui"]["inbox"]["debounce_before_autotag"].as_number() # type: ignore - ) - watchdog = register_inboxes(debounce=debounce) + log.debug("Launching inbox watchdog worker") + debounce_config = get_config().data.gui.inbox.debounce_before_autotag + watchdog = register_inboxes(debounce=debounce_config) + + # Serve until a termination signal is received. + loop = asyncio.get_running_loop() + stop = loop.create_future() + + def _shutdown(): + log.info("Shutting down watchdog worker") + if watchdog: + watchdog.stop() + stop.set_result(True) + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, _shutdown) + + await stop if __name__ == "__main__": asyncio.run(main()) - asyncio.get_event_loop().run_forever() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 09554705..30a69e0f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,13 +1,12 @@ [project] name = "beets-flask" description = "An opinionated web-interface around the music organizer [beets](https://beets.io/)" -version = "1.2.0" +version = "2.0.0-rc4" authors = [ { name = "F. Paul Spitzner", email = "paul.spitzner@gmail.com" }, { name = "Sebastian B. Mohr", email = "sebastian@mohrenclan.de" }, ] -requires-python = ">= 3.11" -readme = "../README.md" +requires-python = "==3.12.*" classifiers = [ "Development Status :: 4 - Beta", "Environment :: Web Environment", @@ -16,7 +15,7 @@ classifiers = [ dependencies = [ "quart>=0.20.0", "confuse>=2.0.1", - "beets==2.5.1", + "beets==2.7.1", "sqlalchemy>=2.0.35", "rq>=2.0.0", "watchdog>=5.0.3", @@ -26,12 +25,12 @@ dependencies = [ "cachetools>=5.3.3", "libtmux>=0.37.0", "Deprecated>=1.2.18", - "nest_asyncio>=1.6.0", # Used for hosting the web-interface & api "uvicorn>=0.36.0", # beets plugin dependencies "pylast>=5.2.0", "python2ts>=0.6.1", + "eyconf>=0.5.0", #"scantree>=0.0.4", "natsort", "tinytag", @@ -39,38 +38,48 @@ dependencies = [ "aiohttp", "aiofiles", "numpy", - "pandas", "typing_extensions", + "polars>=1.36.1", + "alembic>=1.18.4", +] + +[dependency-groups] +dev = [ + { include-group = "test" }, + { include-group = "docs" }, + { include-group = "typed" }, + "ruff>=0.6.5", + "pre-commit>=3.8.0", ] -[project.optional-dependencies] # Can be install with e.g. `pip install -e .[dev]` test = [ "pytest>=8.2.2", + "pytest-benchmark>=5.2.3", "pytest-asyncio>=0.23.8", "pytest-cov>=5.0.0", "fakeredis", ] -dev = ["ruff>=0.6.5", "pre-commit>=3.8.0", "beets_flask[typed]"] -typed = [ - "types-cachetools", - "types-requests", - "mypy>=1.14.1", - "types-cachetools", - "types-Deprecated", - "types-aiofiles", - "types-pytz", - "pandas-stubs", -] -all = ["beets_flask[dev,test]"] docs = [ "sphinx>=8.0.2", "furo>=2024.8.6", "sphinx-copybutton>=0.5.2", "sphinx-inline-tabs>=2023.4.21", "sphinxcontrib-typer[html]>=0.5.0", + "sphinxcontrib-mermaid>=2.0.1", "myst-parser>=4.0.0", "myst-nb>=1.1.2", + "paracelsus>=0.15.0", +] +typed = [ + "types-cachetools", + "types-requests", + "mypy>=1.14.1", + "types-cachetools", + "types-Deprecated", + "types-aiofiles", + "types-pyyaml", + "pandas-stubs", ] [build-system] @@ -90,7 +99,7 @@ target-version = "py311" [tool.ruff.lint.per-file-ignores] "**/tests/**/*" = ["D"] - +"**/alembic/**/*" = ["D","I"] [tool.ruff.lint] select = [ @@ -115,23 +124,30 @@ fixable = ["ALL"] convention = "numpy" [tool.ruff.lint.isort] -known-first-party = ["beets_flask"] +known-first-party = ["beets_flask", "tests"] +known-third-party = ["alembic", "sqlalchemy"] [tool.pytest.ini_options] # addopts = ["--import-mode=importlib", "--cov=beets_flask"] -addopts = ["--import-mode=importlib"] +addopts = ["--import-mode=importlib", "--cov=beets_flask", "--cov-report=html"] filterwarnings = [ "error", "ignore::sqlalchemy.exc.SAWarning", "ignore::DeprecationWarning", ] pythonpath = ["."] -asyncio_mode = "auto" log_format = "%(relativeCreated)-8d [%(levelname)-5s] %(name)s %(filename)-8s:%(lineno)d %(message)s" +asyncio_mode = "auto" +asyncio_default_test_loop_scope = "function" asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] omit = ["*/tests/*"] +exclude_also = [ + "if TYPE_CHECKING", + "raise AssertionError", + "raise NotImplementedError", +] [tool.mypy] check_untyped_defs = true @@ -144,7 +160,11 @@ module = [ "socketio.*", "confuse.*", "mediafile.*", - "nest_asyncio", "beetsplug.*", + "eyconf.*", ] ignore_missing_imports = true +follow_untyped_imports = true + +[tool.uv] +exclude-newer = "3 days" diff --git a/backend/tests/benchmark/test_config_validation.py b/backend/tests/benchmark/test_config_validation.py new file mode 100644 index 00000000..cbcbae96 --- /dev/null +++ b/backend/tests/benchmark/test_config_validation.py @@ -0,0 +1,30 @@ +"""Verify that our rewrite of config using eyconf is as fast as confuse""" + +import beets +from beets.plugins import _instances as plugin_instances +from beets.plugins import load_plugins + +from beets_flask.config import get_config + + +def _reset_beets(): + beets.config.clear() + beets.config.read() + loaded_data = beets.config.flatten() + plugin_instances.clear() + load_plugins() + + +def _reset_beets_flask(): + config = get_config() + config.reload() + config.commit_to_beets() + + +def test_beets_config(benchmark): + benchmark(_reset_beets) + + +def test_beets_flask_config(benchmark): + config = get_config() + benchmark(_reset_beets_flask) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 20c086ae..88c97d87 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,11 +1,17 @@ +import hashlib import logging import os +import pickle import shutil +import tempfile from collections.abc import Callable, Generator from contextlib import _GeneratorContextManager from pathlib import Path import pytest +import yaml +from beets import autotag +from beets.autotag import tag_album as _tag_album from quart import Quart from quart.typing import TestClientProtocol from sqlalchemy.orm import Session @@ -36,6 +42,17 @@ def setup_and_teardown(tmpdir_factory): os.makedirs(name=tmp_dir / "beets-flask", exist_ok=True) os.environ["IB_SERVER_CONFIG"] = "test" + # ---------------------- Overwrite default settings ---------------------- # + + # Let's not create default configs that wouldnt pass our tests + with open(tmp_dir / "beets-flask/config.yaml", "w") as f: + yaml.dump({"gui": {"num_preview_workers": 4}}, f) + + # we have one test that does replacements on this file + # and assumes the default 4 workers + with open(tmp_dir / "beets/config.yaml", "w") as f: + yaml.dump({"plugins": ["musicbrainz", "spotify"]}, f) + yield # Teardown @@ -83,12 +100,8 @@ def db_session(db_session_factory): def beets_lib() -> Generator[BeetsLibrary, None, None]: import beets.library - from beets_flask.config.beets_config import refresh_config - lib = beets.library.Library(path=os.environ["BEETSDIR"] + "/library.db") - refresh_config() - # Copy test audio data source = Path(__file__).parent / "data" / "audio" dest = Path(os.environ["HOME"]) / "audio" @@ -197,3 +210,60 @@ def local_redis(monkeypatch): yield log.debug("Unmocking beets_flask.redis") monkeypatch.undo() + + +lookup_cache_dir: Path + + +@pytest.fixture(scope="module", autouse=True) +def mock_tag_album(): + """Fixture that monkeypatches beets tag_album to use cached lookups.""" + # Create temp lookup cache directory once per module + global lookup_cache_dir + + lookup_cache_dir = Path(tempfile.mkdtemp(prefix="beets_lookup_cache_")) + + original_tag_album = autotag.tag_album + autotag.tag_album = tag_album + yield lookup_cache_dir + autotag.tag_album = original_tag_album + + +def tag_album( + items, + search_artist: str | None = None, + search_name: str | None = None, + search_ids: list[str] = [], +): + global lookup_cache_dir + # Compute items hash based on the items + m = hashlib.md5() + for item in items: + m.update(item.path) + if search_artist: + m.update(search_artist.encode("utf-8")) + if search_name: + m.update(search_name.encode("utf-8")) + for search_id in search_ids: + m.update(search_id.encode("utf-8")) + items_hash = m.hexdigest()[:8] + + cache_file = lookup_cache_dir / f"lookup_{items_hash}.pickle" + if cache_file.exists(): + log.debug(f"Using cached lookup from temp dir {cache_file}") + with open(cache_file, "rb") as f: + return pickle.load(f) + else: + # TODO: This pickle contains absolute paths to the files + # while undesired (no use in having them in the git repo) its for now the + # easiest way... and we hope music brainz does not change its data too often! + res = _tag_album(items, search_artist, search_name, search_ids) + + cache_file.parent.mkdir(parents=True, exist_ok=True) + with open(cache_file, "wb") as f: + pickle.dump(res, f) + + return res + + +autotag.tag_album = tag_album diff --git a/backend/tests/integration/test_flows.py b/backend/tests/integration/test_flows.py index 7e72b907..df3a9f33 100644 --- a/backend/tests/integration/test_flows.py +++ b/backend/tests/integration/test_flows.py @@ -15,6 +15,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session +from beets_flask.config.beets_config import get_config from beets_flask.database.models.states import ( FolderInDb, SessionStateInDb, @@ -40,7 +41,6 @@ from tests.unit.test_importer.conftest import ( VALID_PATHS, album_path_absolute, - use_mock_tag_album, ) @@ -99,7 +99,6 @@ class TestPreview(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryMixi ) def path(self, request) -> Path: path = album_path_absolute(request.param) - use_mock_tag_album(str(path)) return path async def test_preview( @@ -164,7 +163,6 @@ class TestPreviewMultipleTasks( @pytest.fixture() def path(self) -> Path: path = album_path_absolute("multi_flat") - use_mock_tag_album(str(path)) return path @pytest.mark.parametrize( @@ -247,7 +245,6 @@ class TestImportBest(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryM @pytest.fixture() def path(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path def check_mapping_consistency(self, db_session: Session): @@ -309,7 +306,7 @@ async def test_add_candidates(self, db_session: Session, path: Path): id_99_red_balloons, ], # Nena 99 Red Balloons "search_artist": None, - "search_album": None, + "search_name": None, } }, ) @@ -351,7 +348,7 @@ async def test_add_candidates_fails(self, db_session: Session, path: Path): "non_existing_id", ], # Nena 99 Red Balloons "search_artist": None, - "search_album": None, + "search_name": None, } }, ) @@ -391,7 +388,7 @@ async def test_add_candidates_cleared(self, db_session: Session, path: Path): id_99_red_balloons, ], # Nena 99 Red Balloons "search_artist": None, - "search_album": None, + "search_name": None, } }, ) @@ -711,7 +708,6 @@ class TestImportAuto(SendStatusMockMixin, IsolatedDBMixin, IsolatedBeetsLibraryM @pytest.fixture() def path(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path async def test_import_auto_accept(self, db_session: Session, path: Path): @@ -763,7 +759,6 @@ class TestImportAutoFails( @pytest.fixture() def path(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path async def test_import_auto_fails(self, db_session: Session, path: Path): @@ -823,7 +818,6 @@ class TestChooseCandidatesSingleTask( @pytest.fixture() def path_single_task(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path async def test_choose_candidates( @@ -849,6 +843,7 @@ async def test_choose_candidates( assert s_state_indb.folder.full_path == str(path_single_task) assert len(s_state_indb.tasks) == 1 + # Should have to candidates (one from mb and one from spotify) choosen_candidate = s_state_indb.tasks[0].candidates[-2] exc = await run_import_candidate( @@ -885,7 +880,6 @@ class TestMultipleTasks( @pytest.fixture() def path_multiple_tasks(self) -> Path: path = album_path_absolute("multi") - use_mock_tag_album(str(path)) return path async def test_choose_candidates_multiple_tasks( @@ -895,6 +889,13 @@ async def test_choose_candidates_multiple_tasks( ): """Test the import of the tagged folder.""" + # avoid duplicate errors when re-importing. + # TODO: fix beets lib mixin, this should not be necessary, + # if library is cleared correctly. + config = get_config() + config.data.import_.duplicate_action = "remove" + config.commit_to_beets() + exc = await run_preview( "obsolete_hash_preview", str(path_multiple_tasks), @@ -914,9 +915,7 @@ async def test_choose_candidates_multiple_tasks( candidates: TaskIdMappingArg[CandidateChoice] = {} assert candidates is not None for task in s_state_indb.tasks: - print(task.paths) - print([c.metadata for c in task.candidates]) - assert len(task.candidates) > 2, "Should have candidates" + assert len(task.candidates) >= 2, "Should have candidates" candidates[task.id] = task.candidates[-2].id # Check that we have the same number of candidates as tasks @@ -975,7 +974,7 @@ async def test_duplicate_action( assert duplicate_actions is not None for task in s_state_indb.tasks: - assert len(task.candidates) > 2, "Should have candidates" + assert len(task.candidates) >= 2, "Should have candidates" candidates[task.id] = task.candidates[-2].id duplicate_actions[task.id] = duplicate_action @@ -1005,7 +1004,6 @@ class TestPluginEvents( @pytest.fixture() def path(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path async def test_preview_events(self, db_session: Session, path: Path): @@ -1106,7 +1104,6 @@ class TestImportBootleg( @pytest.fixture() def path(self) -> Path: path = album_path_absolute(VALID_PATHS[0]) - use_mock_tag_album(str(path)) return path async def test_import_bootleg(self, db_session: Session, path: Path): diff --git a/backend/tests/integration/test_routes/test_db_models.py b/backend/tests/integration/test_routes/test_db_models.py index 636151d6..d452f1f1 100644 --- a/backend/tests/integration/test_routes/test_db_models.py +++ b/backend/tests/integration/test_routes/test_db_models.py @@ -9,6 +9,7 @@ from beets_flask.database.models.states import FolderInDb, SessionStateInDb from beets_flask.importer.states import SessionState from tests.conftest import beets_lib_item +from tests.mixins.database import IsolatedDBMixin from tests.unit.test_importer.test_states import get_album_match @@ -48,7 +49,7 @@ async def session_in_db(db_session_factory, import_task, tmpdir_factory): db_session.commit() -class TestSessionEndpoint: +class TestSessionEndpoint(IsolatedDBMixin): """Test the end to end functionality of the model endpoints. We automatically generate the endpoints for the sqlalchemy models. Thus we also diff --git a/backend/tests/integration/test_routes/test_file_upload.py b/backend/tests/integration/test_routes/test_file_upload.py new file mode 100644 index 00000000..9486b54c --- /dev/null +++ b/backend/tests/integration/test_routes/test_file_upload.py @@ -0,0 +1,206 @@ +import pytest + + +class TestFileUploadRoute: + @pytest.mark.parametrize( + "filename, target_dir, expected_filename, expected_dir", + [ + ( + "simple.txt", + "upload_1", + "simple.txt", + "upload_1", + ), + ( + "file%20with%20spaces.txt", + "upload_1/nested%20dir", + "file with spaces.txt", + "upload_1/nested dir", + ), + ( + "file.txt", + "foo/bar/nested%2Fsubdir", + "file.txt", + "foo/bar/nested/subdir", + ), + ( + "file.txt", + "foo/bar/nested%5Csubdir", + "file.txt", + "foo/bar/nested\\subdir", + ), + ], + ) + async def test_successful_file_upload( + self, + client, + tmp_path, + monkeypatch, + filename, + target_dir, + expected_filename, + expected_dir, + ): + # Setup: create a valid inbox directory + inbox_dir = tmp_path / "inbox" + inbox_dir.mkdir(parents=True, exist_ok=True) + + # Monkey patch the inbox folders to include our temp inbox + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: [str(inbox_dir)], + ) + + # Perform the upload + file_content = b"hello test file upload" + headers = { + "X-Filename": filename, + "X-File-Target-Dir": str(inbox_dir / target_dir), + } + response = await client.post( + "/api_v1/file_upload/", + headers=headers, + data=file_content, + ) + + # Check status codes + data = await response.get_json() + print(data) + assert response.status_code == 200 + assert data["status"] == "ok" + + # Check file exists in target dir and content matches + final_path = inbox_dir / expected_dir / expected_filename + assert final_path.exists() + with open(final_path, "rb") as f: + assert f.read() == file_content + + @pytest.mark.parametrize( + "headers", + [ + {"X-File-Target-Dir": "/some/path"}, + {"X-Filename": "file.txt"}, + ], + ) + async def test_missing_required_headers(self, client, headers): + response = await client.post( + "/api_v1/file_upload/", + headers=headers, + data=b"testdata", + ) + data = await response.get_json() + assert str(response.status_code).startswith("4") + assert data["type"] == "InvalidUsageException" + assert "Missing header" in data["message"] + + async def test_invalid_target_path(self, client, monkeypatch): + # Patch get_inbox_folders to return a known inbox path + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: ["/valid/inbox"], + ) + response = await client.post( + "/api_v1/file_upload/", + headers={ + "X-Filename": "file.txt", + "X-File-Target-Dir": "/invalid/path", + }, + data=b"testdata", + ) + data = await response.get_json() + assert str(response.status_code).startswith("4") + assert data["type"] == "InvalidUsageException" + assert "Invalid target path" in data["message"] + + async def test_invalid_filename_with_path_separators(self, client, monkeypatch): + # Patch get_inbox_folders to return a known inbox path + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: ["/valid/inbox"], + ) + response = await client.post( + "/api_v1/file_upload/", + headers={ + "X-Filename": "invalid/../file.txt", + "X-File-Target-Dir": "/valid/inbox", + }, + data=b"testdata", + ) + data = await response.get_json() + assert str(response.status_code).startswith("4") + assert data["type"] == "InvalidUsageException" + assert "Invalid filename" in data["message"] + + +class TestFileUploadValidationRoute: + async def test_validate_endpoint_success(self, client, monkeypatch): + """Test that the validate endpoint returns 200 for valid headers.""" + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: ["/valid/inbox"], + ) + response = await client.post( + "/api_v1/file_upload/validate", + headers={ + "X-Filename": "test.txt", + "X-File-Target-Dir": "/valid/inbox/test_dir", + }, + data=b"", + ) + assert response.status_code == 200 + data = await response.get_json() + assert data["status"] == "ok" + + async def test_validate_endpoint_missing_headers(self, client): + """Test that the validate endpoint raises InvalidUsageException for missing headers.""" + response = await client.post( + "/api_v1/file_upload/validate", + headers={ + "X-Filename": "test.txt", + }, + data=b"", + ) + assert str(response.status_code).startswith("4") + data = await response.get_json() + assert data["type"] == "InvalidUsageException" + assert "Missing header" in data["message"] + + async def test_validate_endpoint_invalid_filename(self, client, monkeypatch): + """Test that the validate endpoint raises InvalidUsageException for invalid filenames.""" + # Patch get_inbox_folders to return a known inbox path + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: ["/valid/inbox"], + ) + response = await client.post( + "/api_v1/file_upload/validate", + headers={ + "X-Filename": "invalid/../file.txt", + "X-File-Target-Dir": "/valid/inbox", + }, + data=b"", + ) + assert str(response.status_code).startswith("4") + data = await response.get_json() + assert data["type"] == "InvalidUsageException" + assert "Invalid filename" in data["message"] + + async def test_validate_endpoint_invalid_target_path(self, client, monkeypatch): + """Test that the validate endpoint raises InvalidUsageException for invalid target paths.""" + # Patch get_inbox_folders to return a known inbox path + monkeypatch.setattr( + "beets_flask.server.routes.file_upload.get_inbox_folders", + lambda: ["/valid/inbox"], + ) + response = await client.post( + "/api_v1/file_upload/validate", + headers={ + "X-Filename": "file.txt", + "X-File-Target-Dir": "/invalid/path", + }, + data=b"", + ) + assert str(response.status_code).startswith("4") + data = await response.get_json() + assert data["type"] == "InvalidUsageException" + assert "Invalid target path" in data["message"] diff --git a/backend/tests/integration/test_routes/test_library.py b/backend/tests/integration/test_routes/test_library.py index d660685a..b06f742a 100644 --- a/backend/tests/integration/test_routes/test_library.py +++ b/backend/tests/integration/test_routes/test_library.py @@ -10,6 +10,7 @@ from beets.library import Album from quart.typing import TestClientProtocol as Client +from beets_flask.config import get_config from tests.conftest import beets_lib_album, beets_lib_item from tests.mixins.database import IsolatedBeetsLibraryMixin @@ -127,15 +128,15 @@ async def test_separator(self, client: Client): async def test_no_separators(self, client: Client): # Validate that the logic works if no separators are defined - from beets_flask.config.beets_config import refresh_config - config = refresh_config() - config["gui"]["artist_separators"] = [] + config = get_config() + config.reload() + config.data.gui.library.artist_separators = [] # Mock artist_seperators with mock.patch( - "beets_flask.server.routes.library.artists.ARTIST_SEPARATORS", - [], + "beets_flask.server.routes.library.artists.artist_separators", + lambda: [], ): response = await client.get("/api_v1/library/artists/Foo; Bar") data = await response.get_json() diff --git a/backend/tests/integration/test_watchdog.py b/backend/tests/integration/test_watchdog.py index 4138606a..e639fb2b 100644 --- a/backend/tests/integration/test_watchdog.py +++ b/backend/tests/integration/test_watchdog.py @@ -5,7 +5,7 @@ import pytest from beets_flask.config import get_config -from beets_flask.config.beets_config import refresh_config +from beets_flask.config.schema import InboxFolderSchema from beets_flask.watchdog.inbox import InboxHandler, register_inboxes @@ -16,14 +16,15 @@ def preview_autotag(tmpdir_factory): This fixture will run before and after all tests in this module. """ config = get_config() - config["gui"]["inbox"]["folders"] = { - "inbox1": { - "path": tmpdir_factory.mktemp("inbox").strpath, - "autotag": "preview", - }, + config.data.gui.inbox.folders = { + "inbox1": InboxFolderSchema( + name="inbox1", + path=tmpdir_factory.mktemp("inbox").strpath, + autotag="preview", + ), } yield - refresh_config() + config.reload() from unittest import mock @@ -50,9 +51,8 @@ async def test_watchdog(preview_autotag, mp_en): """Start watching the inbox folder""" config = get_config() - print("foo") - inbox_path = Path(str(config["gui"]["inbox"]["folders"]["inbox1"]["path"].get())) - inbox_autotag = config["gui"]["inbox"]["folders"]["inbox1"]["autotag"].get() + inbox_path = Path(str(config.data.gui.inbox.folders["inbox1"].path)) + inbox_autotag = config.data.gui.inbox.folders["inbox1"].autotag assert inbox_path.is_dir(), "Inbox path should be a directory" assert inbox_autotag == "preview", "Inbox autotag should be set to 'preview'" diff --git a/backend/tests/mixins/database.py b/backend/tests/mixins/database.py index d7c68ef2..ae1ebe08 100644 --- a/backend/tests/mixins/database.py +++ b/backend/tests/mixins/database.py @@ -9,6 +9,8 @@ import pytest +from beets_flask.config import get_config + if TYPE_CHECKING: from beets_flask.importer.types import BeetsLibrary @@ -74,8 +76,6 @@ def setup_beetslib( """Automatically reset the beets library before and after ALL tests in this class.""" import beets.library - from beets_flask.config.beets_config import refresh_config - try: os.remove(os.environ["BEETSDIR"] + "/library.db") except OSError: @@ -84,8 +84,9 @@ def setup_beetslib( path=os.environ["BEETSDIR"] + "/library.db", directory=os.environ["BEETSDIR"] + "/imported", ) - config = refresh_config() - config["directory"] = os.environ["BEETSDIR"] + "/imported" + config = get_config().reload() + config.data.directory = os.environ["BEETSDIR"] + "/imported" + config.commit_to_beets() # Reset the beets library to a clean state yield print("Resetting beets library to a clean state...") @@ -100,13 +101,13 @@ def beets_lib(self) -> BeetsLibrary: """Return the beets library instance.""" import beets.library - from beets_flask.config.beets_config import refresh_config - lib = beets.library.Library( path=os.environ["BEETSDIR"] + "/library.db", directory=os.environ["BEETSDIR"] + "/imported", ) - refresh_config() + config = get_config().reload() + config.data.directory = os.environ["BEETSDIR"] + "/imported" + config.commit_to_beets() # mock needed for the library to be available in the resources endpoints with mock.patch( diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 00000000..04c5d8be --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -0,0 +1,204 @@ +import os +import shutil +import tempfile +from pathlib import Path + +import pytest +import yaml +from eyconf.validation import MultiConfigurationError + +from beets_flask.config import get_config + + +@pytest.fixture() +def hide_config(): + """We have to be carful to not delete our defaults""" + config = get_config() + paths = [config.get_beets_config_path(), config.get_beets_flask_config_path()] + + for p in paths: + shutil.move(p, str(p) + "_bak") + + yield + + for p in paths: + try: + os.unlink(p) + except: + pass + shutil.move(str(p) + "_bak", p) + + +class TestConfig: + def test_path_getters(self): + """Test that config path getters return correct paths.""" + config = get_config() + beets_path = config.get_beets_config_path() + beets_flask_path = config.get_beets_flask_config_path() + + assert beets_path.is_relative_to(os.environ["BEETSDIR"]) + assert beets_flask_path.is_relative_to(os.environ["BEETSFLASKDIR"]) + + def test_write_examples(self, hide_config): + """Test that example config files are written as user defaults.""" + + config = get_config() + beets_path = config.get_beets_config_path() + beets_flask_path = config.get_beets_flask_config_path() + + config.write_examples_as_user_defaults() + + assert beets_path.exists() + assert beets_flask_path.exists() + + def test_set_and_validate(self): + """Test that config validation works as expected.""" + config = get_config() + + # test that wrongly typed fields raise errors + config.data.gui.num_preview_workers = "not an int" # type: ignore + + with pytest.raises(MultiConfigurationError): + config.validate() + + # eyconf currently does not forbid setting wrong types + # so we can work with that in the mean time + assert config.data.gui.num_preview_workers == "not an int" + + def test_reload(self): + """Test that config reload works as expected.""" + + config = get_config() + config.data.gui.num_preview_workers = "not an int" # type: ignore + + # previous set should have modified global singleton + config = get_config() + assert config.data.gui.num_preview_workers == "not an int" + + # test reloading via kwarg + config = get_config(force_reload=True) + assert config.data.gui.num_preview_workers != "not an int" + + # Modify a field + original_value = config.data.directory + config.data.directory = "/some/other/path" + + # Reload the config + config.reload() + + # Check that the field is back to original value + assert config.data.directory == original_value + + # test reloading from file modifications that occur during runtime + beets_flask_path = config.get_beets_flask_config_path() + with open(beets_flask_path) as f: + content = f.read() + modified_content = content.replace( + "num_preview_workers: 4", "num_preview_workers: 7" + ) + with open(beets_flask_path, "w") as f: + f.write(modified_content) + + config.reload() + assert config.data.gui.num_preview_workers == 7 + + # revert changes on disk + with open(beets_flask_path, "w") as f: + f.write(content) + + def test_commit_to_beets(self): + """Test that committing to beets config works as expected.""" + import beets + + # pass commit_to_beets, to makkee sure both configs are in sync + config = get_config(force_reload=True, commit_to_beets=True) + + # Modify a field in beets-flask config + old_directory = config.data.directory + new_directory = "/new/music/directory" + config.data.directory = new_directory + + # Changes should not be in beets yet + assert beets.config["directory"].get() == old_directory + assert config.beets_config["directory"].get() == old_directory + + # Commit to beets config + config.commit_to_beets() + + # Check that the beets config has been updated + assert beets.config["directory"].get() == new_directory + assert config.beets_config["directory"].get() == new_directory + + # Revert changes + config.data.directory = old_directory + config.commit_to_beets() + + def test_commit_to_beets_alias(self): + """ + For reserved keywords, we use eyconfs aliasing. + """ + import beets + + config = get_config() + + # we dont know if keep might be the old action + config.data.import_.duplicate_action = "keep" + config.commit_to_beets() + assert beets.config["import"]["duplicate_action"].get() == "keep" + + config.data.import_.duplicate_action = "skip" + config.commit_to_beets() + assert beets.config["import"]["duplicate_action"].get() == "skip" + + config.data.import_.duplicate_action = "remove" + config.commit_to_beets() + assert beets.config["import"]["duplicate_action"].get() == "remove" + + +class TestValidationFixes: + def test_inbox_folder_name_from_heading(self): + """Inbox names are optional, check order and defaults.""" + + config = get_config() + + with tempfile.TemporaryDirectory() as temp_dir: + temp = { + "gui": { + "inbox": { + "folders": { + "inbox_1": {"path": temp_dir}, + "inbox_2": {"path": temp_dir, "name": "a"}, + } + } + } + } + temp_path = Path(temp_dir) / "temp1.yaml" + with open(temp_path, "w") as f: + yaml.dump(temp, f) + + config = config.reload(extra_yaml_path=temp_path) + assert config.data.gui.inbox.folders["inbox_1"].name == "inbox_1" + assert config.data.gui.inbox.folders["inbox_2"].name == "a" + + def test_inbox_folder_does_not_exist(self): + config = get_config() + + with tempfile.TemporaryDirectory() as temp_dir: + temp = { + "gui": { + "inbox": { + "folders": { + "inbox_1": {"path": Path(temp_dir) / "foo"}, + } + } + } + } + temp_path = Path(temp_dir) / "temp1.yaml" + with open(temp_path, "w") as f: + yaml.dump(temp, f) + + with pytest.raises(Exception): + config = config.reload(extra_yaml_path=temp_path) + + def test_slash_removal(self): + pass diff --git a/backend/tests/unit/test_database/mapper/test_match.py b/backend/tests/unit/test_database/mapper/test_match.py new file mode 100644 index 00000000..edc1565e --- /dev/null +++ b/backend/tests/unit/test_database/mapper/test_match.py @@ -0,0 +1,371 @@ +from beets.autotag.distance import Distance +from beets.autotag.distance import Distance as BeetsDistance +from beets.autotag.hooks import AlbumInfo as BeetsAlbumInfo +from beets.autotag.hooks import AlbumMatch as BeetsAlbumMatch +from beets.autotag.hooks import TrackInfo as BeetsTrackInfo +from beets.autotag.hooks import TrackMatch as BeetsTrackMatch + +from beets_flask.database.mapper.base import Context +from beets_flask.database.mapper.match import ( + AlbumInfoMapper, + AlbumMatchMapper, + DistanceMapper, + MatchMapper, + TrackInfoMapper, + TrackMatchMapper, +) +from beets_flask.database.models.match import TrackInfo +from tests.conftest import beets_lib_item + + +class TestTrackInfoMapper: + """Tests that we can probably serialize and deserialize + beets TrackInfo objs. + """ + + def test_roundtrip_conversion(self): + """Test that we can convert BeetsTrackInfo to TrackInfo and back.""" + from beets.autotag.hooks import TrackInfo as BeetsTrackInfo + + original = BeetsTrackInfo( + title="Test Track", + artist="Test Artist", + album="Test Album", + length=180.0, + index=1, + ) + + mapper = TrackInfoMapper() + ctx = Context() + + # Test from_beets + model = mapper.from_beets(original, ctx) + assert isinstance(model, TrackInfo) + assert model.data["title"] == "Test Track" + assert model.data["artist"] == "Test Artist" + assert model.data["album"] == "Test Album" + assert model.data["length"] == 180.0 + assert model.data["index"] == 1 + + # Test to_beets + result = mapper.to_beets(model, ctx) + assert result.title == original.title + assert result.artist == original.artist + assert result.album == original.album + assert result.length == original.length + assert result.index == original.index + assert result.genre == original.genre + + +class TestAlbumInfoMatcher: + """Tests that we can probably serialize and deserialize + beets AlbumInfo objs. + """ + + def test_roundtrip_conversion(self): + """Test converting model AlbumInfo to BeetsAlbumInfo.""" + from beets.autotag.hooks import AlbumInfo as BeetsAlbumInfo + from beets.autotag.hooks import TrackInfo as BeetsTrackInfo + + original = BeetsAlbumInfo( + tracks=[ + BeetsTrackInfo(title="a"), + BeetsTrackInfo(title="b"), + ], + year=1, + ) + + mapper = AlbumInfoMapper() + ctx = Context() + + # Test from_beets + model = mapper.from_beets(original, ctx) + assert model.data["year"] == 1 + assert len(model.tracks) == 2 + assert model.tracks[0].data["title"] == "a" + assert model.tracks[1].data["title"] == "b" + + # Test to_beets + result = mapper.to_beets(model, ctx) + assert result.year == original.year + assert len(result.tracks) == len(original.tracks) + assert result.tracks[0].title == original.tracks[0].title + assert result.tracks[1].title == original.tracks[1].title + + +class TestDistanceMapper: + """Tests that we can probably serialize and deserialize + beets Distance objs. + """ + + def test_roundtrip_conversion(self): + """Test converting model Distance to BeetsDistance.""" + + from beets.autotag.distance import Distance as BeetsDistance + + original = BeetsDistance() + original.add("artist", 0.1) + original.add("album", 0.2) + + mapper = DistanceMapper() + ctx = Context() + + # Test from_beets + model = mapper.from_beets(original, ctx) + assert model.max_distance == original.max_distance + assert model.raw_distance == original.raw_distance + + # Test to_beets + result = mapper.to_beets(model, ctx) + assert result.distance == original.distance + assert result.max_distance == original.max_distance + assert result.raw_distance == original.raw_distance + assert result._penalties == original._penalties + + +def create_beets_album_match( + album_id="abc123", + album_name="Test Album", + album_artist="Test Artist", + album_track_count=2, + album_url="https://example.com/album", + album_image_path="/path/to/image.jpg", + album_disambig="", + tracks=None, + distance_penalties=None, + track_distances=None, + mapping=None, + extra_items=None, + extra_tracks=None, +): + """Factory function to generate beets AlbumMatch objects for testing purposes. + + Args: + album_id: The album ID (default: "abc123") + album_name: The album name (default: "Test Album") + album_artist: The album artist (default: "Test Artist") + album_track_count: Number of tracks to generate (default: 2) + album_url: Album URL (default: "https://example.com/album") + album_image_path: Album cover path (default: "/path/to/image.jpg") + album_disambig: Album disambiguation (default: "") + tracks: Custom list of TrackInfo objects. If None, generates from album_track_count. + distance_penalties: Dict of {key: value} penalties. Defaults to {"artist": 0.1, "album": 0.2} + track_distances: Dict of {TrackInfo: Dict of {key: value}} for track-level penalties. + e.g., {track1: {"track_title": 0.05}, track2: {"track_title": 0.0}} + mapping: Dict of {Item: TrackInfo} mappings. If None, generates from album_track_count. + extra_items: List of extra Item objects. If None, generates from album_track_count. + extra_tracks: List of extra TrackInfo objects. If None, generates from album_track_count. + + Returns: + beets.autotag.hooks.AlbumMatch: A test AlbumMatch object + """ + + # Default distance penalties + if distance_penalties is None: + distance_penalties = {"artist": 0.1, "album": 0.2} + + # Generate tracks if not provided + if tracks is None: + tracks = [] + for i in range(album_track_count): + track = BeetsTrackInfo( + title=f"Test Track {i + 1}", + artist=album_artist, + length=180.0 + i * 20, + index=i + 1, + ) + tracks.append(track) + + # Create AlbumInfo with the tracks + album_info = BeetsAlbumInfo( + album=album_name, + artist=album_artist, + tracks=tracks, + album_id=album_id, + album_url=album_url, + album_image_path=album_image_path, + album_disambig=album_disambig, + ) + + # Create Distance with penalties + distance = Distance() + for key, value in distance_penalties.items(): + distance.add(key, value) + + # Add track-level distances + if track_distances is not None: + for track, penalties in track_distances.items(): + track_distance = Distance() + for key, value in penalties.items(): + track_distance.add(key, value) + distance.tracks[track] = track_distance + + # Generate mapping if not provided + if mapping is None: + mapping = {} + for i in range(album_track_count): + item = beets_lib_item(title=f"mapping-{i}") + info = BeetsTrackInfo(title=f"mapping-{i}") + mapping[item] = info + + # Generate extra_tracks if not provided + if extra_tracks is None: + extra_tracks = [] + for i in range(album_track_count): + extra_tracks.append(BeetsTrackInfo(title=f"extra-{i}")) + + # Generate extra_items if not provided + if extra_items is None: + extra_items = [] + for i in range(album_track_count): + extra_items.append(beets_lib_item(title=f"extra-item-{i}")) + + return BeetsAlbumMatch( + distance=distance, + info=album_info, + mapping=mapping, + extra_tracks=extra_tracks, + extra_items=extra_items, + ) + + +class TestAlbumMatchMapper: + def test_roundtrip_conversion(self): + """Test converting model TrackMatch to BeetsTrackMatch.""" + + beets_track1 = BeetsTrackInfo(title="Test Track 1") + beets_track2 = BeetsTrackInfo(title="Test Track 2") + + # Create some extra items using the test fixture + extra_item1 = beets_lib_item(title="extra-item-1") + extra_item2 = beets_lib_item(title="extra-item-2") + + beets_album_match = create_beets_album_match( + album_id="abc123", + tracks=[beets_track1, beets_track2], + distance_penalties={"artist": 0.1, "album": 0.2}, + track_distances={ + beets_track1: {"track_title": 0.05}, + beets_track2: {"track_title": 0.0}, + }, + # We reuse objs here. Is a bit unrealistic + # but fully tests our capabilites + extra_tracks=[beets_track1], + extra_items=[extra_item1, extra_item2], + mapping={extra_item1: beets_track1}, + ) + + mapper = AlbumMatchMapper() + ctx = Context() + + # Test from_beets conversion + model = mapper.from_beets(beets_album_match, ctx) + assert model.info.data["album_id"] == "abc123" + assert model.info.data["album"] == "Test Album" + assert model.info.data["artist"] == "Test Artist" + assert len(model.info.tracks) == 2 + assert model.info.tracks[0].data["title"] == "Test Track 1" + assert model.info.tracks[1].data["title"] == "Test Track 2" + assert model.distance.raw_distance == beets_album_match.distance.raw_distance + penalty_keys = {p.key for p in model.distance.penalties} + assert penalty_keys == {"artist", "album"} + assert len(model.distance.track_distances) == 2 + assert ( + len(model.track_mappings) == 4 # 1 mapping + 1 extra_track + 2 extra_items + ) + + # Test to_beets conversion + result = mapper.to_beets(model, ctx) + assert result.info.album_id == "abc123" + assert result.info.album == "Test Album" + assert result.info.artist == "Test Artist" + assert len(result.info.tracks) == 2 + assert result.info.tracks[0].title == "Test Track 1" + assert result.info.tracks[1].title == "Test Track 2" + assert result.distance.raw_distance == beets_album_match.distance.raw_distance + assert len(result.mapping) == 1 + # Check dedbped worked as expected + assert result.extra_items[0] in result.mapping.keys() + assert result.mapping[result.extra_items[0]].title == beets_track1.title + assert len(result.extra_items) == 2 + assert len(result.extra_tracks) == 1 + + +class TestTrackMatchMapper: + def test_roundtrip_conversion(self): + """Test converting model TrackMatch to BeetsTrackMatch.""" + + track_distance = BeetsDistance() + track_distance.add("artist", 0.1) + track_distance.add("album", 0.2) + beets_track1 = BeetsTrackInfo( + title="Test Track 1", + artist="Test Artist", + length=180.0, + index=1, + ) + original = BeetsTrackMatch(distance=track_distance, info=beets_track1) + + mapper = TrackMatchMapper() + ctx = Context() + + # Test from_beets + model = mapper.from_beets(original, ctx) + assert isinstance(model.info, TrackInfo) + assert model.info.data["title"] == "Test Track 1" + assert model.info.data["artist"] == "Test Artist" + assert model.info.data["length"] == 180.0 + assert model.distance.raw_distance == track_distance.raw_distance + assert len(model.distance.penalties) == 2 + + # Test to_beets + result = mapper.to_beets(model, ctx) + assert isinstance(result, BeetsTrackMatch) + assert result.info.title == beets_track1.title + assert result.info.artist == beets_track1.artist + assert result.info.length == beets_track1.length + assert result.distance.raw_distance == original.distance.raw_distance + + # Verify penalties are preserved + penalty_keys = {p.key for p in model.distance.penalties} + assert penalty_keys == {"artist", "album"} + + def test_roundtrip_album_match(self): + """Test roundtrip conversion for AlbumMatch.""" + beets_track1 = BeetsTrackInfo(title="Test Track 1") + beets_album_match = create_beets_album_match( + album_id="abc123", + album_name="Test Album", + tracks=[beets_track1], + distance_penalties={"artist": 0.1}, + ) + + mapper = MatchMapper() + ctx = Context() + + model = mapper.from_beets(beets_album_match, ctx) + result = mapper.to_beets(model, ctx) + + assert isinstance(result, BeetsAlbumMatch) + assert result.info.album_id == "abc123" + assert result.info.album == "Test Album" + + def test_roundtrip_track_match(self): + """Test roundtrip conversion for TrackMatch.""" + from beets.autotag.distance import Distance as BeetsDistance + + track_distance = BeetsDistance() + track_distance.add("artist", 0.1) + + beets_track = BeetsTrackInfo(title="Test Track") + beets_track_match = BeetsTrackMatch(distance=track_distance, info=beets_track) + + mapper = MatchMapper() + ctx = Context() + + model = mapper.from_beets(beets_track_match, ctx) + result = mapper.to_beets(model, ctx) + + assert isinstance(result, BeetsTrackMatch) + assert result.info.title == "Test Track" + assert result.distance.raw_distance == track_distance.raw_distance diff --git a/backend/tests/unit/test_database/test_dates.py b/backend/tests/unit/test_database/test_dates.py index c1931ca4..afeed974 100644 --- a/backend/tests/unit/test_database/test_dates.py +++ b/backend/tests/unit/test_database/test_dates.py @@ -1,8 +1,6 @@ import datetime from pathlib import Path -import pytz - from beets_flask.database.models import SessionStateInDb from beets_flask.importer.session import SessionState from tests.mixins.database import IsolatedDBMixin @@ -38,8 +36,8 @@ def test_dates(self, db_session_factory, tmpdir_factory): # Check that the timezone is UTC assert state_in_db.created_at.tzinfo is not None assert state_in_db.updated_at.tzinfo is not None - assert state_in_db.created_at.tzinfo == pytz.UTC - assert state_in_db.updated_at.tzinfo == pytz.UTC + assert state_in_db.created_at.tzinfo == datetime.UTC + assert state_in_db.updated_at.tzinfo == datetime.UTC # Should be approximately equal to current local time now = datetime.datetime.now().astimezone() diff --git a/backend/tests/unit/test_database/test_migration.py b/backend/tests/unit/test_database/test_migration.py new file mode 100644 index 00000000..90815e08 --- /dev/null +++ b/backend/tests/unit/test_database/test_migration.py @@ -0,0 +1,122 @@ +"""Tests for database migration module.""" + +from unittest.mock import Mock + +import pytest +from sqlalchemy import text + +from beets_flask.database.migration import ( + _alembic_initialized, + _db_has_tables, + run_migrations, +) + + +class TestDbHasTables: + """Tests for _db_has_tables function.""" + + def test_returns_false_for_empty_database(self, db_session): + """Test that _db_has_tables returns False for an empty database.""" + # Drop all + from beets_flask.database.models.base import Base + + Base.metadata.drop_all(db_session.bind) + db_session.execute(text("DROP TABLE IF EXISTS test_table")) + db_session.commit() + + assert _db_has_tables(db_session.bind) is False + + def test_returns_true_when_tables_exist(self, db_session): + """Test that _db_has_tables returns True when tables exist.""" + db_session.execute(text("CREATE TABLE test_table (id INTEGER)")) + db_session.commit() + + assert _db_has_tables(db_session.bind) is True + + +class TestAlembicInitialized: + """Tests for _alembic_initialized function.""" + + def test_returns_false_when_table_does_not_exist(self, db_session): + """Test that _alembic_initialized returns False when table doesn't exist.""" + assert _alembic_initialized(db_session.bind) is False + + def test_returns_true_when_table_has_content(self, db_session): + """Test that _alembic_initialized returns True when table has content.""" + db_session.execute( + text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32))") + ) + db_session.execute( + text("INSERT INTO alembic_version (version_num) VALUES ('abc123')") + ) + db_session.commit() + + assert _alembic_initialized(db_session.bind) is True + + def test_returns_false_when_table_exists_but_empty(self, db_session): + """Test that _alembic_initialized returns False when table exists but is empty.""" + db_session.execute( + text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32))") + ) + db_session.execute(text("DELETE FROM alembic_version")) + db_session.commit() + + assert _alembic_initialized(db_session.bind) is False + + +class TestRunMigrations: + """Tests for run_migrations function.""" + + @pytest.fixture(autouse=True) + def setup(self): + import beets_flask.database.migration as mig + + mig.shutil.copy2 = Mock() + + return mig + + def test_runs_upgrade_empty_db(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=False) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + + # Check log + assert "Database empty" in caplog.text + assert "Database migrations complete." in caplog.text + + def test_runs_upgrade_alembic_missing(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=True) + mig._alembic_initialized = Mock(return_value=False) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + mig._alembic_initialized.assert_called_once() + + # Check log + assert "Database has no alembic" in caplog.text + assert "Database migrations complete." in caplog.text + + def test_runs_upgrade_alembic_exist(self, caplog): + import beets_flask.database.migration as mig + + mig._db_has_tables = Mock(return_value=True) + mig._alembic_initialized = Mock(return_value=True) + mig.command.upgrade = Mock() + + run_migrations() + mig._db_has_tables.assert_called_once() + mig.command.upgrade.assert_called_once() + mig._alembic_initialized.assert_called_once() + + # Check log + assert "Running database migrations..." in caplog.text + assert "Database migrations complete." in caplog.text diff --git a/backend/tests/unit/test_database/test_models_state.py b/backend/tests/unit/test_database/test_models_state.py index 8d8b4765..b306b9e0 100644 --- a/backend/tests/unit/test_database/test_models_state.py +++ b/backend/tests/unit/test_database/test_models_state.py @@ -8,6 +8,7 @@ from beets_flask.importer.session import SessionState from beets_flask.importer.stages import Progress from tests.conftest import beets_lib_item +from tests.mixins.database import IsolatedDBMixin from tests.unit.test_importer.test_states import get_album_match log = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def import_task(beets_lib): return task -class TestSessionStateInDb: +class TestSessionStateInDb(IsolatedDBMixin): state: SessionState @pytest.fixture(autouse=True) diff --git a/backend/tests/unit/test_database/test_setup.py b/backend/tests/unit/test_database/test_setup.py index c887f8e3..e1244734 100644 --- a/backend/tests/unit/test_database/test_setup.py +++ b/backend/tests/unit/test_database/test_setup.py @@ -5,17 +5,7 @@ from sqlalchemy.orm import Session from beets_flask.database.models.states import FolderInDb -from beets_flask.database.setup import _reset_database, with_db_session - - -def test_with_db_session_decorator(testapp): - # Needs the testapp - - @with_db_session - def sample_function(session=None): - return session is not None - - assert sample_function() is True +from beets_flask.database.setup import _reset_database def test_reset( diff --git a/backend/tests/unit/test_importer/conftest.py b/backend/tests/unit/test_importer/conftest.py index 5e284a6c..dcc8e28b 100644 --- a/backend/tests/unit/test_importer/conftest.py +++ b/backend/tests/unit/test_importer/conftest.py @@ -66,72 +66,3 @@ def valid_data_for_album_path(path: str | Path) -> dict: } else: raise NotImplementedError(f"Unknown test album path {p=}") - - -# ----------------- Monkeypath beets to use cached responses ----------------- # - -import hashlib -import pickle - -from beets import autotag -from beets.autotag import tag_album as _tag_album - -album_path: str - - -def use_mock_tag_album(a_dir: str): - """Use a cached lookup for the tag_album function in beets - this allows to not make requests to the internet when testing - the importer. - """ - global album_path - album_path = a_dir - - autotag.tag_album = tag_album - - -def tag_album( - items, - search_artist: str | None = None, - search_album: str | None = None, - search_ids: list[str] = [], -): - global album_path - log.debug(f"Using monkey patched lookup {album_path=}") - - # Compute items hash based on the items - - m = hashlib.md5() - for item in items: - m.update(item.path) - if search_artist: - m.update(search_artist.encode("utf-8")) - if search_album: - m.update(search_album.encode("utf-8")) - for search_id in search_ids: - m.update(search_id.encode("utf-8")) - items_hash = m.hexdigest()[:8] - - if (Path(album_path) / f"lookup_{items_hash}.pickle").exists(): - log.debug(f"Using cached lookup {album_path=}") - with open(Path(album_path) / f"lookup_{items_hash}.pickle", "rb") as f: - return pickle.load(f) - - else: - # TODO: This pickle contains absolute paths to the files - # while undesired (no use in having them in the git repo) its for now the - # easiest way... and we hope music brainz does not change its data too often! - log.debug(f"Using default lookup {album_path=}") - res = _tag_album(items, search_artist, search_album, search_ids) - - outdir = Path(album_path) - if not outdir.is_dir(): - outdir = outdir.parent - - with open(outdir / f"lookup_{items_hash}.pickle", "wb") as f: - pickle.dump(res, f) - - return res - - -autotag.tag_album = tag_album diff --git a/backend/tests/unit/test_importer/test_asyncpipe.py b/backend/tests/unit/test_importer/test_asyncpipe.py index 2ac5bfa0..0cc8f080 100644 --- a/backend/tests/unit/test_importer/test_asyncpipe.py +++ b/backend/tests/unit/test_importer/test_asyncpipe.py @@ -1,13 +1,9 @@ import asyncio import logging -import time - -import pytest from beets_flask.importer.pipeline import AsyncPipeline -@pytest.mark.asyncio async def test_smoke_async(caplog): """Async version of the beets smoke test for pipeline @@ -58,55 +54,3 @@ async def consume(): ("logger", logging.INFO, "working 4"), ("logger", logging.INFO, "consuming 8"), ] - - -@pytest.mark.asyncio -async def test_smoke_sync(caplog): - """Async version of the beets smoke test - for pipeline - """ - - log = logging.getLogger("logger") - log.setLevel(logging.INFO) - - def produce(): - for i in range(5): - log.info(f"producing {i}") - time.sleep(0.05) - yield i - - def work(): - num = yield - while True: - log.info(f"working {num}") - time.sleep(0.05) - num = yield num * 2 - - def consume(): - while True: - num = yield - time.sleep(0.05) - log.info(f"consuming {num}") - - initial_task = produce() - stages = [work(), consume()] - pipeline: AsyncPipeline = AsyncPipeline(initial_task, stages) - - await pipeline.run_async() - assert caplog.record_tuples == [ - ("logger", logging.INFO, "producing 0"), - ("logger", logging.INFO, "working 0"), - ("logger", logging.INFO, "consuming 0"), - ("logger", logging.INFO, "producing 1"), - ("logger", logging.INFO, "working 1"), - ("logger", logging.INFO, "consuming 2"), - ("logger", logging.INFO, "producing 2"), - ("logger", logging.INFO, "working 2"), - ("logger", logging.INFO, "consuming 4"), - ("logger", logging.INFO, "producing 3"), - ("logger", logging.INFO, "working 3"), - ("logger", logging.INFO, "consuming 6"), - ("logger", logging.INFO, "producing 4"), - ("logger", logging.INFO, "working 4"), - ("logger", logging.INFO, "consuming 8"), - ] diff --git a/backend/tests/unit/test_importer/test_session.py b/backend/tests/unit/test_importer/test_session.py index dba94180..414caf24 100644 --- a/backend/tests/unit/test_importer/test_session.py +++ b/backend/tests/unit/test_importer/test_session.py @@ -10,7 +10,6 @@ from .conftest import ( VALID_PATHS, album_path_absolute, - use_mock_tag_album, ) log = logging.getLogger(__name__) @@ -26,7 +25,6 @@ def test_generate_lookup(): """ for path in VALID_PATHS: p = Path(__file__).parent.parent.parent / "data" / "audio" / path - use_mock_tag_album(str(p)) state = SessionState(p) session = PreviewSession(state) @@ -45,15 +43,14 @@ def test_album_exists(album_paths: list[Path]): class TestPreviewSessions: - def get_state(self, path: str): + async def get_state(self, path: str): p = album_path_absolute(path) self.session = PreviewSession(SessionState(p)) - use_mock_tag_album(str(p)) - return self.session.run_sync() + return await self.session.run_async() @pytest.mark.parametrize("path", VALID_PATHS) - def test_candidates_url(self, path): - state = self.get_state(path) + async def test_candidates_url(self, path): + state = await self.get_state(path) for task in state.task_states: for candidate in task.candidate_states: if candidate.id.startswith("asis"): diff --git a/backend/tests/unit/test_setup.py b/backend/tests/unit/test_setup.py index b08ef9bc..7bfbcc9d 100644 --- a/backend/tests/unit/test_setup.py +++ b/backend/tests/unit/test_setup.py @@ -18,6 +18,10 @@ def test_config(): """Test that config is correctly set up for testing.""" import tempfile - dir = os.environ.get("BEETSFLASKDIR") - assert dir is not None - assert str(tempfile.tempdir) in dir + dir_bf = os.environ.get("BEETSFLASKDIR") + assert dir_bf is not None + assert str(tempfile.tempdir) in dir_bf + + dir_b = os.environ.get("BEETSDIR") + assert dir_b is not None + assert str(tempfile.tempdir) in dir_b diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 00000000..b47cdc30 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,2691 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "beets" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "confuse" }, + { name = "jellyfish" }, + { name = "lap" }, + { name = "mediafile" }, + { name = "numba" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pyyaml" }, + { name = "requests-ratelimiter" }, + { name = "scipy" }, + { name = "typing-extensions" }, + { name = "unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/9d/4f72fc5f772ca958f48263ca688bc60752bc7d752a248d6191c62e3bafb1/beets-2.7.1.tar.gz", hash = "sha256:95f20c4087be2f4b2ab400be07cbea380bec2720e2a83180b025a972ee94bd22", size = 2189644, upload-time = "2026-03-08T08:31:30.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3b/1ad6e0c1f3e3d3e6113634ec97f372b00f1caad5ead91b9744358964b4e2/beets-2.7.1-py3-none-any.whl", hash = "sha256:bbe5a6b29aa55f520e635ef9d4222d8f3d53431bd0d78c68ee5c8bb0f80426f1", size = 610663, upload-time = "2026-03-08T08:31:28.363Z" }, +] + +[[package]] +name = "beets-flask" +version = "2.0.0rc4" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "alembic" }, + { name = "beets" }, + { name = "cachetools" }, + { name = "confuse" }, + { name = "deprecated" }, + { name = "eyconf" }, + { name = "libtmux" }, + { name = "natsort" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "polars" }, + { name = "pydub" }, + { name = "pylast" }, + { name = "python-socketio" }, + { name = "python2ts" }, + { name = "quart" }, + { name = "requests" }, + { name = "rq" }, + { name = "sqlalchemy" }, + { name = "tinytag" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fakeredis" }, + { name = "furo" }, + { name = "mypy" }, + { name = "myst-nb" }, + { name = "myst-parser" }, + { name = "pandas-stubs" }, + { name = "paracelsus" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "sphinx" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxcontrib-mermaid" }, + { name = "sphinxcontrib-typer", extra = ["html"] }, + { name = "types-aiofiles" }, + { name = "types-cachetools" }, + { name = "types-deprecated" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] +docs = [ + { name = "furo" }, + { name = "myst-nb" }, + { name = "myst-parser" }, + { name = "paracelsus" }, + { name = "sphinx" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinxcontrib-mermaid" }, + { name = "sphinxcontrib-typer", extra = ["html"] }, +] +test = [ + { name = "fakeredis" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-benchmark" }, + { name = "pytest-cov" }, +] +typed = [ + { name = "mypy" }, + { name = "pandas-stubs" }, + { name = "types-aiofiles" }, + { name = "types-cachetools" }, + { name = "types-deprecated" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "alembic", specifier = ">=1.18.4" }, + { name = "beets", specifier = "==2.7.1" }, + { name = "cachetools", specifier = ">=5.3.3" }, + { name = "confuse", specifier = ">=2.0.1" }, + { name = "deprecated", specifier = ">=1.2.18" }, + { name = "eyconf", specifier = ">=0.5.0" }, + { name = "libtmux", specifier = ">=0.37.0" }, + { name = "natsort" }, + { name = "numpy" }, + { name = "pillow", specifier = ">=10.4.0" }, + { name = "polars", specifier = ">=1.36.1" }, + { name = "pydub" }, + { name = "pylast", specifier = ">=5.2.0" }, + { name = "python-socketio", specifier = ">=5.11.4" }, + { name = "python2ts", specifier = ">=0.6.1" }, + { name = "quart", specifier = ">=0.20.0" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "rq", specifier = ">=2.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.35" }, + { name = "tinytag" }, + { name = "typing-extensions" }, + { name = "uvicorn", specifier = ">=0.36.0" }, + { name = "watchdog", specifier = ">=5.0.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "fakeredis" }, + { name = "furo", specifier = ">=2024.8.6" }, + { name = "mypy", specifier = ">=1.14.1" }, + { name = "myst-nb", specifier = ">=1.1.2" }, + { name = "myst-parser", specifier = ">=4.0.0" }, + { name = "pandas-stubs" }, + { name = "paracelsus", specifier = ">=0.15.0" }, + { name = "pre-commit", specifier = ">=3.8.0" }, + { name = "pytest", specifier = ">=8.2.2" }, + { name = "pytest-asyncio", specifier = ">=0.23.8" }, + { name = "pytest-benchmark", specifier = ">=5.2.3" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, + { name = "ruff", specifier = ">=0.6.5" }, + { name = "sphinx", specifier = ">=8.0.2" }, + { name = "sphinx-copybutton", specifier = ">=0.5.2" }, + { name = "sphinx-inline-tabs", specifier = ">=2023.4.21" }, + { name = "sphinxcontrib-mermaid", specifier = ">=2.0.1" }, + { name = "sphinxcontrib-typer", extras = ["html"], specifier = ">=0.5.0" }, + { name = "types-aiofiles" }, + { name = "types-cachetools" }, + { name = "types-deprecated" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] +docs = [ + { name = "furo", specifier = ">=2024.8.6" }, + { name = "myst-nb", specifier = ">=1.1.2" }, + { name = "myst-parser", specifier = ">=4.0.0" }, + { name = "paracelsus", specifier = ">=0.15.0" }, + { name = "sphinx", specifier = ">=8.0.2" }, + { name = "sphinx-copybutton", specifier = ">=0.5.2" }, + { name = "sphinx-inline-tabs", specifier = ">=2023.4.21" }, + { name = "sphinxcontrib-mermaid", specifier = ">=2.0.1" }, + { name = "sphinxcontrib-typer", extras = ["html"], specifier = ">=0.5.0" }, +] +test = [ + { name = "fakeredis" }, + { name = "pytest", specifier = ">=8.2.2" }, + { name = "pytest-asyncio", specifier = ">=0.23.8" }, + { name = "pytest-benchmark", specifier = ">=5.2.3" }, + { name = "pytest-cov", specifier = ">=5.0.0" }, +] +typed = [ + { name = "mypy", specifier = ">=1.14.1" }, + { name = "pandas-stubs" }, + { name = "types-aiofiles" }, + { name = "types-cachetools" }, + { name = "types-deprecated" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, +] + +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/8b/0d3945a13955303b81272f759a0331e54c5c793da455e6f5706b89d2639c/cachetools-7.1.4.tar.gz", hash = "sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6", size = 40085, upload-time = "2026-05-21T22:40:43.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7b/1fc1c09cc0756cf25861a3be10565915953876da48bb228fb9a672b20a42/cachetools-7.1.4-py3-none-any.whl", hash = "sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54", size = 16761, upload-time = "2026-05-21T22:40:41.845Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "confuse" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/a6/444c7376439851ce1d07932f88b707910d4605466d1c313621943c738112/confuse-2.2.0.tar.gz", hash = "sha256:35c1b53e81be125f441bee535130559c935917b26aeaa61289010cd1f55c2b9e", size = 52496, upload-time = "2026-01-28T10:40:16.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/5d/a1333543a1b40fcbb08830330f6ea7bc7a3bda387a7a0c2bead534f5c01f/confuse-2.2.0-py3-none-any.whl", hash = "sha256:470c6aa1a5008c8d740267f2ad574e3a715b6dd873c1e5f8778b7f7abb954722", size = 27904, upload-time = "2026-01-28T10:40:14.508Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "eyconf" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/a8/36e8b7c870f8e7581fe7b3e604c3407e8fab87c411f359dcfa3afc7190ae/eyconf-0.7.0.tar.gz", hash = "sha256:d5fee1a9c5ce8d88aeb33278dce4d60c81ddbcdb939a25038a29ab32528e0a1d", size = 47280, upload-time = "2026-05-07T12:59:54.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/69/37a060f0e2c53fc4685aba0e998993e9bef8b4f745c40e42e2bc1296cc60/eyconf-0.7.0-py3-none-any.whl", hash = "sha256:cd8c16f6acec5acb5343ada82973f3bc56425870bafc18e66a3ac62873b23575", size = 40967, upload-time = "2026-05-07T12:59:53.196Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.35.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/50/b748233c02fa77e5105238190cc9bb58b852eb1c8b1d0763230d3a5b745a/fakeredis-2.35.1.tar.gz", hash = "sha256:5bae5eba7b9d93cb968944ac40936373cf2397ff71667d4b595df65c3d2e413f", size = 189118, upload-time = "2026-04-12T17:05:58.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/27/b8b057a23f7777177e92d3a602fd866751b6b45014964548997e92e048fd/fakeredis-2.35.1-py3-none-any.whl", hash = "sha256:67d97e11f562b7870e11e5c30cf182270bfb2dd37f6707dba47cc6d91628d1b9", size = 129678, upload-time = "2026-04-12T17:05:56.86Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, +] + +[[package]] +name = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "9.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/c2/c0064cf15d026501a1ef70e42efd9c3f818663089399aacc5e37a82901c1/ipython-9.14.0.tar.gz", hash = "sha256:6f27ff0f1d9ea050e0551f71568bc4b34d8aba579e8f111c5b4175f44ac6b4aa", size = 4432601, upload-time = "2026-05-29T15:13:24.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a3/9e59340f02c1dc8f8c0a05b09244712b8609eb5439f9996e887e2b82f452/ipython-9.14.0-py3-none-any.whl", hash = "sha256:8fd984a3372c14b12790b084ba6b5cff5678c0cb063244a0034f06a51f20d6c2", size = 627457, upload-time = "2026-05-29T15:13:22.942Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jellyfish" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/14/fc5bdb637996df181e5c4fa3b15dcc27d33215e6c41753564ae453bdb40f/jellyfish-1.2.1.tar.gz", hash = "sha256:72d2fda61b23babe862018729be73c8b0dc12e3e6601f36f6e65d905e249f4db", size = 364417, upload-time = "2025-10-11T19:36:37.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/52/4112537334f1b21ead968a663f0aeb8a5774f42f9c92ded69bad21db1c5e/jellyfish-1.2.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:32a85b752cb51463face13e2b1797cfa617cd7fb7073f15feaa4020a86a346ce", size = 323225, upload-time = "2025-10-11T19:35:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/c54aa2476e5e63673910720b75f3b15e2484687fff9a457a84861f3fa898/jellyfish-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:675ab43840488944899ca87f02d4813c1e32107e56afaba7489705a70214e8aa", size = 317839, upload-time = "2025-10-11T19:35:19.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/3f/a81347d705150a69e446cabcbe8f223ad990164dffd3e6f8178ed44cf198/jellyfish-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c888f624d03e55e501bc438906505c79fb307d8da37a6dda18dd1ac2e6d5ea9c", size = 353337, upload-time = "2025-10-11T19:35:20.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3a/b655e72b852f6c304a2bc12091485f71e58e8c6374a15c8f21a1f0e1b9cd/jellyfish-1.2.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2b56a1fd2c5126c4a3362ec4470291cdd3c7daa22f583da67e75e30dc425ce6", size = 362632, upload-time = "2025-10-11T19:35:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/4e/be/f9f9a0b7ba48c994e0573d718e39bde713572cfb11f967d97328420a7aef/jellyfish-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a3ccff843822e7f3ad6f91662488a3630724c8587976bce114f3c7238e8ffa1", size = 360514, upload-time = "2025-10-11T19:35:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/960e556e155f65438c1b70d50f745ceb2989de8255a769ccaad26bf94a3f/jellyfish-1.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10da696747e2de0336180fd5ba77ef769a7c80f9743123545f7fc0251efbbcec", size = 533973, upload-time = "2025-10-11T19:35:24.077Z" }, + { url = "https://files.pythonhosted.org/packages/24/63/f5b5fb00c0df70387f699535c38190a97f30b79c2e7d4afb97794f838875/jellyfish-1.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c3c18f13175a9c90f3abd8805720b0eb3e10eca1d5d4e0cf57722b2a62d62016", size = 553863, upload-time = "2025-10-11T19:35:25.64Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/4de6626b6045884ed27995e170bacd09239b19549e25d95492cde10ea052/jellyfish-1.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0368596e176bf548b3be2979ff33e274fb6d5e13b2cebe85137b8b698b002a85", size = 523629, upload-time = "2025-10-11T19:35:26.732Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/8593e08568438b207f91b2fba2f6c879abc85dc450c0ad599a4e81dd9f07/jellyfish-1.2.1-cp312-cp312-win32.whl", hash = "sha256:451ddf4094e108e33d3b86d7817a7e20a2c5e6812d08c34ee22f6a595f38dcca", size = 209179, upload-time = "2025-10-11T19:35:27.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ff/ae991a96e8a370f41bbd91dbabdc94b404a164b0ab268388f43c2ab10d45/jellyfish-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:15318c13070fe6d9caeb7e10f9cdf89ff47c9d20f05a9a2c0d3b5cb8062a7033", size = 213630, upload-time = "2025-10-11T19:35:28.978Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-cache" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "importlib-metadata" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/f7/3627358075f183956e8c4974603232b03afd4ddc7baf72c2bc9fff522291/jupyter_cache-1.0.1.tar.gz", hash = "sha256:16e808eb19e3fb67a223db906e131ea6e01f03aa27f49a7214ce6a5fec186fb9", size = 32048, upload-time = "2024-11-15T16:03:55.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/6b/67b87da9d36bff9df7d0efbd1a325fa372a43be7158effaf43ed7b22341d/jupyter_cache-1.0.1-py3-none-any.whl", hash = "sha256:9c3cafd825ba7da8b5830485343091143dff903e4d8c69db9349b728b140abf6", size = 33907, upload-time = "2024-11-15T16:03:54.021Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "lap" +version = "0.5.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/ae/5cc637c2e5158b7dcf1a9744d33b11dfc21d9309931169402f573e4d1ee3/lap-0.5.13.tar.gz", hash = "sha256:9eff7169e3ca452995af0493cc20d35452c4bfd06122c36c06457119ffbd411b", size = 1537351, upload-time = "2026-02-23T12:37:24.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/95/96bd702a260ddcdeef35a1d99a510b1f0cd51eab40f749daa728a2f66728/lap-0.5.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77bbb235de0a416c77aae07aa2bebed4846ed741002da7721059279bd130ed4d", size = 1480314, upload-time = "2026-02-23T12:36:20.979Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c8/c16081ffcc8bf9f123940af8b74bfc8a1fac4f36b3cd7e9b440fdecd9fbc/lap-0.5.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8a793935e238f5430f764c38a1757331e86487738e5c7e8b82c374860e5a1074", size = 1478096, upload-time = "2026-02-23T12:36:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/92/0a/8d8395c8ea22a665ab4150fb2bcb97cc1f987843a1d316aaabc2d71044dd/lap-0.5.13-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:226c24acbc1acd22c76bac54525174577571d7e71e70845d0c43dd664332e867", size = 1732084, upload-time = "2026-02-23T12:36:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/8e/82/63fd09e866677f4263372785b23908efcce8da39bcc72fcced51e606bbe2/lap-0.5.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:355600a369281c830f900a9a215f8a8729c89ce3f2bf75e1943386fe3d8d1c88", size = 1725964, upload-time = "2026-02-23T12:36:25.339Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d3/82678703ab1b5a8773905e982244624b14ad004d8d3068d466c56bde0a31/lap-0.5.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0a099030000709e5acfc85b1f3a464a2b7a61abc50e51ab0235f3058d9f26abb", size = 1724642, upload-time = "2026-02-23T12:36:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/89/f9/e1b61bd002ed6d37e71c355e102ca626f5c50218e769d3105215733b6c0d/lap-0.5.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8687037b179a4a5014f69d26ab917fd2129bbe5894b0768e0a18a60e242794da", size = 1735247, upload-time = "2026-02-23T12:36:28.16Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/cfd1b2274c00aba8513c0fa385c7e71790a9f44d7d23f5cdbcd94a895c06/lap-0.5.13-cp312-cp312-win_amd64.whl", hash = "sha256:eb9fc5d7977cb73cc6e69ee704b5329d18d0b1e1da27f4a6c848259b8148f39a", size = 1476908, upload-time = "2026-02-23T12:36:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/9101b3837c3aad5b0ca84f7fcdb8a75ecc666d8f060e7592a97e55f6da57/lap-0.5.13-cp312-cp312-win_arm64.whl", hash = "sha256:0f96f70d093896f0c61c48ad0b31b88225d310e7f6ab50401ca8fe9f5d5268d4", size = 1465883, upload-time = "2026-02-23T12:36:30.832Z" }, +] + +[[package]] +name = "librt" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, + { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, + { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, + { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, + { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, +] + +[[package]] +name = "libtmux" +version = "0.58.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/4e/daccd4fd72ad3f17b8fb97f69403774d6c510b5d513521b454fdaedb0561/libtmux-0.58.0.tar.gz", hash = "sha256:abbe330bec2c45687a4bf417ee436373b37046afe123ba547495ee0448e1145a", size = 522080, upload-time = "2026-05-23T16:03:38.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d5/1cee7c13865d0d55ddb54709aaf85e0dc645e2998ce85ffce2c36d3bf08d/libtmux-0.58.0-py3-none-any.whl", hash = "sha256:1aec9875983a8eb121a8de7be7dffa6b97d9754c013ce960944d058764e47ec3", size = 113680, upload-time = "2026-05-23T16:03:37.049Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mediafile" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filetype" }, + { name = "mutagen" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/460b31c20833036d8f171b991ff2f46c7f1dc85c6219e8bf7efca4a9aa5a/mediafile-0.17.0.tar.gz", hash = "sha256:80c9003fd25d7096a7237e3b58e6ff018ef67f9c39900feafacabac1742c7d3a", size = 24612, upload-time = "2026-04-24T19:06:31.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/58/c4389f744b64fa81c5380cf2b0bed7e67b5789443860e5d51d31206b8e9d/mediafile-0.17.0-py3-none-any.whl", hash = "sha256:1bba387527d474f4d93a1c4033ecaff4700e9e311cfac334b9232e409a222a59", size = 29889, upload-time = "2026-04-24T19:06:29.74Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mutagen" +version = "1.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e6/64bc71b74eef4b68e61eb921dcf72dabd9e4ec4af1e11891bbd312ccbb77/mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99", size = 1274186, upload-time = "2023-09-03T16:33:33.411Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "myst-nb" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jupyter-cache" }, + { name = "myst-parser" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "pyyaml" }, + { name = "sphinx" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/b4/ff1abeea67e8cfe0a8c033389f6d1d8b0bfecfd611befb5cbdeab884fce6/myst_nb-1.4.0.tar.gz", hash = "sha256:c145598de62446a6fd009773dd071a40d3b76106ace780de1abdfc6961f614c2", size = 82285, upload-time = "2026-03-02T21:14:56.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/93/0a378b48488879a1d925b42a804edfc6e0cd0ef854220f2dce738a46e7e9/myst_nb-1.4.0-py3-none-any.whl", hash = "sha256:0e2c86e7d3b82c3aa51383f82d6268f7714f3b772c23a796ab09538a8e68b4e4", size = 82555, upload-time = "2026-03-02T21:14:55.652Z" }, +] + +[[package]] +name = "myst-parser" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/dc/603751677fff302f34396e206b610f556a59d7fe58b9a2145f54e96b48e8/myst_parser-5.1.0.tar.gz", hash = "sha256:ab69322dc6719dcc7f296479dbb70181b66df6ed315064f92dbc85c0e1bf2f02", size = 101182, upload-time = "2026-05-13T09:38:19.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/dc/f3dfb7488b770f3f67e6545085bf2abea5172e88f57b8ad25ef860ca704c/myst_parser-5.1.0-py3-none-any.whl", hash = "sha256:9c91c52b3cdb4d94a6506e4fab4e2f296c7623a0da0dcbe6de1565c3dad67a8a", size = 85817, upload-time = "2026-05-13T09:38:17.904Z" }, +] + +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, +] + +[[package]] +name = "outcome" +version = "1.3.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/df/77698abfac98571e65ffeb0c1fba8ffd692ab8458d617a0eed7d9a8d38f2/outcome-1.3.0.post0.tar.gz", hash = "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", size = 21060, upload-time = "2023-10-26T04:26:04.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/8b/5ab7257531a5d830fc8000c476e63c935488d74609b50f9384a643ec0a62/outcome-1.3.0.post0-py2.py3-none-any.whl", hash = "sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b", size = 10692, upload-time = "2023-10-26T04:26:02.532Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas-stubs" +version = "3.0.0.260204" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383, upload-time = "2026-02-04T15:17:17.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" }, +] + +[[package]] +name = "paracelsus" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pydot" }, + { name = "sqlalchemy" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/cc/d545a19967c3bdeba92ca1d8a736576b96b4610154f3bd6dbf01a198e2c3/paracelsus-0.15.0.tar.gz", hash = "sha256:b850b56417eef7b5e301b09ba7d44655f3c76de8681699b93ef6ae410afeb278", size = 92053, upload-time = "2026-01-04T21:38:25.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/70/3fa8dad530ae181b0a30f9874bababaa3d3781f9ef6c87aeaeed79b3c954/paracelsus-0.15.0-py3-none-any.whl", hash = "sha256:0ed0f97fb5ec09e379e45c1a95e280b1c40ee42af3c77f59f03998477a73fde2", size = 19606, upload-time = "2026-01-04T21:38:24.284Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "polars" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "polars-runtime-32" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.41.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pydub" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylast" +version = "7.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/56/1729aa02df4bf959f31f6bd024775f8be0aaa08fd18a1d2bbdaab3c38e9e/pylast-7.0.2.tar.gz", hash = "sha256:825e2b5d9144c5491d9c353511169a1595813e6a1ad203faf7525cd2d1d1828e", size = 435704, upload-time = "2026-01-19T12:40:01.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/94/677dade2b8ed48631de3fd34b320ebfd59b7a66f831640831112d7a7190b/pylast-7.0.2-py3-none-any.whl", hash = "sha256:c995e078670b3a8e3116a31b17d1f0d89c4d020407f6967ee9ffab2aeecd9de7", size = 26773, upload-time = "2026-01-19T12:40:00.48Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyrate-limiter" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/0c/6e78218e6ef726be35a4c0a5e2e281e36ddd940566800219e96d13de99ad/pyrate_limiter-4.1.0.tar.gz", hash = "sha256:be1ac413a263aa410b98757d1b01a880650948a1fc3a959512f15865eb58dbf3", size = 306136, upload-time = "2026-03-22T14:43:03.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/fd/57181fafae08385d00ea2702be246ab8035352a0a8e1f63391c2bcad74d4/pyrate_limiter-4.1.0-py3-none-any.whl", hash = "sha256:2696b4e4a6cffb3d40fc76662baccb766697893f0979e12bebbfc7d3b6b19603", size = 38197, upload-time = "2026-03-22T14:43:01.975Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-engineio" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6d/4384c2723adad93a3d6de4297e6d9c8b93be7f778a407f34f6ee0b2bea3e/python_engineio-4.13.2.tar.gz", hash = "sha256:a7732e99cfb7db6ed1aee31f18d7f73bbae086a92f31dee019bc646155d9684e", size = 79639, upload-time = "2026-05-21T21:45:07.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/28/180bfc5c95e83d40cb2abce512684ccad44e4819ec899fc36cb404a19061/python_engineio-4.13.2-py3-none-any.whl", hash = "sha256:8c101cd170e400dc4e970cd523325cde22df8fc25140953f379327055d701a6b", size = 59993, upload-time = "2026-05-21T21:45:06.162Z" }, +] + +[[package]] +name = "python-socketio" +version = "5.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/dd/6fd4112b941f7d39b8171b6ba17902609bd8fa2059c3812a3c29dade13e7/python_socketio-5.16.2.tar.gz", hash = "sha256:ad88c228d921646efa436c0a0df217e364ef30ec072df4041484e54d49c15989", size = 128011, upload-time = "2026-05-21T22:03:44.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/dc/0decaf5da92a7a969374474025787102d811d42aed1d32191fa338620e15/python_socketio-5.16.2-py3-none-any.whl", hash = "sha256:bef2da3374fd533aed4297f57b4f6512b52aa51604cb0da2165f401291c5ca20", size = 82137, upload-time = "2026-05-21T22:03:42.616Z" }, +] + +[[package]] +name = "python2ts" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/56/44919c9a70a6e94c37491fdf31dbd45971c3b633add199c84fe7060b4fe5/python2ts-0.6.1.tar.gz", hash = "sha256:c6a2deb39b4bd50aa1723afa93d3a45a1921eb9ba506ea20433fa1b5ac8efe4e", size = 37227, upload-time = "2025-09-09T12:01:01.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/e3/ba0e1e01d3b414917c9be17fca939acd971ef7db17561756a248ba98cca0/python2ts-0.6.1-py3-none-any.whl", hash = "sha256:cd3cdc22e512b0b5fb8c0345a57e3befdfdd82e5d1889eb73c9546fe8018f5c3", size = 24202, upload-time = "2025-09-09T12:00:59.726Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, +] + +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, +] + +[[package]] +name = "redis" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-ratelimiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyrate-limiter" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/71/aecc6307695ddad2d11f474cd79d79b111ee90dd123d697b76eaa1cd73a1/requests_ratelimiter-0.10.0.tar.gz", hash = "sha256:9c1a78d7646caa5ccf211a6c341abd16d329be2c8c35044a418aa9da7c0e7a33", size = 17190, upload-time = "2026-04-22T18:11:15.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/87/c94855590fde39c87cf671d242e555984d93e76d2029a6a3ad3a67a072ad/requests_ratelimiter-0.10.0-py3-none-any.whl", hash = "sha256:79a3e44c13de8d72705512696b44b94265bd96d997580c73e480373814af228e", size = 11408, upload-time = "2026-04-22T18:11:14.398Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, +] + +[[package]] +name = "rq" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "croniter" }, + { name = "redis" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/5e/43a7a61f3ebfa79789c72bf442a47fd80bb1a743caeea47b2b833001f388/rq-2.9.0.tar.gz", hash = "sha256:db5dfc1e1fe80ef977fd557d4305107dcf99a80b33d381c9a06e8a2bb11730e5", size = 744959, upload-time = "2026-05-19T15:00:29.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/a9/aa38ae2505e5dcb1e898f83a263e703b05829580f75ee86c5e480760ae1a/rq-2.9.0-py3-none-any.whl", hash = "sha256:665b9ad34e36ea15913e60d2a32e1775fef1e9aff907bcae297d6f70dccffed2", size = 120113, upload-time = "2026-05-19T15:00:27.511Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, +] + +[[package]] +name = "selenium" +version = "4.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "trio" }, + { name = "trio-websocket" }, + { name = "typing-extensions" }, + { name = "urllib3", extra = ["socks"] }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/4a/6d0a4f4a07e2a91511a51398203ee82bf6ce644a448aaa35c59b44aa9531/selenium-4.44.0.tar.gz", hash = "sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e", size = 993133, upload-time = "2026-05-12T22:48:19.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/bc/885047e975e996cb317db31c4551caa915aafc6befea990f082c7233adc2/selenium-4.44.0-py3-none-any.whl", hash = "sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24", size = 9654291, upload-time = "2026-05-12T22:48:16.836Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/ee/67eef9600338e245ad7838230969a34c823ddbdbccc5e1fc43cd75b55bc9/snowballstemmer-3.1.0.tar.gz", hash = "sha256:fd9e34526b23340cd23ffea6c9f9760974ecc2c2ac9e1d81401443ccdb2a801f", size = 122523, upload-time = "2026-05-24T19:04:19.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/83/ddbf4533c62dd32667ef1238952abef155f3d3391f5be69a352ad1638a42/snowballstemmer-3.1.0-py3-none-any.whl", hash = "sha256:17e6d1da216aa07db6dad37139ea70cf13c4b2e9a096f6e64a9648fc657d3154", size = 104550, upload-time = "2026-05-24T19:04:18.026Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-inline-tabs" +version = "2025.12.21.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/6a/f39bde46a79b80a9983233d99b773bd24b468bdd9c1e87acb46ff69af441/sphinx_inline_tabs-2025.12.21.14.tar.gz", hash = "sha256:c71a75800326e613fb4e410eed92a0934214741326aca9897c18018b9f968cb6", size = 45572, upload-time = "2025-12-21T13:30:51.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/2b/e64e7de34663cff1df029ba4f05a86124315bd9eba3d3b78e64904bea7e0/sphinx_inline_tabs-2025.12.21.14-py3-none-any.whl", hash = "sha256:e685c782b58d4e01490bcc4e2367cf7135ec28e7283a05e89095394e4ca6e81a", size = 7082, upload-time = "2025-12-21T13:30:50.142Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-mermaid" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/75/3a1cc926da8c563c58ddc124a7b3fe5ccadcae96c96e3a6f8ac3653a210a/sphinxcontrib_mermaid-2.0.2.tar.gz", hash = "sha256:f09576c78ca93fa0e3034fd9c45aaffa7c44ab449de9c43b8b8d262afe52bc66", size = 19265, upload-time = "2026-05-05T13:59:02.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8d/93be7e0f7fa915a576859b3bfac7a7baa3303181c44d7db7eefbd3e8a69f/sphinxcontrib_mermaid-2.0.2-py3-none-any.whl", hash = "sha256:d862e514991279fb4816302c5cfe167d2557bf3ce7125ae0cb47dac80a0f46ce", size = 14094, upload-time = "2026-05-05T13:59:01.585Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sphinxcontrib-typer" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/17/7e07799e1df369c170e747f8866b80563ff88d385f14304f8891a9f0fe4e/sphinxcontrib_typer-0.8.1.tar.gz", hash = "sha256:223016757b3ab1995971fce048ed5e939b4fd45ca0c255e320e0f87d04dbf9ef", size = 228669, upload-time = "2026-03-06T00:43:01.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/30/715e975d6e0b47979d37dab9fca05d8a178e45384d6f44fbf7ec76368220/sphinxcontrib_typer-0.8.1-py3-none-any.whl", hash = "sha256:869a69caf367af94e4acf2cfd62ce8b87faa02831c3d68c5fb1c96cc6b5dac2a", size = 14711, upload-time = "2026-03-06T00:42:59.781Z" }, +] + +[package.optional-dependencies] +html = [ + { name = "selenium" }, + { name = "webdriver-manager" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "tabulate" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, +] + +[[package]] +name = "tinytag" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214, upload-time = "2026-03-15T18:48:01.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927, upload-time = "2026-03-15T18:47:59.613Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, + { url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, + { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, +] + +[[package]] +name = "trio" +version = "0.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, + { name = "idna" }, + { name = "outcome" }, + { name = "sniffio" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/b6/c744031c6f89b18b3f5f4f7338603ab381d740a7f45938c4607b2302481f/trio-0.33.0.tar.gz", hash = "sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970", size = 605109, upload-time = "2026-02-14T18:40:55.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/93/dab25dc87ac48da0fe0f6419e07d0bfd98799bed4e05e7b9e0f85a1a4b4b/trio-0.33.0-py3-none-any.whl", hash = "sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b", size = 510294, upload-time = "2026-02-14T18:40:53.313Z" }, +] + +[[package]] +name = "trio-websocket" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "trio" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/3c/8b4358e81f2f2cfe71b66a267f023a91db20a817b9425dd964873796980a/trio_websocket-0.12.2.tar.gz", hash = "sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae", size = 33549, upload-time = "2025-02-25T05:16:58.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/19/eb640a397bba49ba49ef9dbe2e7e5c04202ba045b6ce2ec36e9cadc51e04/trio_websocket-0.12.2-py3-none-any.whl", hash = "sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6", size = 21221, upload-time = "2025-02-25T05:16:57.545Z" }, +] + +[[package]] +name = "typer" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/15/f5fc7be23b7196bc065b282d9589a372392fb10860c80f9c1dd7eb008662/typer-0.26.3.tar.gz", hash = "sha256:3e2b9352f535e5303ef27806dadc2c8647687bdca5c902f03fec3fb88f46a46a", size = 198326, upload-time = "2026-05-28T20:30:50.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/cc/c6c5dea061e2740355bfeef22ac6a41751bd2f3903e83921295569bdcec4/typer-0.26.3-py3-none-any.whl", hash = "sha256:e70549ec5a403ca8a0bf0802ddd9f3c6ff7a14ccbb859b01b697baa943636f33", size = 122338, upload-time = "2026-05-28T20:30:49.816Z" }, +] + +[[package]] +name = "types-aiofiles" +version = "25.1.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/42/f5b9b90162d2196f016b87228d6bf43f2c2c0c6501bfd5415001b3eb68bb/types_aiofiles-25.1.0.20260518.tar.gz", hash = "sha256:c0c95eb78755d4fa7b397d4f0332c632714dd7cd0d17f49b96e31d4d7a8d8c76", size = 14891, upload-time = "2026-05-18T06:05:27.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/3d/7a9ed9faafeae3aa3b5bc22fa5b979ff9cf3c83ecbe919b58eae07795b8c/types_aiofiles-25.1.0.20260518-py3-none-any.whl", hash = "sha256:f776bdfb4bec17f743d9ef042e61edf03bdcc7821fc08556fba9b63d873fdea9", size = 14377, upload-time = "2026-05-18T06:05:26.871Z" }, +] + +[[package]] +name = "types-cachetools" +version = "7.0.0.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/14/1e4fca2b250dbc75be9f0beab083acb3cd1151711e1031eb4a854dfd71be/types_cachetools-7.0.0.20260518.tar.gz", hash = "sha256:7730014e4fef0c6f01e2cd0f980f8ce6d1b1d2472c8459c1f382348ec1a6b435", size = 10072, upload-time = "2026-05-18T06:02:20.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/e0/767be6b60859fd2edc4512fabdedbce703fc8d4ec5007b31abaf37a51c6c/types_cachetools-7.0.0.20260518-py3-none-any.whl", hash = "sha256:997b356870915f8bbc9b2cdb4e7271c01d487996fdac2a9c8e91cc5b1261b3d1", size = 9500, upload-time = "2026-05-18T06:02:19.042Z" }, +] + +[[package]] +name = "types-deprecated" +version = "1.3.1.20260520" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/86/8cc66b0a4d01826e64f86e0001b8755105d1b6052c654e2448a0e72750ee/types_deprecated-1.3.1.20260520.tar.gz", hash = "sha256:4d0d9e5521432d9ce88169fb8b793b45d70d8e8cc1a7ecd5a4465abbf83c9ab4", size = 8649, upload-time = "2026-05-20T05:55:57.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/27/e8fd41f0d41b964870d370e7fc78311193910fe9a15c7399ade4a4aeea58/types_deprecated-1.3.1.20260520-py3-none-any.whl", hash = "sha256:8af853b7ccd3b7685159f3ab2e3b6f7999b6cd7e425cda02749e32a42a96c7d6", size = 9105, upload-time = "2026-05-20T05:55:56.259Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260518" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "uvicorn" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/f0/b47ecf438211a25a97f8f0e4b23c22bc2496ebfea18dd6ec16210f09cc36/virtualenv-21.4.1.tar.gz", hash = "sha256:2ca543c713b72840ceffd94e9bdedfbd09a661defa1f7f69e5429ad4059442e2", size = 7613344, upload-time = "2026-05-28T04:12:49.905Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/dc/ac4f3a987a87e1a18556896f257c4e15c95ed157b7975347ec6b313b75ce/virtualenv-21.4.1-py3-none-any.whl", hash = "sha256:caf4ff72d1b4039057f41d8e8466e859513d67c0400d9c6b62c02c9d1ebc3e12", size = 7594078, upload-time = "2026-05-28T04:12:47.686Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "webdriver-manager" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "python-dotenv" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/1a/12fc5f9e820f09c5c142e6a9275a4efbf6323a14acdead8dbedff98c880e/webdriver_manager-4.1.1.tar.gz", hash = "sha256:3832f4ab2186c86496b5c0a6d362357a294ca8c2be9972b49d089b809e262687", size = 38170, upload-time = "2026-05-18T21:35:52.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/04/526b7942f948a08b6d0f6c4d4ad11df16d184460a0d643e196407fecdcb1/webdriver_manager-4.1.1-py3-none-any.whl", hash = "sha256:4cedd217862e3fe324fadf7423fc9757c40af5979dda36493712c6c751224df1", size = 32266, upload-time = "2026-05-18T21:35:51.628Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +] diff --git a/docker/Dockerfile b/docker/Dockerfile index d6ac7a0a..3ec4c019 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,165 +1,203 @@ -FROM python:3.11-alpine3.20 AS base +# ------------------------------ Builder ffmpeg ------------------------------ # -FROM base AS deps +# FFMPEG comes with a ton of dependencies (e.g. llvm) +# the full install is over 400mb... +# we use a static version which is only 50mb +FROM python:3.12-slim-trixie AS builder_ffmpeg +# Install required tools +RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \ + apt-get update && apt-get install -y --no-install-recommends \ + xz-utils \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* -RUN addgroup -g 1000 beetle && \ - adduser -D -u 1000 -G beetle beetle +WORKDIR /tmp +RUN mkdir -p /tmp/ffmpeg +ARG TARGETARCH +RUN curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${TARGETARCH}-static.tar.xz \ + | tar -xJ -C /tmp/ffmpeg --strip-components=1 -ENV HOSTNAME="beets-container" -ENV EDITOR="vi" -# need to set some cli editor so `beet edit` works, vi comes with alpine - -# map beets directory and our configs to /config -RUN mkdir -p /config/beets -RUN mkdir -p /config/beets-flask -RUN mkdir -p /logs -RUN chown -R beetle:beetle /config -RUN chown -R beetle:beetle /logs -ENV BEETSDIR="/config/beets" -ENV BEETSFLASKDIR="/config/beets-flask" -ENV BEETSFLASKLOG="/logs/beets-flask.log" - -# our default folders they should not be used in production -RUN mkdir -p /music/inbox -RUN mkdir -p /music/imported -RUN chown -R beetle:beetle /music - -# dependencies -# RUN --mount=type=cache,target=/var/cache/apk \ -RUN apk update -RUN --mount=type=cache,target=/var/cache/apk \ - apk add \ - imagemagick \ - redis \ - bash \ - tmux \ - shadow \ - git \ - ffmpeg +# ------------------------------ Builder python ------------------------------ # -# Install backend dependencies - -# Prevent __pycache__ directories -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 -# avoid creating a venv with uv, use system python -ENV UV_PROJECT_ENVIRONMENT="/usr/local/" - -RUN --mount=type=cache,target=/root/.cache/pip \ - pip install --no-cache-dir uv - +FROM python:3.12-slim-trixie AS builder_py +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /repo/backend -COPY ./backend/pyproject.toml /repo/backend/ -COPY ./README.md /repo/ +ENV PYTHONUNBUFFERED=1 +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_NO_DEV=1 +ENV UV_NO_EDITABLE=1 +ENV UV_PYTHON_DOWNLOADS=0 +ENV UV_PROJECT_ENVIRONMENT=/venv -RUN uv sync --no-install-project +# Install backend dependencies +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=backend/uv.lock,target=uv.lock \ + --mount=type=bind,source=backend/pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project -# Install our package (backend) -COPY ./backend/beets_flask/ /repo/backend/beets_flask/ -COPY ./backend/generate_types.py /repo/backend/ -COPY ./backend/launch_redis_workers.py /repo/backend/ -COPY ./backend/launch_watchdog_worker.py /repo/backend/ -COPY ./backend/launch_db_init.py /repo/backend/ +# Install backend package +COPY ./backend/ /repo/backend/ -RUN uv sync +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked # Extract version from pyproject.toml RUN mkdir -p /version RUN python -c "import tomllib; print(tomllib.load(open('/repo/backend/pyproject.toml', 'rb'))['project']['version'])" > /version/backend.txt -# ------------------------------------------------------------------------------------ # -# Development # -# ------------------------------------------------------------------------------------ # - -FROM deps AS dev +# ------------------------------- Builder node ------------------------------- # -RUN --mount=type=cache,target=/var/cache/apk \ - apk add \ - npm +# Note: there is no tag for node that fixes to debian trixi. +# we might get inconsistencies after a new debian release +FROM node:22-slim AS builder_node -RUN npm install -g pnpm -RUN pnpm config set store-dir /repo/frontend/.pnpm-store +# Install pnpm +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable -# Copy the lock files and install dependencies -WORKDIR /repo -COPY ./frontend/package.json /repo/frontend/ -COPY ./frontend/pnpm-lock.yaml /repo/frontend/ +COPY ./frontend /repo/frontend/ WORKDIR /repo/frontend -# RUN pnpm i +RUN --mount=type=cache,sharing=locked,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile # Extract version from package.json RUN mkdir -p /version -RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \ - > /version/frontend.txt +RUN node -p "require('/repo/frontend/package.json').version" > /version/frontend.txt -ENV IB_SERVER_CONFIG="dev_docker" +RUN pnpm run build -# relies on mounting this volume -WORKDIR /repo -USER root -ENTRYPOINT [ \ - "/bin/sh", "-c", \ - "./docker/entrypoints/entrypoint_fix_permissions.sh && \ - su beetle -c ./docker/entrypoints/entrypoint_dev.sh" \ - ] +# ---------------------------------------------------------------------------- # +# Base # +# ---------------------------------------------------------------------------- # -# ------------------------------------------------------------------------------------ # -# Build # -# ------------------------------------------------------------------------------------ # +FROM python:3.12-slim-trixie AS base +COPY --from=builder_py /bin/uv/ /bin/uvx/ /bin/ -FROM deps AS build -# Build frontend files +ENV HOSTNAME="beets-container" +ENV EDITOR="vi" +# need to set some cli editor so `beet edit` works, vi comes with slim +ENV BEETSDIR="/config/beets" +ENV BEETSFLASKDIR="/config/beets-flask" +ENV BEETSFLASKLOG="/logs/beets-flask.log" +ENV UV_PROJECT_ENVIRONMENT=/venv -RUN --mount=type=cache,target=/var/cache/apk \ - apk add \ - npm +# Create user and group +RUN groupadd -g 1000 beetle && \ + useradd -m -u 1000 -g beetle beetle && \ + usermod -s /bin/bash beetle -RUN npm install -g pnpm -RUN pnpm config set store-dir /repo/frontend/.pnpm-store +ENV VIRTUAL_ENV_DISABLE_PROMPT=1 +RUN echo 'if [ -f /repo/backend/.venv/bin/activate ]; then source /repo/backend/.venv/bin/activate; fi' >> /home/beetle/.bashrc +RUN echo 'alias pip="echo '\''Beets-Flask relies on uv. Use: uv pip install'\''"' >> /home/beetle/.bashrc -WORKDIR /repo -COPY ./frontend ./frontend/ -RUN chown -R beetle:beetle /repo +# map beets directory and our configs to /config +RUN mkdir -p /config/beets /config/beets-flask /logs && \ + chown -R beetle:beetle /config /logs -# Extract version from package.json -RUN mkdir -p /version -RUN python -c "import json; print(json.load(open('/repo/frontend/package.json'))['version'])" \ - > /version/frontend.txt +# our default folders they should not be used in production +RUN mkdir -p \ + /music/beets_flask_config_example/imported \ + /music/beets_flask_config_example/inbox_off \ + /music/beets_flask_config_example/inbox_auto \ + /music/beets_flask_config_example/inbox_preview +RUN chown -R beetle:beetle /music -USER beetle -WORKDIR /repo/frontend -# RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ -RUN pnpm install -RUN pnpm run build +# Install dependencies: +RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + redis \ + tmux \ + imagemagick && \ + rm -rf /var/lib/apt/lists/* + +# Copy only the binaries from builder +COPY --from=builder_ffmpeg /tmp/ffmpeg/ffmpeg /usr/local/bin/ffmpeg +COPY --from=builder_ffmpeg /tmp/ffmpeg/ffprobe /usr/local/bin/ffprobe +RUN ffmpeg -version +# Copy bf dependencies +COPY --from=builder_py --chown=beetle:beetle /venv /venv # ------------------------------------------------------------------------------------ # # Production # # ------------------------------------------------------------------------------------ # -FROM deps AS prod +FROM base AS prod ENV IB_SERVER_CONFIG="prod" WORKDIR /repo -COPY --from=build /repo/frontend/dist /repo/frontend/dist -COPY --from=build /version /version -COPY docker/entrypoints . -RUN chown -R beetle:beetle /repo +COPY --from=builder_node --chown=beetle:beetle /repo/frontend/dist /repo/frontend/dist +COPY --from=builder_node --chown=beetle:beetle /version/frontend.txt /version/frontend.txt +COPY --from=builder_py --chown=beetle:beetle /repo/backend/ /repo/backend/ +COPY --from=builder_py --chown=beetle:beetle /version/backend.txt /version/backend.txt -USER root +COPY --chown=beetle:beetle ./docker/entrypoints/*.sh /repo/ +RUN chmod +x /repo/*.sh ENTRYPOINT [ \ - "/bin/sh", "-c", \ - "/repo/entrypoint_fix_permissions.sh && \ + "/bin/bash", "-c", \ + "source /venv/bin/activate && \ + /repo/entrypoint_fix_permissions.sh && \ /repo/entrypoint_user_scripts.sh && \ su beetle -c /repo/entrypoint.sh" \ ] +# ------------------------------------------------------------------------------------ # +# Development # +# ------------------------------------------------------------------------------------ # + +FROM base AS dev + + +ENV IB_SERVER_CONFIG="dev_docker" + +RUN --mount=type=cache,sharing=locked,target=/var/cache/apt \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + build-essential && \ + rm -rf /var/lib/apt/lists/* + + +# Install nodejs +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +RUN apt-get install -y nodejs + +# Install pnpm +RUN npm install --global corepack@latest +RUN corepack enable pnpm +RUN corepack use pnpm@latest-10 + +# Copy the lock files and install dependencies +WORKDIR /repo +COPY ./frontend/package.json ./frontend/pnpm-lock.yaml /repo/frontend/ +COPY ./backend/pyproject.toml ./backend/uv.lock /repo/backend/ + +# Extract version from package.json +WORKDIR /repo/frontend +RUN mkdir -p /version +RUN node -p "require('/repo/frontend/package.json').version" > /version/frontend.txt +RUN uv run python -c "import tomllib; print(tomllib.load(open('/repo/backend/pyproject.toml', 'rb'))['project']['version'])" > /version/backend.txt + +# relies on mounting this volume +WORKDIR /repo +ENTRYPOINT [ \ + "/bin/bash", "-c", \ + "source /venv/bin/activate && \ + ./docker/entrypoints/entrypoint_fix_permissions.sh && \ + ./docker/entrypoints/entrypoint_user_scripts.sh && \ + su beetle -c ./docker/entrypoints/entrypoint_dev.sh" \ + ] diff --git a/docker/docker-compose.tests.yaml b/docker/docker-compose.tests.yaml deleted file mode 100644 index 765ede09..00000000 --- a/docker/docker-compose.tests.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - beets-flask-tests: - container_name: beets-flask-tests - hostname: beets-container - build: - context: . - dockerfile: Dockerfile - target: test - image: beets-flask-tests - ports: - - "5001:5001" - - "5173:5173" - environment: - # 502 is default on macos, 1000 on linux - USER_ID: 502 - GROUP_ID: 502 - volumes: - - ./:/repo/ diff --git a/docker/entrypoints/common.sh b/docker/entrypoints/common.sh index cc32ffa8..e0bd8474 100644 --- a/docker/entrypoints/common.sh +++ b/docker/entrypoints/common.sh @@ -1,15 +1,15 @@ +#!/bin/bash # A number of common functions used by entrypoints - log() { - echo -e "[Entrypoint] $1" + printf "[Entrypoint] $1\n" } log_error() { - echo -e "\033[0;31m[Entrypoint] $1\033[0m" + printf "\033[0;31m[Entrypoint] $1\033[0m\n" >&2 } log_warning() { - echo -e "\033[0;33m[Entrypoint] $1\033[0m" + printf "\033[0;33m[Entrypoint] $1\033[0m\n" } log_current_user() { diff --git a/docker/entrypoints/entrypoint.sh b/docker/entrypoints/entrypoint.sh index 0cd0b92e..6e01a66b 100755 --- a/docker/entrypoints/entrypoint.sh +++ b/docker/entrypoints/entrypoint.sh @@ -1,13 +1,13 @@ -#!/bin/sh -source ./common.sh +#!/bin/bash +. ./common.sh log_current_user log_version_info cd /repo -mkdir -p /repo/log +mkdir -p /logs mkdir -p /config/beets mkdir -p /config/beets-flask @@ -18,26 +18,26 @@ mkdir -p /config/beets-flask # Ignore warnings for production builds export PYTHONWARNINGS="ignore" + # running the server from inside the backend dir makes imports and redis easier cd /repo/backend -redis-server --daemonize yes >/dev/null 2>&1 +# Databse creation & migrations (beets-flask) +python -c "from beets_flask.database.migration import run_migrations; run_migrations()" +# Database creation & migration (beets) +python -c "from beets.ui import _open_library; from beets_flask.config.beets_config import get_config; _open_library(get_config().beets_config)" -# blocking -python ./launch_db_init.py -python ./launch_redis_workers.py > /logs/redis_workers.log 2>&1 +# Redis server (if not set outside container) +if [ -z "$REDIS_URL" ]; then + redis-server --daemonize yes >/dev/null 2>&1 +fi -# keeps running in the background +# FIXME: Logging is a bit strange for the workers atm a bit of unification could help +python ./launch_redis_workers.py > /logs/redis_workers.log 2>&1 python ./launch_watchdog_worker.py & - redis-cli FLUSHALL >/dev/null 2>&1 -# we need to run with one worker for socketio to work -uvicorn beets_flask.server.app:create_app --port 5001 \ - --host 0.0.0.0 \ - --factory \ - --workers 4 \ - --use-colors \ - --log-level info \ - --no-access-log +# Launch server +sleep 0.5 +python ./launch_server.py diff --git a/docker/entrypoints/entrypoint_add_groups.sh b/docker/entrypoints/entrypoint_add_groups.sh index a9fc45ad..25f38182 100644 --- a/docker/entrypoints/entrypoint_add_groups.sh +++ b/docker/entrypoints/entrypoint_add_groups.sh @@ -1,12 +1,6 @@ - -# this script runs both, in dev and in prod, so we have to check where we -# can source common.sh from. -if [ -f ./common.sh ]; then - source ./common.sh -elif [ -f ./docker/entrypoints/common.sh ]; then - source ./docker/entrypoints/common.sh -fi - +#!/bin/bash +SCRIPT_DIR=$(dirname "$0") +. "$SCRIPT_DIR/common.sh" # ---------------------------------- Helper ---------------------------------- # diff --git a/docker/entrypoints/entrypoint_dev.sh b/docker/entrypoints/entrypoint_dev.sh index 9fcb5ef8..e721de1b 100755 --- a/docker/entrypoints/entrypoint_dev.sh +++ b/docker/entrypoints/entrypoint_dev.sh @@ -1,10 +1,15 @@ -#!/bin/sh -source ./docker/entrypoints/common.sh +#!/bin/bash +. ./docker/entrypoints/common.sh log_current_user log_version_info log "Starting development environment..." +# We rely on live mounting the /repo folder +# and expect the developer to have the dependencies installed +# locally + + cd /repo/frontend # pnpm run build:dev & # use this for debugging with ios, port 5001 (no cors allowed) @@ -12,7 +17,7 @@ pnpm run dev & # normal dev, port 5173 vite_pid=$! sleep 3 # Give Vite a moment to start if ! kill -0 $vite_pid 2>/dev/null; then - echo "starting vite failed, will try to fix this by installing dependencies ..." + log_warning "starting vite failed, will try to fix this by installing dependencies ..." pnpm install pnpm run dev & fi @@ -37,11 +42,18 @@ export FLASK_DEBUG=1 # running the server from inside the backend dir makes imports and redis easier cd /repo/backend -redis-server --daemonize yes +uv sync --locked --active + +# Databse creation & migrations (beets-flask) +python -c "from beets_flask.database.migration import run_migrations; run_migrations()" +# Database creation & migration (beets) +python -c "from beets.ui import _open_library; from beets_flask.config.beets_config import get_config; _open_library(get_config().beets_config)" + + +redis-server --daemonize yes # blocking -python ./launch_db_init.py python ./launch_redis_workers.py # keeps running in the background @@ -54,7 +66,7 @@ redis-cli FLUSHALL python ./generate_types.py # we need to run with one worker for socketio to work (but need at least threads for SSEs) -# sufficient timout for the interactive import sessions, which may take a couple of minutes +# sufficient timeout for the interactive import sessions, which may take a couple of minutes # gunicorn --worker-class eventlet -w 1 --threads 32 --timeout 300 --bind 0.0.0.0:5001 --reload 'main:create_app()' @@ -69,5 +81,5 @@ uvicorn beets_flask.server.app:create_app --port 5001 \ -# if we need to debug the continaer without running the webserver: +# if we need to debug the container without running the webserver: # tail -f /dev/null diff --git a/docker/entrypoints/entrypoint_fix_permissions.sh b/docker/entrypoints/entrypoint_fix_permissions.sh index c4e9115d..4f96da5e 100755 --- a/docker/entrypoints/entrypoint_fix_permissions.sh +++ b/docker/entrypoints/entrypoint_fix_permissions.sh @@ -1,11 +1,13 @@ #!/bin/bash +SCRIPT_DIR=$(dirname "$0") +. "$SCRIPT_DIR/common.sh" -# this script runs both, in dev and in prod, so we have to check where we -# can source common.sh from. -if [ -f ./common.sh ]; then - source ./common.sh -elif [ -f ./docker/entrypoints/common.sh ]; then - source ./docker/entrypoints/common.sh +# We want to allow both, USER_ID and PUID (the linuxserver.io convention) +if [ -n "$PUID" ]; then + USER_ID=$PUID +fi +if [ -n "$PGID" ]; then + GROUP_ID=$PGID fi if [ ! -z "$USER_ID" ] && [ ! -z "$GROUP_ID" ]; then @@ -14,17 +16,11 @@ if [ ! -z "$USER_ID" ] && [ ! -z "$GROUP_ID" ]; then groupmod -g $GROUP_ID beetle usermod -u $USER_ID -g $GROUP_ID beetle > /dev/null 2>&1 log "User beetle now has $(id beetle)" - find /home/beetle ! -user beetle -exec chown beetle:beetle {} + - find /logs ! -user beetle -exec chown beetle:beetle {} + - find /repo ! -user beetle -exec chown beetle:beetle {} + + find /home/beetle /logs /repo ! -user beetle -exec chown beetle:beetle {} + log "Done fixing permissions" else log "No USER_ID and GROUP_ID set, skipping permission updates" fi # add groups -if [ -f ./entrypoint_add_groups.sh ]; then - source ./entrypoint_add_groups.sh -elif [ -f ./docker/entrypoints/entrypoint_add_groups.sh ]; then - source ./docker/entrypoints/entrypoint_add_groups.sh -fi +. "$SCRIPT_DIR/entrypoint_add_groups.sh" diff --git a/docker/entrypoints/entrypoint_user_scripts.sh b/docker/entrypoints/entrypoint_user_scripts.sh index cf8db0f3..06883472 100755 --- a/docker/entrypoints/entrypoint_user_scripts.sh +++ b/docker/entrypoints/entrypoint_user_scripts.sh @@ -1,22 +1,24 @@ -#!/bin/sh +#!/bin/bash +SCRIPT_DIR=$(dirname "$0") +. "$SCRIPT_DIR/common.sh" # check for user startup scripts if [ -f /config/startup.sh ]; then - echo "Running user startup script from /config/startup.sh" + log "Running user startup script from /config/startup.sh" /config/startup.sh fi if [ -f /config/beets-flask/startup.sh ]; then - echo "Running user startup script from /config/beets-flask/startup.sh" + log "Running user startup script from /config/beets-flask/startup.sh" /config/beets-flask/startup.sh fi # check for requirements.txt if [ -f /config/requirements.txt ]; then - echo "Installing pip requirements from /config/requirements.txt" - pip install -r /config/requirements.txt + log "Installing pip requirements from /config/requirements.txt" + uv pip install -r /config/requirements.txt fi if [ -f /config/beets-flask/requirements.txt ]; then - echo "Installing pip requirements from /config/beets-flask/requirements.txt" - pip install -r /config/beets-flask/requirements.txt + log "Installing pip requirements from /config/beets-flask/requirements.txt" + uv pip install -r /config/beets-flask/requirements.txt fi diff --git a/docs/.readthedocs.yaml b/docs/.readthedocs.yaml index 155de2dc..061cae6b 100644 --- a/docs/.readthedocs.yaml +++ b/docs/.readthedocs.yaml @@ -7,28 +7,19 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 - tools: - python: "3.11" - # You can also specify other tool versions: - # nodejs: "19" - # rust: "1.64" - # golang: "1.19" + os: ubuntu-24.04 + tools: + python: "3.12" + jobs: + create_environment: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - cd backend + - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs + install: + - "true" # Build documentation in the "docs/" directory with Sphinx sphinx: - configuration: ./docs/conf.py -# Optionally build your docs in additional formats such as PDF and ePub -# formats: -# - pdf -# - epub - -# Optional but recommended, declare the Python requirements required -# to build your documentation -# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html -python: - install: - - method: pip - path: ./backend - extra_requirements: - - docs + configuration: docs/conf.py diff --git a/docs/Makefile b/docs/Makefile index b432ee1a..595f71c0 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -8,6 +8,13 @@ SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = build +DIAGRAMCMD = paracelsus graph beets_flask.database.models.base:Base \ + --config ../backend/pyproject.toml \ + --column-sort preserve-order + +# the grep breaks the charts, causing a slow down, but they still render and are cleaner +DIGRAMGREP = | grep -vE "created_at|updated_at" + # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) @@ -16,10 +23,44 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). + %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) +.PHONY: diagrams + +# to do exact filtering we need to use e.g. --include-tables "^track_info$$" +diagrams: + @$(DIAGRAMCMD) \ + > ./diagrams/erd_all.mmd + + @$(DIAGRAMCMD) \ + --include-tables "task" \ + --include-tables "folder" \ + --include-tables "session" \ + --include-tables "candidate" \ + --include-tables "^matches$$" \ + --include-tables "^items$$" \ + $(DIGRAMGREP) \ + > ./diagrams/erd_high_level.mmd + + @$(DIAGRAMCMD) \ + --include-tables "candidate" \ + --include-tables "matches" \ + $(DIGRAMGREP) \ + > ./diagrams/erd_matches_overview.mmd + + @$(DIAGRAMCMD) \ + --include-tables "matches_album" \ + --include-tables "matches_track" \ + --include-tables "distance" \ + --include-tables "penalties" \ + --include-tables "album" \ + --include-tables "track" \ + --include-tables "^items$$" \ + $(DIGRAMGREP) \ + > ./diagrams/erd_matches_types.mmd clean: rm -rf $(BUILDDIR)/* - rm -rf $(SOURCEDIR)/_autosummary/* \ No newline at end of file + rm -rf $(SOURCEDIR)/_autosummary/* diff --git a/docs/_static/config_error.webp b/docs/_static/config_error.webp new file mode 100644 index 00000000..39959188 Binary files /dev/null and b/docs/_static/config_error.webp differ diff --git a/docs/conf.py b/docs/conf.py index 1d9fd499..9332d19f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ "sphinx_inline_tabs", "sphinxcontrib.typer", "sphinx.ext.napoleon", + "sphinxcontrib.mermaid", # "myst_parser", "myst_nb", ] diff --git a/docs/configuration.md b/docs/configuration.md index 399855ba..f74a103c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,6 @@ We extend the [default beets configuration](https://beets.readthedocs.io/en/stab You may use the following example configuration as a starting point. It contains all options that can be set in the `/config/beets-flask/config.yaml`. - ```{literalinclude} ../backend/beets_flask/config/config_bf_example.yaml :language: yaml ``` @@ -30,15 +29,14 @@ You may add multiple inboxes, each may have a different purpose. The `gui.inbox.folders` section allows you to define multiple inboxes, each with a name, path, and an `autotag` setting. The `autotag` setting determines how the files in the inbox are processed by beets-flask. -- `"no"` you have to do everything manually. -- `"preview"` fetch meta data from online sources, but don't import yet. -- `"auto"` fetch meta data, and import if the match is good enough (based on threshold). -- `"bootleg"` you are sure the meta data is fine, or it does not exist online. +- `"off"` you have to do everything manually. +- `"preview"` fetch meta data from online sources, but don't import yet. +- `"auto"` fetch meta data, and import if the match is good enough (based on threshold). +- `"bootleg"` you are sure the meta data is fine, or it does not exist online. Drop files to import as-is in here, _but still create one subfolder for each import session you want to create_. (Beets acts on _folders_. Files directly inside the inbox wont trigger an imported) - Note that the top label (i.e. Inbox1, Inbox2...) does not matter. ```yaml @@ -48,7 +46,7 @@ gui: Inbox1: name: "Dummy inbox" path: "/music/dummy" - autotag: no + autotag: "off" # do not automatically trigger tagging and do not automatically import Inbox2: @@ -80,6 +78,7 @@ gui: --- ### `gui.inbox.debounce_before_autotag` + Specify the number of seconds to wait after the last filesystem event before starting the autotagging process. Applies to _all_ inboxes. For example, when adding files one by one, the timer gets reset after each file. @@ -90,6 +89,7 @@ The default value is `30` seconds. --- ### `gui.inbox.ignore` + Specifies a list of file patterns to ignore when scanning the inbox folders. This is useful to exclude temporary files or other unwanted files from being shown in the inbox. @@ -100,25 +100,24 @@ To show all files in the inbox (independent of which files beets will copy) set ```yaml # beets-flask/config.yaml gui: - inbox: - ignore: - # Usually you want to keep these in place. - # When customizing, we _do not_ copy beets defaults over. - - ".*" - - "*~" - - "System Volume Information" - - "lost+found" - # also exclude some common resource files on Synology NAS - - "@eaDir" - - "@SynoEAStream" + inbox: + ignore: + # Usually you want to keep these in place. + # When customizing, we _do not_ copy beets defaults over. + - ".*" + - "*~" + - "System Volume Information" + - "lost+found" + # also exclude some common resource files on Synology NAS + - "@eaDir" + - "@SynoEAStream" # default in beets config ignore: - - ".*" - - "*~" - - "System Volume Information" - - "lost+found" - + - ".*" + - "*~" + - "System Volume Information" + - "lost+found" ``` ## Library @@ -135,16 +134,22 @@ The default is `[";", ",", "&"]`. ## Terminal +### `gui.terminal.enabled` + +A boolean to enable or disable the terminal in the web interface. +By default, the terminal is enabled. + ### `gui.terminal.start_path` + Specifies the path that is used when starting the terminal in the web interface. This is useful if you want to start the terminal in a specific directory, such as your music library. The default value is `/music/inbox`. You should change this if you have a different inbox path! - ## Other options ### `gui.num_preview_workers` + Specifies the number of worker threads that are used to generate previews for the inboxes. This is useful to speed up the preview generation process, especially when you have a large number of items in your inboxes. The default value is `4`. @@ -155,6 +160,11 @@ However, the import itself is always done sequentially. This is to ensure that the import process is not interrupted by other operations. ``` +### `gui.inbox.temp_dir` +Specifies the temporary directory that is used to store files during the upload process. +This is useful to ensure that files are uploaded to a filesystem that is fast and has enough +space. The default value is `/tmp/beets-flask/upload`. + ## Docker Environment Variables These environment variables are set in the `docker-compose.yaml` file and control the container's behavior. @@ -163,12 +173,16 @@ These environment variables are set in the `docker-compose.yaml` file and contro The `USER_ID` and `GROUP_ID` environment variables are used to set the UID and GID of the `beetle` user inside the container. This is useful to match the user and group IDs of the host system. The default value is `1000` for both. +Need to be set together! + ```yaml environment: USER_ID: 1000 GROUP_ID: 1000 ``` +For convenience and your yaml anchors, you can also use `PUID` and `PGID` as in [linuxserver.io images](https://docs.linuxserver.io/general/understanding-puid-and-pgid/). + ### `EXTRA_GROUPS` The `EXTRA_GROUPS` environment variable allows you to add additional groups to the `beetle` user. This is useful when you need the container to have access to files owned by different groups on the host system. @@ -181,10 +195,11 @@ environment: ``` This is particularly useful in scenarios where: -- Files in the inbox are created by external services running as different users/groups -- You're using ACL-based permissions with specific group access -- You're running in environments like LXC/Proxmox with mapped group IDs -- You need the container to manage files from network shares with specific group ownership + +- Files in the inbox are created by external services running as different users/groups +- You're using ACL-based permissions with specific group access +- You're running in environments like LXC/Proxmox with mapped group IDs +- You need the container to manage files from network shares with specific group ownership Example: If your download client (e.g., slskd, transmission) creates files with group ownership `nas_shares` (gid 1001), you can add that group to the beetle user: @@ -194,3 +209,64 @@ environment: ``` This will allow the beets-flask container to delete and manage those files via the web UI. + +### `BEETSFLASKLOG` + +Location and Filename for our log file + +Default: `/logs/beets-flask.log` + +### `LOG_LEVEL_BEETSFLASK` + +Log level for our own logs. Uses Python log level notation (DEBUG, INFO, WARNING etc) + +Default: `INFO` + +### `LOG_LEVEL_OTHERS` + +Log level for our all other modules. Uses Python log level notation (DEBUG, INFO, WARNING etc) + +Default: `WARNING` + +### `BEETSDIR` + +Default: `/config/beets` + +Should not be changed. + +### `BEETSFLASKDIR` + +Default: `/config/beets-flask` + +Should not be changed. + +### `REDIS_URL` + +Usually, a daemonised Redis server is started within the container. This can cause issues due to the expectation that only one process runs per container, or if you wish to run the beets-flask container with a read-only root filesystem, or want greater control over the persistence of the Redis database. The `REDIS_URL` environment variable makes beets-flask connect to an external Redis instead, ideally one running in another container. + +Example: Add a Redis container to your `docker-compose.yaml`, and make beets-flask connect to it: + +```yaml +services: + redis: + image: docker.io/library/redis:alpine + volumes: + - redis:/data + beets-flask: + ... + environment: + ... + REDIS_URL: "redis://redis:6379/" +volumes: + redis: +``` + +## Validation + +Beets Flask validates it's own configuration[^1] and a few select fields of the native beets configs. The frontend and docker log messages will inform you of any typos or incompatible values. + +[^1]: Powered by [eyconf](https://github.com/semohr/eyconf) + +```{figure} _static/config_error.webp +Note however, for these changes to take full effect in beets-flask's background workers, you still need to manually restart the container (despite that cheeky _retry_ button)! +``` diff --git a/docs/develop/contribution.md b/docs/develop/contribution.md index d69b7e12..6dc5e624 100644 --- a/docs/develop/contribution.md +++ b/docs/develop/contribution.md @@ -23,10 +23,13 @@ cd beets-flask ``` 2.1 **Install the dependencies (backend):** -We recommend using a virtual environment to manage the dependencies. +We recommend using uv to manage the python dependencies but you can also use pip and a virtual environment of your choice. ```bash cd backend +uv sync --all-extras --dev +source .venv/bin/activate +# or pip install -e .[dev] ``` @@ -64,8 +67,11 @@ Run [Ruff](https://docs.astral.sh/ruff/) manually or use the pre-commit hooks to ```bash cd backend -# Run Ruff manually -ruff check +# Code formatting and linting +ruff check . --fix +ruff format . +# Typing checks +mypy . # Run the tests pytest ``` diff --git a/docs/develop/resources/backend.md b/docs/develop/resources/backend.md index 9403e3f1..b58e730e 100644 --- a/docs/develop/resources/backend.md +++ b/docs/develop/resources/backend.md @@ -5,6 +5,8 @@ Beets-Flask provides a quart application with REST API for the beets music libra ```{toctree} :hidden: +./classes +./database ./state_serialize ``` @@ -23,5 +25,3 @@ BEETSDIR="/config/beets" BEETSFLASKDIR="/config/beets-flask" BEETSFLASKLOG="/logs/beets-flask.log" ``` - - diff --git a/docs/develop/resources/classes.md b/docs/develop/resources/classes.md new file mode 100644 index 00000000..435143b0 --- /dev/null +++ b/docs/develop/resources/classes.md @@ -0,0 +1,62 @@ +# Class Overview + +To keep an overview which types come from beets native, we prefix them to `BeetsSomeType` (see `beets_flask/importer/types.py`) + +## Notation + +### Items + +In Beets, an `Item` is a single track. The `Item` can be stored +in the beets database. It represents a tracks metadata on disk. + +### Candidates (TrackInfo & AlbumInfo) + +Retrieved from external sources (e.g. spotify, tidal...). In particular `TrackInfo` is a single tracks metadata from an external source while `AlbumInfo` is information shared but also additional. `AlbumInfo` may contain a list of `TrackInfo`s. + +### Matches (TrackMatch & AlbumMatch) + +Matches are the association between `candidates` and `items`. Historically in beets this was just a list of indice mappings but changed to direct references to objects. + +For the tracks of a candidate we may find the following relationships after trying +to assign items and tracks. + +``` +items ∩ tracks = pairs +items' ∩ tracks = extra_items +items ∩ tracks' = extra_tracks +``` + +Matches are ranked through predefined penalties and using linear assignment problem. This yields a percentage score. + +### Task(s) + +A `Task` is a specific import operation. Tasks need to be started on a folder (which contains the music files – `items`) and looks up `candidates` online. The goal of task is to assign `items` to `candidates` by finding `matches`. A user can than pick a match. + +```{eval-rst} +.. mermaid:: ../../diagrams/tasks.mmd +``` + +## Sessions and Queues + +In Beets and BeetsFlask, imports are abstracted into sessions. Most of the time, we have a clean hierarchy of one folder, one session, one task. +In BeetsFlask, each `Session` gets placed in a redis `Queue`, depending on its type: +Previews can take place in parallel, while imports take place one at a time, since this requires file movements on disk and writes into the beets database. + +```{eval-rst} +.. mermaid:: ../../diagrams/sessions.mmd +``` + + +## States + +We keep states of various objects in our own database, mostly to be able to resume imports after generating the initial preview. +This requires us to wrap a lot of the beets objects, to make them persistable. + +The state objects have a hierachy close to the beets internal logic: +- SessionState: Reflects the state of the import session. +- TaskState: Reflects an import task, but they dont have such a precise real-life meaning. +- CandidateState: Reflects a beets match (i.e. a candidate the user might choose) + +```{eval-rst} +.. mermaid:: ../../diagrams/objects_state_relation.mmd +``` diff --git a/docs/develop/resources/database.md b/docs/develop/resources/database.md new file mode 100644 index 00000000..d43b399b --- /dev/null +++ b/docs/develop/resources/database.md @@ -0,0 +1,79 @@ +# Database + +## Migrations Guide + +We use [Alembic](https://alembic.sqlalchemy.org/) for database migrations. + +### Overview + +- Migrations are stored in `backend/alembic/versions/` +- The database tracks its current version in the `alembic_version` table +- Migration files define `upgrade()` and `downgrade()` functions + +### Quick Reference + +| Task | Command | +|------|---------| +| Create migration | `alembic revision --autogenerate -m "description"` | +| Apply all pending | `alembic upgrade head` | +| Roll back one | `alembic downgrade -1` | +| Check version | `alembic current` | +| See history | `alembic history` | +| Validate | `alembic check` | + +### Workflow: Creating a migration + +We use a local database (`./beets-flask-sqlite.db`) to avoid breaking the docker setup. + +1. Ensure you have a local database: + ```bash + cd backend + alembic upgrade head + ``` + +2. Edit the model (e.g., add a column to `states.py`) + +3. Generate the migration: + ```bash + alembic revision --autogenerate -m "add_column_name" + ``` + +4. Review the generated migration file in `alembic/versions/` + +5. Apply the migration: + ```bash + alembic upgrade head + ``` + +6. Validate with + ```bash + alembic current + ``` + + +## Schema + +Autogenerated database schemas. + +### Overview + +```{eval-rst} +.. mermaid:: ../../diagrams/erd_high_level.mmd +``` +### Matches + +```{eval-rst} +.. mermaid:: ../../diagrams/erd_matches_overview.mmd +``` + +### Matches Types + +```{eval-rst} +.. mermaid:: ../../diagrams/erd_matches_types.mmd +``` + +### Complete + +```{eval-rst} +.. mermaid:: ../../diagrams/erd_all.mmd +``` diff --git a/docs/develop/resources/documentation.md b/docs/develop/resources/documentation.md index 23012e06..67973eb2 100644 --- a/docs/develop/resources/documentation.md +++ b/docs/develop/resources/documentation.md @@ -8,6 +8,10 @@ You may build the documentation locally with. # Install the requirements cd backend pip install -e .[docs] + +# Optionally, create ER-Diargrams +make diagrams + # Build the documentation cd ../docs make html diff --git a/docs/diagrams/erd_all.mmd b/docs/diagrams/erd_all.mmd new file mode 100644 index 00000000..c1cf2467 --- /dev/null +++ b/docs/diagrams/erd_all.mmd @@ -0,0 +1,140 @@ +erDiagram + album_info { + JSON data + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + album_match_track_mappings { + VARCHAR album_match_id FK + VARCHAR track_info_id FK "nullable" + VARCHAR item_id FK "nullable" + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + candidate { + VARCHAR task_id FK + VARCHAR match_id FK + VARCHAR duplicate_ids + TEXT mapping + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + distances { + VARCHAR track_info_id FK "nullable" + VARCHAR parent_distance_id FK "nullable" + FLOAT raw_distance + FLOAT max_distance + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + folder { + VARCHAR full_path PK "indexed" + BOOLEAN is_album "nullable" + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + items { + JSON fixed_values + JSON flex_values + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + matches { + VARCHAR id PK + VARCHAR type + VARCHAR distance_id FK + DATETIME created_at "indexed" + DATETIME updated_at + } + + matches_album { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + matches_track { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + penalties { + VARCHAR key "indexed" + BLOB value + VARCHAR distance_id FK + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + session { + VARCHAR folder_hash FK + INTEGER folder_revision + ENUM progress + BLOB exc "nullable" + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + task { + VARCHAR session_id FK + VARCHAR chosen_candidate_id FK "nullable" + BLOB toppath "nullable" + BLOB paths + BLOB old_paths "nullable" + ENUM choice_flag "nullable" + VARCHAR cur_artist "nullable" + VARCHAR cur_album "nullable" + ENUM progress + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + tasks_items { + VARCHAR task_id FK + VARCHAR item_id FK + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + track_info { + VARCHAR album_id FK "nullable" + JSON data + VARCHAR id PK + DATETIME created_at "indexed" + DATETIME updated_at + } + + matches_album ||--o{ album_match_track_mappings : album_match_id + track_info ||--o{ album_match_track_mappings : track_info_id + items ||--o{ album_match_track_mappings : item_id + task ||--o{ candidate : task_id + matches ||--o{ candidate : match_id + track_info ||--o{ distances : track_info_id + distances ||--o{ distances : parent_distance_id + distances ||--o{ matches : distance_id + matches ||--o| matches_album : id + album_info ||--o{ matches_album : info_id + matches ||--o| matches_track : id + track_info ||--o{ matches_track : info_id + distances ||--o{ penalties : distance_id + folder ||--o{ session : folder_hash + session ||--o{ task : session_id + candidate ||--o{ task : chosen_candidate_id + task ||--o{ tasks_items : task_id + items ||--o{ tasks_items : item_id + album_info ||--o{ track_info : album_id diff --git a/docs/diagrams/erd_high_level.mmd b/docs/diagrams/erd_high_level.mmd new file mode 100644 index 00000000..3371d381 --- /dev/null +++ b/docs/diagrams/erd_high_level.mmd @@ -0,0 +1,61 @@ +erDiagram + candidate { + VARCHAR task_id FK + VARCHAR match_id FK + VARCHAR duplicate_ids + TEXT mapping + VARCHAR id PK + } + + folder { + VARCHAR full_path PK "indexed" + BOOLEAN is_album "nullable" + VARCHAR id PK + } + + items { + JSON fixed_values + JSON flex_values + VARCHAR id PK + } + + matches { + VARCHAR id PK + VARCHAR type + VARCHAR distance_id FK + } + + session { + VARCHAR folder_hash FK + INTEGER folder_revision + ENUM progress + BLOB exc "nullable" + VARCHAR id PK + } + + task { + VARCHAR session_id FK + VARCHAR chosen_candidate_id FK "nullable" + BLOB toppath "nullable" + BLOB paths + BLOB old_paths "nullable" + ENUM choice_flag "nullable" + VARCHAR cur_artist "nullable" + VARCHAR cur_album "nullable" + ENUM progress + VARCHAR id PK + } + + tasks_items { + VARCHAR task_id FK + VARCHAR item_id FK + VARCHAR id PK + } + + task ||--o{ candidate : task_id + matches ||--o{ candidate : match_id + folder ||--o{ session : folder_hash + session ||--o{ task : session_id + candidate ||--o{ task : chosen_candidate_id + task ||--o{ tasks_items : task_id + items ||--o{ tasks_items : item_id diff --git a/docs/diagrams/erd_matches_overview.mmd b/docs/diagrams/erd_matches_overview.mmd new file mode 100644 index 00000000..6748c23e --- /dev/null +++ b/docs/diagrams/erd_matches_overview.mmd @@ -0,0 +1,28 @@ +erDiagram + candidate { + VARCHAR task_id FK + VARCHAR match_id FK + VARCHAR duplicate_ids + TEXT mapping + VARCHAR id PK + } + + matches { + VARCHAR id PK + VARCHAR type + VARCHAR distance_id FK + } + + matches_album { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + matches_track { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + matches ||--o{ candidate : match_id + matches ||--o| matches_album : id + matches ||--o| matches_track : id diff --git a/docs/diagrams/erd_matches_types.mmd b/docs/diagrams/erd_matches_types.mmd new file mode 100644 index 00000000..71c17f4f --- /dev/null +++ b/docs/diagrams/erd_matches_types.mmd @@ -0,0 +1,59 @@ +erDiagram + album_info { + JSON data + VARCHAR id PK + } + + album_match_track_mappings { + VARCHAR album_match_id FK + VARCHAR track_info_id FK "nullable" + VARCHAR item_id FK "nullable" + VARCHAR id PK + } + + distances { + VARCHAR track_info_id FK "nullable" + VARCHAR parent_distance_id FK "nullable" + FLOAT raw_distance + FLOAT max_distance + VARCHAR id PK + } + + items { + JSON fixed_values + JSON flex_values + VARCHAR id PK + } + + matches_album { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + matches_track { + VARCHAR id PK,FK + VARCHAR info_id FK + } + + penalties { + VARCHAR key "indexed" + BLOB value + VARCHAR distance_id FK + VARCHAR id PK + } + + track_info { + VARCHAR album_id FK "nullable" + JSON data + VARCHAR id PK + } + + matches_album ||--o{ album_match_track_mappings : album_match_id + track_info ||--o{ album_match_track_mappings : track_info_id + items ||--o{ album_match_track_mappings : item_id + track_info ||--o{ distances : track_info_id + distances ||--o{ distances : parent_distance_id + album_info ||--o{ matches_album : info_id + track_info ||--o{ matches_track : info_id + distances ||--o{ penalties : distance_id + album_info ||--o{ track_info : album_id diff --git a/docs/diagrams/objects_state_relation.mmd b/docs/diagrams/objects_state_relation.mmd new file mode 100644 index 00000000..47bab45d --- /dev/null +++ b/docs/diagrams/objects_state_relation.mmd @@ -0,0 +1,29 @@ +flowchart LR + subgraph Beets + direction TB + LS[BeetsImportSession] + LT[BeetsImportTask] + LC["BeetsAlbumMatch | BeetsTrackMatch"] + end + + + subgraph BeetsFlask + direction TB + LF["Folder | Archive"] + SS[SessionState] + ST[TaskState] + SC[CandidateState] + end + + subgraph BeetsFlask Database + direction TB + DF[(FolderInDb)] + DS[(SessionStateInDb)] + DT[(TaskStateInDb)] + DC[(CandidateStateInDb)] + end + + LF --> DF + LS --> SS --> DS + LT --> ST --> DT + LC --> SC --> DC diff --git a/docs/diagrams/sessions.mmd b/docs/diagrams/sessions.mmd new file mode 100644 index 00000000..bd1585e8 --- /dev/null +++ b/docs/diagrams/sessions.mmd @@ -0,0 +1,42 @@ +flowchart LR + + BeetsImportSession + BaseSession + subgraph Preview Queue + PreviewSession["` + __PreviewSession__ + _enqueue_preview()_ + _enqueue_import_auto()_ + `"] + AddCandidatesSession["` + __AddCandidatesSession__ + _enqueue_preview_add_candidates()_ + `"] + end + + subgraph Import Queue + ImportSession["` + __ImportSession__ + _enqueue_import_candidate()_ + `"] + BootlegImportSession["` + __BootlegImportSession__ + _enqueue_import_bootleg()_ + `"] + AutoImportSession["` + __AutoImportSession__ + _enqueue_import_auto()_ + `"] + UndoSession["` + __UndoSession__ + _enqueue_import_undo()_ + `"] + end + + BeetsImportSession --> BaseSession + BaseSession --> PreviewSession + BaseSession --> ImportSession + BaseSession --> UndoSession + PreviewSession --> AddCandidatesSession + ImportSession --> BootlegImportSession + ImportSession --> AutoImportSession diff --git a/docs/diagrams/tasks.mmd b/docs/diagrams/tasks.mmd new file mode 100644 index 00000000..c818ba34 --- /dev/null +++ b/docs/diagrams/tasks.mmd @@ -0,0 +1,20 @@ +flowchart LR + + Folder --> Session --> Task + Folder --> Items + + subgraph Task + direction LR + Items@{ shape: processes, label: "$$N_i \\times $$Items"} + Items <--> AlbumMatch + + subgraph Candidates["$$N_c \\times $$ Candidates"] + direction LR + AlbumMatch <--> AlbumInfo + AlbumInfo <--> TrackInfo + + TrackInfo@{ shape: processes, label: "$$N_t \\times $$ TrackInfo"} + end + + + end diff --git a/docs/faq.md b/docs/faq.md index 1a582115..7d639f03 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -4,26 +4,19 @@ Beets by default does not support 7z and rar files. However, you can enable support for these formats by installing the `unrar` and/or `py7zr` packages in your container. (See also [beets documentation](https://beets.readthedocs.io/en/stable/reference/cli.html#import)). -As we are running an alpine image this is not as straightforward as it sounds. - ### `rar` support -To enable `rar` support, you can use the `unrar` binary from the [EDM115/unrar-alpine](https://github.com/EDM115/unrar-alpine) repository. This repository provides a precompiled `unrar` binary that is compatible with Alpine Linux. +To enable `rar` support, you can install the `unrar` package from the (non-free) Debian repositories. ```bash # /config/startup.sh -apk add --no-cache curl jq - -curl -LsSf https://api.github.com/repos/EDM115/unrar-alpine/releases/latest \ - | jq -r '.assets[] | select(.name == "unrar") | .id' \ - | xargs -I {} curl -LsSf https://api.github.com/repos/EDM115/unrar-alpine/releases/assets/{} \ - | jq -r '.browser_download_url' \ - | xargs -I {} curl -Lsf {} -o /tmp/unrar && \ - install -v -m755 /tmp/unrar /usr/local/bin +# add `contrib non-free non-free-firmware` to components +sed -i '/Components:/s/main\b[^c]*$/main contrib non-free non-free-firmware/' \ + /etc/apt/sources.list.d/debian.sources -# You MUST install required libraries or else you'll run into linked libraries loading issues -apk add --no-cache libstdc++ libgcc +apt-get update +apt-get install -y unrar ``` ```bash @@ -33,12 +26,9 @@ rarfile ### `7z` support -To enable `7z` support, you can use the `py7zr` package, which also needs some shenanigans to install on Alpine Linux. +To enable `7z` support, you can use the `py7zr` package. ```bash -# /config/startup.sh -apk add gcc musl-dev linux-headers - # /config/requirements.txt py7zr ``` diff --git a/docs/limitations.md b/docs/limitations.md index 96f1c372..08433f45 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -28,3 +28,10 @@ We are thinking about workarounds that add back the convenience of automatic del See also: - [#193](https://github.com/pSpitzner/beets-flask/issues/193) + + +## Upload via webfrontend only supports files + +This is a design choice, as folder uploads would require users to set up ssl. + +To upload whole albums, simply zip them on your host machine before uploading. diff --git a/docs/plugins.md b/docs/plugins.md index ece02e08..a6e1f317 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -8,31 +8,59 @@ Plugin support is experimental. Installing beets plugins varies depending on the particular plugin. [See the official docs](https://docs.beets.io/en/latest/plugins/index.html). -We might automate this in the future, but for now you can place a `requirements.txt` and/or `startup.sh` in either the `/config` folder or `/config/beets-flask` folder. The `requirements.txt` may include [python dependencies](https://pip.pypa.io/en/stable/reference/requirements-file-format/), and the `startup.sh` file may be an executable shell script that is compatible with the container's alpine linux base. +We might automate this in the future, but for now you can place a `requirements.txt` and/or `startup.sh` in either the `/config` folder or `/config/beets-flask` folder. The `requirements.txt` may include [python dependencies](https://pip.pypa.io/en/stable/reference/requirements-file-format/), and the `startup.sh` file may be an executable shell script that is compatible with the container's debian linux base. -On startup, the container will run the startup script if it exists, and afterwards install the requirements from the `requirements.txt` file using pip. +On startup, the container will run the startup script if it exists, and afterwards install the requirements from the `requirements.txt` file using [uv](https://docs.astral.sh/uv/pip/). + +```{note} +We use uv to manage python dependecies in a virtual environment at `/repo/backend/.venv`. +This should by default be activated already (`which python`), but note that, to install +more dependencies you need to use `uv pip install`. A normal `pip install` will not place +packages at the right location. +``` ## Example startup.sh: keyfinder -For example, we can install the [keyfinder plugin](https://docs.beets.io/en/latest/plugins/keyfinder.html) via `startup.sh`, as it requires quite a few build steps. +For example, we can install the [keyfinder plugin](https://docs.beets.io/en/latest/plugins/keyfinder.html) via `startup.sh`. +It requires quite a few build steps, and you have to manually compile from two repos. Place the following in a `startup.sh` file in either the `/config` folder or `/config/beets-flask` folder. ```sh #!/bin/sh -apk update -apk add \ - build-base \ - ffmpeg-dev \ - libkeyfinder-dev \ - +# get build dependencies +apt-get update +apt-get install -y \ + build-essential \ + ffmpeg \ + libavformat-dev \ + libavcodec-dev \ + libswresample-dev \ + libavutil-dev \ + git \ + cmake \ + libfftw3-dev \ + pkg-config \ + +# clone and build the library +git clone https://github.com/mixxxdj/libkeyfinder.git +cd libkeyfinder + +cmake -DCMAKE_INSTALL_PREFIX=/usr/local -S . -B build +cmake --build build --parallel "$(nproc)" +cmake --install build + +# clone and build the cli tool +cd .. git clone https://github.com/evanpurkhiser/keyfinder-cli.git cd keyfinder-cli/ -make -make install + +cmake -DCMAKE_INSTALL_PREFIX=/usr/local -S . -B build +cmake --build build --parallel "$(nproc)" +cmake --install build ``` -Note that the container is based on alpine, so you have to use apk. +Note that the container is based on debian. Make executable ```sh chmod +x ./startup.sh diff --git a/frontend/package.json b/frontend/package.json index ea815988..047d6c2a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.2.0", + "version": "2.0.0-rc4", "type": "module", "scripts": { "dev": "vite --host --cors", @@ -18,50 +18,50 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@lucide/lab": "^0.1.2", - "@mui/material": "^7.3.6", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-query-devtools": "^5.91.1", - "@tanstack/react-router": "^1.140.0", - "@tanstack/router-cli": "^1.140.0", + "@mui/material": "^7.3.9", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", + "@tanstack/react-router": "^1.167.4", + "@tanstack/router-cli": "^1.166.12", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", - "diff": "^8.0.2", + "diff": "^8.0.3", "lucide-react": "^0.546.0", - "react": "^19.2.1", - "react-dom": "^19.2.1", - "react-error-boundary": "^6.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-error-boundary": "^6.1.1", "react-virtualized-auto-sizer": "^1.0.26", - "react-window": "^2.2.3", - "sass": "^1.95.0", - "socket.io-client": "^4.8.1", - "wavesurfer.js": "^7.12.1" + "react-window": "^2.2.7", + "sass": "^1.98.0", + "socket.io-client": "^4.8.3", + "wavesurfer.js": "^7.12.4" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@tanstack/eslint-plugin-query": "^5.91.2", - "@tanstack/router-plugin": "^1.140.0", - "@types/node": "^24.10.2", + "@eslint/js": "^9.39.4", + "@tanstack/eslint-plugin-query": "^5.91.4", + "@tanstack/router-plugin": "^1.166.13", + "@types/node": "^24.12.0", "@types/react": "19.2.1", "@types/react-dom": "19.2.1", - "@typescript-eslint/eslint-plugin": "^8.49.0", - "@typescript-eslint/parser": "^8.49.0", - "@vitejs/plugin-react": "^5.1.2", - "@vitejs/plugin-react-swc": "^4.2.2", + "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/parser": "^8.57.1", + "@vitejs/plugin-react": "^5.2.0", + "@vitejs/plugin-react-swc": "^4.3.0", "babel-plugin-react-compiler": "1.0.0", - "eslint": "^9.39.1", + "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "7.0.0", - "eslint-plugin-react-refresh": "^0.4.24", + "eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^16.5.0", - "postcss": "^8.5.6", - "prettier": "^3.7.4", + "postcss": "^8.5.8", + "prettier": "^3.8.1", "typescript": "^5.9.3", - "typescript-eslint": "^8.49.0", - "vite": "^7.2.7", + "typescript-eslint": "^8.57.1", + "vite": "^7.3.1", "vite-plugin-svgr": "^4.5.0", "vite-tsconfig-paths": "^5.1.4" }, - "packageManager": "pnpm@9.12.0" + "packageManager": "pnpm@10.26.1" } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6952ab85..3df02e80 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,34 +10,34 @@ importers: dependencies: '@dnd-kit/core': specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@dnd-kit/sortable': specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1) + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@emotion/react': specifier: ^11.14.0 - version: 11.14.0(@types/react@19.2.1)(react@19.2.1) + version: 11.14.0(@types/react@19.2.1)(react@19.2.4) '@emotion/styled': specifier: ^11.14.1 - version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1) + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4) '@lucide/lab': specifier: ^0.1.2 version: 0.1.2 '@mui/material': - specifier: ^7.3.6 - version: 7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^7.3.9 + version: 7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': - specifier: ^5.90.12 - version: 5.90.12(react@19.2.1) + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) '@tanstack/react-query-devtools': - specifier: ^5.91.1 - version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + specifier: ^5.91.3 + version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) '@tanstack/react-router': - specifier: ^1.140.0 - version: 1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^1.167.4 + version: 1.167.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-cli': - specifier: ^1.140.0 - version: 1.140.0 + specifier: ^1.166.12 + version: 1.166.12 '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -45,48 +45,48 @@ importers: specifier: ^5.5.0 version: 5.5.0 diff: - specifier: ^8.0.2 - version: 8.0.2 + specifier: ^8.0.3 + version: 8.0.3 lucide-react: specifier: ^0.546.0 - version: 0.546.0(react@19.2.1) + version: 0.546.0(react@19.2.4) react: - specifier: ^19.2.1 - version: 19.2.1 + specifier: ^19.2.4 + version: 19.2.4 react-dom: - specifier: ^19.2.1 - version: 19.2.1(react@19.2.1) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) react-error-boundary: - specifier: ^6.0.0 - version: 6.0.0(react@19.2.1) + specifier: ^6.1.1 + version: 6.1.1(react@19.2.4) react-virtualized-auto-sizer: specifier: ^1.0.26 - version: 1.0.26(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.0.26(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-window: - specifier: ^2.2.3 - version: 2.2.3(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ^2.2.7 + version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) sass: - specifier: ^1.95.0 - version: 1.95.0 + specifier: ^1.98.0 + version: 1.98.0 socket.io-client: - specifier: ^4.8.1 - version: 4.8.1 + specifier: ^4.8.3 + version: 4.8.3 wavesurfer.js: - specifier: ^7.12.1 - version: 7.12.1 + specifier: ^7.12.4 + version: 7.12.4 devDependencies: '@eslint/js': - specifier: ^9.39.1 - version: 9.39.1 + specifier: ^9.39.4 + version: 9.39.4 '@tanstack/eslint-plugin-query': - specifier: ^5.91.2 - version: 5.91.2(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + specifier: ^5.91.4 + version: 5.91.4(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) '@tanstack/router-plugin': - specifier: ^1.140.0 - version: 1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)) + specifier: ^1.166.13 + version: 1.166.13(@tanstack/react-router@1.167.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)) '@types/node': - specifier: ^24.10.2 - version: 24.10.2 + specifier: ^24.12.0 + version: 24.12.0 '@types/react': specifier: 19.2.1 version: 19.2.1 @@ -94,62 +94,62 @@ importers: specifier: 19.2.1 version: 19.2.1(@types/react@19.2.1) '@typescript-eslint/eslint-plugin': - specifier: ^8.49.0 - version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.57.1 + version: 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^8.49.0 - version: 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.57.1 + version: 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) '@vitejs/plugin-react': - specifier: ^5.1.2 - version: 5.1.2(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)) + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)) '@vitejs/plugin-react-swc': - specifier: ^4.2.2 - version: 4.2.2(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)) + specifier: ^4.3.0 + version: 4.3.0(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 eslint: - specifier: ^9.39.1 - version: 9.39.1(jiti@1.21.7) + specifier: ^9.39.4 + version: 9.39.4(jiti@1.21.7) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.1(jiti@1.21.7)) + version: 10.1.8(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.1(jiti@1.21.7)) + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react-hooks: specifier: 7.0.0 - version: 7.0.0(eslint@9.39.1(jiti@1.21.7)) + version: 7.0.0(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react-refresh: - specifier: ^0.4.24 - version: 0.4.24(eslint@9.39.1(jiti@1.21.7)) + specifier: ^0.4.26 + version: 0.4.26(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-simple-import-sort: specifier: ^12.1.1 - version: 12.1.1(eslint@9.39.1(jiti@1.21.7)) + version: 12.1.1(eslint@9.39.4(jiti@1.21.7)) globals: specifier: ^16.5.0 version: 16.5.0 postcss: - specifier: ^8.5.6 - version: 8.5.6 + specifier: ^8.5.8 + version: 8.5.8 prettier: - specifier: ^3.7.4 - version: 3.7.4 + specifier: ^3.8.1 + version: 3.8.1 typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.49.0 - version: 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + specifier: ^8.57.1 + version: 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) vite: - specifier: ^7.2.7 - version: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + specifier: ^7.3.1 + version: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) vite-plugin-svgr: specifier: ^4.5.0 - version: 4.5.0(rollup@4.52.5)(typescript@5.9.3)(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)) + version: 4.5.0(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)) + version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)) packages: @@ -161,10 +161,18 @@ packages: resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.27.2': resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + '@babel/core@7.27.1': resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} engines: {node: '>=6.9.0'} @@ -177,6 +185,10 @@ packages: resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.27.1': resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} engines: {node: '>=6.9.0'} @@ -189,32 +201,30 @@ packages: resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.27.2': resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.28.5': - resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.27.1': resolution: {integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==} engines: {node: '>=6.9.0'} @@ -227,22 +237,14 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.27.1': - resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.27.1': - resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': @@ -269,6 +271,10 @@ packages: resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.27.2': resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==} engines: {node: '>=6.0.0'} @@ -289,6 +295,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.27.1': resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} engines: {node: '>=6.9.0'} @@ -301,12 +312,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-modules-commonjs@7.27.1': - resolution: {integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -319,30 +324,22 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-typescript@7.28.5': - resolution: {integrity: sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.27.1': resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.4': - resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.1': resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} engines: {node: '>=6.9.0'} @@ -359,6 +356,10 @@ packages: resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.1': resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} @@ -379,6 +380,10 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -455,314 +460,158 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/aix-ppc64@0.27.1': - resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.27.1': - resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.1': - resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.1': - resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.1': - resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.27.1': - resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.1': - resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.27.1': - resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.1': - resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.27.1': - resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.1': - resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.27.1': - resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.27.1': - resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.1': - resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.1': - resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.27.1': - resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.27.1': - resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.1': - resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.1': - resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.1': - resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.27.1': - resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.1': - resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.27.1': - resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.1': - resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.27.1': - resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.27.1': - resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -773,12 +622,22 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.1': resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -789,12 +648,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.1': - resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -861,16 +720,16 @@ packages: '@lucide/lab@0.1.2': resolution: {integrity: sha512-VprF2BJa7ZuTGOhUd5cf8tHJXyL63wdxcGieAiVVoR9hO0YmPsnZO0AGqDiX2/br+/MC6n8BoJcmPilltOXIJA==} - '@mui/core-downloads-tracker@7.3.6': - resolution: {integrity: sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==} + '@mui/core-downloads-tracker@7.3.9': + resolution: {integrity: sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==} - '@mui/material@7.3.6': - resolution: {integrity: sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==} + '@mui/material@7.3.9': + resolution: {integrity: sha512-I8yO3t4T0y7bvDiR1qhIN6iBWZOTBfVOnmLlM7K6h3dx5YX2a7rnkuXzc2UkZaqhxY9NgTnEbdPlokR1RxCNRQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^7.3.6 + '@mui/material-pigment-css': ^7.3.9 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -884,8 +743,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@7.3.6': - resolution: {integrity: sha512-Ws9wZpqM+FlnbZXaY/7yvyvWQo1+02Tbx50mVdNmzWEi51C51y56KAbaDCYyulOOBL6BJxuaqG8rNNuj7ivVyw==} + '@mui/private-theming@7.3.9': + resolution: {integrity: sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -894,8 +753,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@7.3.6': - resolution: {integrity: sha512-+wiYbtvj+zyUkmDB+ysH6zRjuQIJ+CM56w0fEXV+VDNdvOuSywG+/8kpjddvvlfMLsaWdQe5oTuYGBcodmqGzQ==} + '@mui/styled-engine@7.3.9': + resolution: {integrity: sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -907,8 +766,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@7.3.6': - resolution: {integrity: sha512-8fehAazkHNP1imMrdD2m2hbA9sl7Ur6jfuNweh5o4l9YPty4iaZzRXqYvBCWQNwFaSHmMEj2KPbyXGp7Bt73Rg==} + '@mui/system@7.3.9': + resolution: {integrity: sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -923,16 +782,16 @@ packages: '@types/react': optional: true - '@mui/types@7.4.9': - resolution: {integrity: sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==} + '@mui/types@7.4.12': + resolution: {integrity: sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@7.3.6': - resolution: {integrity: sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==} + '@mui/utils@7.3.9': + resolution: {integrity: sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -941,18 +800,6 @@ packages: '@types/react': optional: true - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -1038,11 +885,11 @@ packages: '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} - '@rolldown/pluginutils@1.0.0-beta.47': - resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rolldown/pluginutils@1.0.0-beta.53': - resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} '@rollup/pluginutils@5.3.0': resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} @@ -1234,68 +1081,68 @@ packages: peerDependencies: '@svgr/core': '*' - '@swc/core-darwin-arm64@1.13.5': - resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.5': - resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.5': - resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.5': - resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.5': - resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.5': - resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.5': - resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.5': - resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.5': - resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.5': - resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.5': - resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -1306,67 +1153,73 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/types@0.1.24': - resolution: {integrity: sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng==} + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tanstack/eslint-plugin-query@5.91.2': - resolution: {integrity: sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==} + '@tanstack/eslint-plugin-query@5.91.4': + resolution: {integrity: sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ==} peerDependencies: eslint: ^8.57.0 || ^9.0.0 + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true - '@tanstack/history@1.140.0': - resolution: {integrity: sha512-u+/dChlWlT3kYa/RmFP+E7xY5EnzvKEKcvKk+XrgWMpBWExQIh3RQX/eUqhqwCXJPNc4jfm1Coj8umnm/hDgyA==} - engines: {node: '>=12'} + '@tanstack/history@1.161.6': + resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} + engines: {node: '>=20.19'} - '@tanstack/query-core@5.90.12': - resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} - '@tanstack/query-devtools@5.91.1': - resolution: {integrity: sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==} + '@tanstack/query-devtools@5.93.0': + resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} - '@tanstack/react-query-devtools@5.91.1': - resolution: {integrity: sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==} + '@tanstack/react-query-devtools@5.91.3': + resolution: {integrity: sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==} peerDependencies: - '@tanstack/react-query': ^5.90.10 + '@tanstack/react-query': ^5.90.20 react: ^18 || ^19 - '@tanstack/react-query@5.90.12': - resolution: {integrity: sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==} + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} peerDependencies: react: ^18 || ^19 - '@tanstack/react-router@1.140.0': - resolution: {integrity: sha512-Xe4K1bEtU5h0cAhaKYXDQA2cuITgEs1x6tOognJbcxamlAdzDAkhYBhRg8dKSVAyfGejAUNlUi4utnN0s6R+Yw==} - engines: {node: '>=12'} + '@tanstack/react-router@1.167.4': + resolution: {integrity: sha512-VpbZh382zX3WF4+X2Z+EUyd8eJhJyjg9C6ByYwrVZiWbhgbMK4+zQQIG2+lCAlIlDi7SV8fDcGL09NA8Z2kpGQ==} + engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.8.0': - resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + '@tanstack/react-store@0.9.2': + resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-cli@1.140.0': - resolution: {integrity: sha512-W/bWmUFwR66UAg4MuWY9Bj/eRYQ7bms9BvxJmWzEWMBnnhz0mNKVP2p+q54FgcCCINipTnXOnjjxb/s5G0q8Kw==} - engines: {node: '>=12'} + '@tanstack/router-cli@1.166.12': + resolution: {integrity: sha512-kW65OG9Fkui6qBWcK8aiqbwqTP7BDVhTyFH/mWFm+7Gpch3VYBQp58pycPl3IBZKHuQ9RalVAEiKUOmUQYH/pw==} + engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-core@1.140.0': - resolution: {integrity: sha512-/Te/mlAzi5FEpZ9NF9RhVw/n+cWYLiCHpvevNKo7JPA8ZYWF58wkalPtNWSocftX4P+OIBNerFAW9UbLgSbvSw==} - engines: {node: '>=12'} + '@tanstack/router-core@1.167.4': + resolution: {integrity: sha512-Gk5V9Zr5JFJ4SbLyCheQLJ3MnXddccENPA+DJRz+9g3QxtN8DJB8w8KCUCgDeYlWp4LvmO4nX3fy3tupqVP2Pw==} + engines: {node: '>=20.19'} + hasBin: true - '@tanstack/router-generator@1.140.0': - resolution: {integrity: sha512-YYq/DSn7EkBboCySf87RDH3mNq3AfN18v4qHmre73KOdxUJchTZ4LC1+8vbO/1K/Uus2ZFXUDy7QX5KziNx08g==} - engines: {node: '>=12'} + '@tanstack/router-generator@1.166.12': + resolution: {integrity: sha512-2HdxSTbCkbU9JeYogKVigIlXoLtIJE1x5rbEov+ZLTPjGCO9kicNQuljqg9Js+u2/ahtWewNrE5u1QCAyxmpIg==} + engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.140.0': - resolution: {integrity: sha512-hUOOYTPLFS3LvGoPoQNk3BY3ZvPlVIgxnJT3JMJMdstLMT2RUYha3ddsaamZd4ONUSWmt+7N5OXmiG0v4XmzMw==} - engines: {node: '>=12'} + '@tanstack/router-plugin@1.166.13': + resolution: {integrity: sha512-xG3ND3AlMe6DN9PihJAYUbQJevqJvVdzN1QpZbfU1/jkHurL97ynP2yXfmMTh8Qgi1K+SWRko4bi7iZlYP9SUw==} + engines: {node: '>=20.19'} + hasBin: true peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.140.0 + '@tanstack/react-router': ^1.167.4 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1382,16 +1235,17 @@ packages: webpack: optional: true - '@tanstack/router-utils@1.140.0': - resolution: {integrity: sha512-gobraqMjkR5OO4nNbnwursGo08Idla6Yu30RspIA9IR1hv4WPJlxIyRWJcKjiQeXGyu5TuekLPUOHM46oood7w==} - engines: {node: '>=12'} + '@tanstack/router-utils@1.161.6': + resolution: {integrity: sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw==} + engines: {node: '>=20.19'} - '@tanstack/store@0.8.0': - resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/store@0.9.2': + resolution: {integrity: sha512-K013lUJEFJK2ofFQ/hZKJUmCnpcV00ebLyOyFOWQvyQHUOZp/iYO84BM6aOGiV81JzwbX0APTVmW8YI7yiG5oA==} - '@tanstack/virtual-file-routes@1.140.0': - resolution: {integrity: sha512-LVmd19QkxV3x40oHkuTii9ey3l5XDV+X8locO2p5zfVDUC+N58H2gA7cDUtVc9qtImncnz3WxQkO/6kM3PMx2w==} - engines: {node: '>=12'} + '@tanstack/virtual-file-routes@1.161.7': + resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} + engines: {node: '>=20.19'} + hasBin: true '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1414,8 +1268,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.10.2': - resolution: {integrity: sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==} + '@types/node@24.12.0': + resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1436,113 +1290,76 @@ packages: '@types/react@19.2.1': resolution: {integrity: sha512-1U5NQWh/GylZQ50ZMnnPjkYHEaGhg6t5i/KI0LDDh3t4E3h3T3vzm+GLY2BRzMfIjSBwzm6tginoZl5z0O/qsA==} - '@typescript-eslint/eslint-plugin@8.49.0': - resolution: {integrity: sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.49.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.49.0': - resolution: {integrity: sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.44.1': - resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==} + '@typescript-eslint/eslint-plugin@8.57.1': + resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + '@typescript-eslint/parser': ^8.57.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.49.0': - resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} + '@typescript-eslint/parser@8.57.1': + resolution: {integrity: sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.44.1': - resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.49.0': - resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.44.1': - resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==} + '@typescript-eslint/project-service@8.57.1': + resolution: {integrity: sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.49.0': - resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} + '@typescript-eslint/scope-manager@8.57.1': + resolution: {integrity: sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.49.0': - resolution: {integrity: sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==} + '@typescript-eslint/tsconfig-utils@8.57.1': + resolution: {integrity: sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.44.1': - resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/types@8.49.0': - resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.44.1': - resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==} + '@typescript-eslint/type-utils@8.57.1': + resolution: {integrity: sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.49.0': - resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.44.1': - resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==} + '@typescript-eslint/typescript-estree@8.57.1': + resolution: {integrity: sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.49.0': - resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} + '@typescript-eslint/utils@8.57.1': + resolution: {integrity: sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.44.1': - resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==} + '@typescript-eslint/visitor-keys@8.57.1': + resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.49.0': - resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react-swc@4.2.2': - resolution: {integrity: sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA==} + '@vitejs/plugin-react-swc@4.3.0': + resolution: {integrity: sha512-mOkXCII839dHyAt/gpoSlm28JIVDwhZ6tnG6wJxUy2bmOx7UaPjvOyIDf3SFv5s7Eo7HVaq6kRcu6YMEzt5Z7w==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4 || ^5 || ^6 || ^7 + vite: ^4 || ^5 || ^6 || ^7 || ^8 - '@vitejs/plugin-react@5.1.2': - resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} @@ -1562,8 +1379,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1624,8 +1441,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - babel-dead-code-elimination@1.0.10: - resolution: {integrity: sha512-DV5bdJZTzZ0zn0DC24v3jD7Mnidh6xhKa4GfKCbq3sfW8kaWhDdZjP3i81geA8T33tdYqWKw4D3fVv0CwEgKVA==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -1637,15 +1454,25 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.8: + resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1656,6 +1483,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1679,6 +1511,9 @@ packages: caniuse-lite@1.0.30001717: resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==} + caniuse-lite@1.0.30001780: + resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1735,9 +1570,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1771,15 +1603,6 @@ packages: supports-color: optional: true - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1805,8 +1628,8 @@ packages: engines: {node: '>=0.10'} hasBin: true - diff@8.0.2: - resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} doctrine@2.1.0: @@ -1826,6 +1649,9 @@ packages: electron-to-chromium@1.5.151: resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==} + electron-to-chromium@1.5.313: + resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1875,13 +1701,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} - engines: {node: '>=18'} - hasBin: true - - esbuild@0.27.1: - resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} engines: {node: '>=18'} hasBin: true @@ -1905,8 +1726,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react-refresh@0.4.24: - resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} peerDependencies: eslint: '>=8.40' @@ -1933,8 +1754,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.1: - resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -1974,19 +1799,12 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2057,8 +1875,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -2135,8 +1953,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@5.1.2: - resolution: {integrity: sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -2282,6 +2100,10 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2344,20 +2166,19 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2379,6 +2200,9 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2468,16 +2292,16 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.7.4: - resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} hasBin: true @@ -2488,24 +2312,21 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@19.2.1: - resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.2.1 + react: ^19.2.4 - react-error-boundary@6.0.0: - resolution: {integrity: sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==} + react-error-boundary@6.1.1: + resolution: {integrity: sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==} peerDependencies: - react: '>=16.13.1' + react: ^18.0.0 || ^19.0.0 react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - react-is@19.2.1: - resolution: {integrity: sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==} + react-is@19.2.4: + resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} @@ -2523,14 +2344,14 @@ packages: react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-window@2.2.3: - resolution: {integrity: sha512-gTRqQYC8ojbiXyd9duYFiSn2TJw0ROXCgYjenOvNKITWzK0m0eCvkUsEUM08xvydkMh7ncp+LE0uS3DeNGZxnQ==} + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} peerDependencies: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react@19.2.1: - resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} readdirp@3.6.0: @@ -2573,18 +2394,11 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.52.5: resolution: {integrity: sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -2597,8 +2411,8 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} - sass@1.95.0: - resolution: {integrity: sha512-9QMjhLq+UkOg/4bb8Lt8A+hJZvY3t+9xeZMKSBtBEgxrXA3ed5Ts4NDreUkYgJP1BTmrscQE/xYhf7iShow6lw==} + sass@1.98.0: + resolution: {integrity: sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==} engines: {node: '>=14.0.0'} hasBin: true @@ -2609,24 +2423,19 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true - seroval-plugins@1.4.0: - resolution: {integrity: sha512-zir1aWzoiax6pbBVjoYVd0O1QQXgIL3eVGBMsBsNmM8Ukq90yGaWlfx0AB9dTS8GPqrOrbXn79vmItCUP9U3BQ==} + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 - seroval@1.4.0: - resolution: {integrity: sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==} + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} set-function-length@1.2.2: @@ -2668,8 +2477,8 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - socket.io-client@4.8.1: - resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==} + socket.io-client@4.8.3: + resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} socket.io-parser@4.2.4: @@ -2751,8 +2560,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -2795,11 +2604,11 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.49.0: - resolution: {integrity: sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==} + typescript-eslint@8.57.1: + resolution: {integrity: sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' typescript@5.9.3: @@ -2824,6 +2633,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2845,8 +2660,8 @@ packages: vite: optional: true - vite@7.2.7: - resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2885,8 +2700,8 @@ packages: yaml: optional: true - wavesurfer.js@7.12.1: - resolution: {integrity: sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==} + wavesurfer.js@7.12.4: + resolution: {integrity: sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==} webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -2981,8 +2796,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.27.2': {} + '@babel/compat-data@7.29.0': {} + '@babel/core@7.27.1': dependencies: '@ampproject/remapping': 2.3.0 @@ -3043,6 +2866,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.27.1': dependencies: '@babel/parser': 7.27.2 @@ -3067,9 +2910,13 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.27.3': + '@babel/generator@7.29.1': dependencies: - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -3079,32 +2926,27 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': + '@babel/helper-compilation-targets@7.28.6': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.5) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 semver: 6.3.1 - transitivePeerDependencies: - - supports-color '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.28.5': + '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.27.1': + '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.27.1 - '@babel/types': 7.27.1 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -3135,27 +2977,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.27.1': + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: - '@babel/types': 7.28.5 - - '@babel/helper-plugin-utils@7.27.1': {} - - '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.5 + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color + '@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-string-parser@7.27.1': {} @@ -3175,13 +3006,18 @@ snapshots: '@babel/template': 7.27.2 '@babel/types': 7.28.4 + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.27.2': dependencies: '@babel/types': 7.27.1 '@babel/parser@7.28.3': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.28.4 '@babel/parser@7.28.4': dependencies: @@ -3191,59 +3027,33 @@ snapshots: dependencies: '@babel/types': 7.28.5 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + '@babel/parser@7.29.2': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/types': 7.29.0 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 - - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.28.5)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.28.5) - transitivePeerDependencies: - - supports-color '@babel/runtime@7.27.1': {} - '@babel/runtime@7.28.4': {} + '@babel/runtime@7.29.2': {} '@babel/template@7.27.2': dependencies: @@ -3251,6 +3061,12 @@ snapshots: '@babel/parser': 7.28.5 '@babel/types': 7.28.5 + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@babel/traverse@7.27.1': dependencies: '@babel/code-frame': 7.27.1 @@ -3258,7 +3074,7 @@ snapshots: '@babel/parser': 7.27.2 '@babel/template': 7.27.2 '@babel/types': 7.27.1 - debug: 4.4.3 + debug: 4.4.0 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -3266,12 +3082,12 @@ snapshots: '@babel/traverse@7.28.3': dependencies: '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 + '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.3 + '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -3299,6 +3115,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.1': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3324,29 +3152,34 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@dnd-kit/accessibility@3.1.1(react@19.2.1)': + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: - react: 19.2.1 + react: 19.2.4 tslib: 2.8.1 - '@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.1) - '@dnd-kit/utilities': 3.2.2(react@19.2.1) - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tslib: 2.8.1 - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)': + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@dnd-kit/utilities': 3.2.2(react@19.2.1) - react: 19.2.1 + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 tslib: 2.8.1 - '@dnd-kit/utilities@3.2.2(react@19.2.1)': + '@dnd-kit/utilities@3.2.2(react@19.2.4)': dependencies: - react: 19.2.1 + react: 19.2.4 tslib: 2.8.1 '@emotion/babel-plugin@11.13.5': @@ -3381,17 +3214,17 @@ snapshots: '@emotion/memoize@0.9.0': {} - '@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1)': + '@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4)': dependencies: '@babel/runtime': 7.27.1 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.1) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.4) '@emotion/utils': 1.4.2 '@emotion/weak-memoize': 0.4.0 hoist-non-react-statics: 3.3.2 - react: 19.2.1 + react: 19.2.4 optionalDependencies: '@types/react': 19.2.1 transitivePeerDependencies: @@ -3403,20 +3236,20 @@ snapshots: '@emotion/memoize': 0.9.0 '@emotion/unitless': 0.10.0 '@emotion/utils': 1.4.2 - csstype: 3.1.3 + csstype: 3.2.3 '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4)': dependencies: '@babel/runtime': 7.27.1 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.3.1 - '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.1) + '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.4) '@emotion/serialize': 1.3.3 - '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.1) + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.2.4) '@emotion/utils': 1.4.2 - react: 19.2.1 + react: 19.2.4 optionalDependencies: '@types/react': 19.2.1 transitivePeerDependencies: @@ -3424,182 +3257,111 @@ snapshots: '@emotion/unitless@0.10.0': {} - '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.1)': + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.2.4)': dependencies: - react: 19.2.1 + react: 19.2.4 '@emotion/utils@1.4.2': {} '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.25.11': - optional: true - - '@esbuild/aix-ppc64@0.27.1': - optional: true - - '@esbuild/android-arm64@0.25.11': - optional: true - - '@esbuild/android-arm64@0.27.1': - optional: true - - '@esbuild/android-arm@0.25.11': - optional: true - - '@esbuild/android-arm@0.27.1': - optional: true - - '@esbuild/android-x64@0.25.11': - optional: true - - '@esbuild/android-x64@0.27.1': - optional: true - - '@esbuild/darwin-arm64@0.25.11': - optional: true - - '@esbuild/darwin-arm64@0.27.1': - optional: true - - '@esbuild/darwin-x64@0.25.11': - optional: true - - '@esbuild/darwin-x64@0.27.1': - optional: true - - '@esbuild/freebsd-arm64@0.25.11': - optional: true - - '@esbuild/freebsd-arm64@0.27.1': + '@esbuild/aix-ppc64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.25.11': + '@esbuild/android-arm64@0.27.4': optional: true - '@esbuild/freebsd-x64@0.27.1': + '@esbuild/android-arm@0.27.4': optional: true - '@esbuild/linux-arm64@0.25.11': + '@esbuild/android-x64@0.27.4': optional: true - '@esbuild/linux-arm64@0.27.1': + '@esbuild/darwin-arm64@0.27.4': optional: true - '@esbuild/linux-arm@0.25.11': + '@esbuild/darwin-x64@0.27.4': optional: true - '@esbuild/linux-arm@0.27.1': + '@esbuild/freebsd-arm64@0.27.4': optional: true - '@esbuild/linux-ia32@0.25.11': + '@esbuild/freebsd-x64@0.27.4': optional: true - '@esbuild/linux-ia32@0.27.1': + '@esbuild/linux-arm64@0.27.4': optional: true - '@esbuild/linux-loong64@0.25.11': + '@esbuild/linux-arm@0.27.4': optional: true - '@esbuild/linux-loong64@0.27.1': + '@esbuild/linux-ia32@0.27.4': optional: true - '@esbuild/linux-mips64el@0.25.11': + '@esbuild/linux-loong64@0.27.4': optional: true - '@esbuild/linux-mips64el@0.27.1': + '@esbuild/linux-mips64el@0.27.4': optional: true - '@esbuild/linux-ppc64@0.25.11': + '@esbuild/linux-ppc64@0.27.4': optional: true - '@esbuild/linux-ppc64@0.27.1': + '@esbuild/linux-riscv64@0.27.4': optional: true - '@esbuild/linux-riscv64@0.25.11': + '@esbuild/linux-s390x@0.27.4': optional: true - '@esbuild/linux-riscv64@0.27.1': + '@esbuild/linux-x64@0.27.4': optional: true - '@esbuild/linux-s390x@0.25.11': + '@esbuild/netbsd-arm64@0.27.4': optional: true - '@esbuild/linux-s390x@0.27.1': + '@esbuild/netbsd-x64@0.27.4': optional: true - '@esbuild/linux-x64@0.25.11': + '@esbuild/openbsd-arm64@0.27.4': optional: true - '@esbuild/linux-x64@0.27.1': + '@esbuild/openbsd-x64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.25.11': + '@esbuild/openharmony-arm64@0.27.4': optional: true - '@esbuild/netbsd-arm64@0.27.1': + '@esbuild/sunos-x64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.25.11': + '@esbuild/win32-arm64@0.27.4': optional: true - '@esbuild/netbsd-x64@0.27.1': + '@esbuild/win32-ia32@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.25.11': + '@esbuild/win32-x64@0.27.4': optional: true - '@esbuild/openbsd-arm64@0.27.1': - optional: true - - '@esbuild/openbsd-x64@0.25.11': - optional: true - - '@esbuild/openbsd-x64@0.27.1': - optional: true - - '@esbuild/openharmony-arm64@0.25.11': - optional: true - - '@esbuild/openharmony-arm64@0.27.1': - optional: true - - '@esbuild/sunos-x64@0.25.11': - optional: true - - '@esbuild/sunos-x64@0.27.1': - optional: true - - '@esbuild/win32-arm64@0.25.11': - optional: true - - '@esbuild/win32-arm64@0.27.1': - optional: true - - '@esbuild/win32-ia32@0.25.11': - optional: true - - '@esbuild/win32-ia32@0.27.1': - optional: true - - '@esbuild/win32-x64@0.25.11': - optional: true - - '@esbuild/win32-x64@0.27.1': - optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.4(jiti@1.21.7))': + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} - '@eslint/config-array@0.21.1': + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.0 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -3611,21 +3373,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.0 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.1': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -3688,97 +3450,85 @@ snapshots: '@lucide/lab@0.1.2': {} - '@mui/core-downloads-tracker@7.3.6': {} + '@mui/core-downloads-tracker@7.3.9': {} - '@mui/material@7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@mui/material@7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.4 - '@mui/core-downloads-tracker': 7.3.6 - '@mui/system': 7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1) - '@mui/types': 7.4.9(@types/react@19.2.1) - '@mui/utils': 7.3.6(@types/react@19.2.1)(react@19.2.1) + '@babel/runtime': 7.29.2 + '@mui/core-downloads-tracker': 7.3.9 + '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4) + '@mui/types': 7.4.12(@types/react@19.2.1) + '@mui/utils': 7.3.9(@types/react@19.2.1)(react@19.2.4) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@19.2.1) clsx: 2.1.1 - csstype: 3.1.3 + csstype: 3.2.3 prop-types: 15.8.1 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - react-is: 19.2.1 - react-transition-group: 4.4.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 19.2.4 + react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.1) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1) + '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4) '@types/react': 19.2.1 - '@mui/private-theming@7.3.6(@types/react@19.2.1)(react@19.2.1)': + '@mui/private-theming@7.3.9(@types/react@19.2.1)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.4 - '@mui/utils': 7.3.6(@types/react@19.2.1)(react@19.2.1) + '@babel/runtime': 7.29.2 + '@mui/utils': 7.3.9(@types/react@19.2.1)(react@19.2.4) prop-types: 15.8.1 - react: 19.2.1 + react: 19.2.4 optionalDependencies: '@types/react': 19.2.1 - '@mui/styled-engine@7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(react@19.2.1)': + '@mui/styled-engine@7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 csstype: 3.2.3 prop-types: 15.8.1 - react: 19.2.1 + react: 19.2.4 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.1) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1) + '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4) - '@mui/system@7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1)': + '@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.4 - '@mui/private-theming': 7.3.6(@types/react@19.2.1)(react@19.2.1) - '@mui/styled-engine': 7.3.6(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1))(react@19.2.1) - '@mui/types': 7.4.9(@types/react@19.2.1) - '@mui/utils': 7.3.6(@types/react@19.2.1)(react@19.2.1) + '@babel/runtime': 7.29.2 + '@mui/private-theming': 7.3.9(@types/react@19.2.1)(react@19.2.4) + '@mui/styled-engine': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4))(react@19.2.4) + '@mui/types': 7.4.12(@types/react@19.2.1) + '@mui/utils': 7.3.9(@types/react@19.2.1)(react@19.2.4) clsx: 2.1.1 - csstype: 3.1.3 + csstype: 3.2.3 prop-types: 15.8.1 - react: 19.2.1 + react: 19.2.4 optionalDependencies: - '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.1) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.1))(@types/react@19.2.1)(react@19.2.1) + '@emotion/react': 11.14.0(@types/react@19.2.1)(react@19.2.4) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.1)(react@19.2.4))(@types/react@19.2.1)(react@19.2.4) '@types/react': 19.2.1 - '@mui/types@7.4.9(@types/react@19.2.1)': + '@mui/types@7.4.12(@types/react@19.2.1)': dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 optionalDependencies: '@types/react': 19.2.1 - '@mui/utils@7.3.6(@types/react@19.2.1)(react@19.2.1)': + '@mui/utils@7.3.9(@types/react@19.2.1)(react@19.2.4)': dependencies: - '@babel/runtime': 7.28.4 - '@mui/types': 7.4.9(@types/react@19.2.1) + '@babel/runtime': 7.29.2 + '@mui/types': 7.4.12(@types/react@19.2.1) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 - react: 19.2.1 - react-is: 19.2.1 + react: 19.2.4 + react-is: 19.2.4 optionalDependencies: '@types/react': 19.2.1 - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -3842,9 +3592,9 @@ snapshots: '@popperjs/core@2.11.8': {} - '@rolldown/pluginutils@1.0.0-beta.47': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rolldown/pluginutils@1.0.0-beta.53': {} + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rollup/pluginutils@5.3.0(rollup@4.52.5)': dependencies: @@ -3992,125 +3742,126 @@ snapshots: transitivePeerDependencies: - supports-color - '@swc/core-darwin-arm64@1.13.5': + '@swc/core-darwin-arm64@1.15.18': optional: true - '@swc/core-darwin-x64@1.13.5': + '@swc/core-darwin-x64@1.15.18': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.5': + '@swc/core-linux-arm-gnueabihf@1.15.18': optional: true - '@swc/core-linux-arm64-gnu@1.13.5': + '@swc/core-linux-arm64-gnu@1.15.18': optional: true - '@swc/core-linux-arm64-musl@1.13.5': + '@swc/core-linux-arm64-musl@1.15.18': optional: true - '@swc/core-linux-x64-gnu@1.13.5': + '@swc/core-linux-x64-gnu@1.15.18': optional: true - '@swc/core-linux-x64-musl@1.13.5': + '@swc/core-linux-x64-musl@1.15.18': optional: true - '@swc/core-win32-arm64-msvc@1.13.5': + '@swc/core-win32-arm64-msvc@1.15.18': optional: true - '@swc/core-win32-ia32-msvc@1.13.5': + '@swc/core-win32-ia32-msvc@1.15.18': optional: true - '@swc/core-win32-x64-msvc@1.13.5': + '@swc/core-win32-x64-msvc@1.15.18': optional: true - '@swc/core@1.13.5': + '@swc/core@1.15.18': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.24 + '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.5 - '@swc/core-darwin-x64': 1.13.5 - '@swc/core-linux-arm-gnueabihf': 1.13.5 - '@swc/core-linux-arm64-gnu': 1.13.5 - '@swc/core-linux-arm64-musl': 1.13.5 - '@swc/core-linux-x64-gnu': 1.13.5 - '@swc/core-linux-x64-musl': 1.13.5 - '@swc/core-win32-arm64-msvc': 1.13.5 - '@swc/core-win32-ia32-msvc': 1.13.5 - '@swc/core-win32-x64-msvc': 1.13.5 + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 '@swc/counter@0.1.3': {} - '@swc/types@0.1.24': + '@swc/types@0.1.25': dependencies: '@swc/counter': 0.1.3 - '@tanstack/eslint-plugin-query@5.91.2(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@tanstack/eslint-plugin-query@5.91.4(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.44.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + optionalDependencies: + typescript: 5.9.3 transitivePeerDependencies: - supports-color - - typescript - '@tanstack/history@1.140.0': {} + '@tanstack/history@1.161.6': {} - '@tanstack/query-core@5.90.12': {} + '@tanstack/query-core@5.90.20': {} - '@tanstack/query-devtools@5.91.1': {} + '@tanstack/query-devtools@5.93.0': {} - '@tanstack/react-query-devtools@5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1)': + '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/query-devtools': 5.91.1 - '@tanstack/react-query': 5.90.12(react@19.2.1) - react: 19.2.1 + '@tanstack/query-devtools': 5.93.0 + '@tanstack/react-query': 5.90.21(react@19.2.4) + react: 19.2.4 - '@tanstack/react-query@5.90.12(react@19.2.1)': + '@tanstack/react-query@5.90.21(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.90.12 - react: 19.2.1 + '@tanstack/query-core': 5.90.20 + react: 19.2.4 - '@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@tanstack/react-router@1.167.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/history': 1.140.0 - '@tanstack/react-store': 0.8.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@tanstack/router-core': 1.140.0 + '@tanstack/history': 1.161.6 + '@tanstack/react-store': 0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.167.4 isbot: 5.1.29 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.8.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/store': 0.8.0 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) - use-sync-external-store: 1.6.0(react@19.2.1) + '@tanstack/store': 0.9.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/router-cli@1.140.0': + '@tanstack/router-cli@1.166.12': dependencies: - '@tanstack/router-generator': 1.140.0 + '@tanstack/router-generator': 1.166.12 chokidar: 3.6.0 yargs: 17.7.2 transitivePeerDependencies: - supports-color - '@tanstack/router-core@1.140.0': + '@tanstack/router-core@1.167.4': dependencies: - '@tanstack/history': 1.140.0 - '@tanstack/store': 0.8.0 + '@tanstack/history': 1.161.6 + '@tanstack/store': 0.9.2 cookie-es: 2.0.0 - seroval: 1.4.0 - seroval-plugins: 1.4.0(seroval@1.4.0) + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-generator@1.140.0': + '@tanstack/router-generator@1.166.12': dependencies: - '@tanstack/router-core': 1.140.0 - '@tanstack/router-utils': 1.140.0 - '@tanstack/virtual-file-routes': 1.140.0 - prettier: 3.7.4 + '@tanstack/router-core': 1.167.4 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 + prettier: 3.8.1 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -4118,7 +3869,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.140.0(@tanstack/react-router@1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0))': + '@tanstack/router-plugin@1.166.13(@tanstack/react-router@1.167.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -4126,57 +3877,57 @@ snapshots: '@babel/template': 7.27.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 - '@tanstack/router-core': 1.140.0 - '@tanstack/router-generator': 1.140.0 - '@tanstack/router-utils': 1.140.0 - '@tanstack/virtual-file-routes': 1.140.0 - babel-dead-code-elimination: 1.0.10 + '@tanstack/router-core': 1.167.4 + '@tanstack/router-generator': 1.166.12 + '@tanstack/router-utils': 1.161.6 + '@tanstack/virtual-file-routes': 1.161.7 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.140.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + '@tanstack/react-router': 1.167.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color - '@tanstack/router-utils@1.140.0': + '@tanstack/router-utils@1.161.6': dependencies: '@babel/core': 7.28.5 - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/preset-typescript': 7.28.5(@babel/core@7.28.5) + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.28.5 ansis: 4.2.0 - diff: 8.0.2 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 pathe: 2.0.3 tinyglobby: 0.2.15 transitivePeerDependencies: - supports-color - '@tanstack/store@0.8.0': {} + '@tanstack/store@0.9.2': {} - '@tanstack/virtual-file-routes@1.140.0': {} + '@tanstack/virtual-file-routes@1.161.7': {} '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.7 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.20.7': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.0 '@types/estree@1.0.7': {} @@ -4184,7 +3935,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@24.10.2': + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -4204,166 +3955,114 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/type-utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 - eslint: 9.39.1(jiti@1.21.7) + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/type-utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 + eslint: 9.39.4(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.44.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.3) - '@typescript-eslint/types': 8.44.1 - debug: 4.4.0 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.49.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.44.1': - dependencies: - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/visitor-keys': 8.44.1 - - '@typescript-eslint/scope-manager@8.49.0': - dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 - - '@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.57.1': dependencies: - typescript: 5.9.3 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 - '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.57.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.1(jiti@1.21.7) - ts-api-utils: 2.1.0(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.44.1': {} + '@typescript-eslint/types@8.57.1': {} - '@typescript-eslint/types@8.49.0': {} - - '@typescript-eslint/typescript-estree@8.44.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.44.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.9.3) - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/visitor-keys': 8.44.1 - debug: 4.4.0 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 + '@typescript-eslint/project-service': 8.57.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.1(typescript@5.9.3) + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.3 + minimatch: 10.2.4 + semver: 7.7.4 tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.44.1(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.44.1 - '@typescript-eslint/types': 8.44.1 - '@typescript-eslint/typescript-estree': 8.44.1(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.57.1 + '@typescript-eslint/types': 8.57.1 + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.44.1': + '@typescript-eslint/visitor-keys@8.57.1': dependencies: - '@typescript-eslint/types': 8.44.1 - eslint-visitor-keys: 4.2.1 - - '@typescript-eslint/visitor-keys@8.49.0': - dependencies: - '@typescript-eslint/types': 8.49.0 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.57.1 + eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react-swc@4.2.2(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0))': + '@vitejs/plugin-react-swc@4.3.0(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0))': dependencies: - '@rolldown/pluginutils': 1.0.0-beta.47 - '@swc/core': 1.13.5 - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + '@rolldown/pluginutils': 1.0.0-rc.7 + '@swc/core': 1.15.18 + vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.1.2(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0))': dependencies: - '@babel/core': 7.28.5 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) - '@rolldown/pluginutils': 1.0.0-beta.53 + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -4379,7 +4078,7 @@ snapshots: acorn@8.15.0: {} - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -4466,10 +4165,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - babel-dead-code-elimination@1.0.10: + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.28.5 - '@babel/parser': 7.28.5 + '@babel/parser': 7.29.2 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 transitivePeerDependencies: @@ -4487,16 +4186,20 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.8: {} + binary-extensions@2.3.0: {} - brace-expansion@1.1.11: + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@5.0.4: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -4509,6 +4212,14 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.5) + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.8 + caniuse-lite: 1.0.30001780 + electron-to-chromium: 1.5.313 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4532,6 +4243,8 @@ snapshots: caniuse-lite@1.0.30001717: {} + caniuse-lite@1.0.30001780: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4598,8 +4311,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csstype@3.1.3: {} - csstype@3.2.3: {} data-view-buffer@1.0.2: @@ -4628,10 +4339,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.1: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4653,7 +4360,7 @@ snapshots: detect-libc@1.0.3: optional: true - diff@8.0.2: {} + diff@8.0.3: {} doctrine@2.1.0: dependencies: @@ -4661,7 +4368,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 csstype: 3.2.3 dot-case@3.0.4: @@ -4677,6 +4384,8 @@ snapshots: electron-to-chromium@1.5.151: {} + electron-to-chromium@1.5.313: {} + emoji-regex@8.0.0: {} engine.io-client@6.6.3: @@ -4797,88 +4506,59 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.25.11: + esbuild@0.27.4: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 - - esbuild@0.27.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.1 - '@esbuild/android-arm': 0.27.1 - '@esbuild/android-arm64': 0.27.1 - '@esbuild/android-x64': 0.27.1 - '@esbuild/darwin-arm64': 0.27.1 - '@esbuild/darwin-x64': 0.27.1 - '@esbuild/freebsd-arm64': 0.27.1 - '@esbuild/freebsd-x64': 0.27.1 - '@esbuild/linux-arm': 0.27.1 - '@esbuild/linux-arm64': 0.27.1 - '@esbuild/linux-ia32': 0.27.1 - '@esbuild/linux-loong64': 0.27.1 - '@esbuild/linux-mips64el': 0.27.1 - '@esbuild/linux-ppc64': 0.27.1 - '@esbuild/linux-riscv64': 0.27.1 - '@esbuild/linux-s390x': 0.27.1 - '@esbuild/linux-x64': 0.27.1 - '@esbuild/netbsd-arm64': 0.27.1 - '@esbuild/netbsd-x64': 0.27.1 - '@esbuild/openbsd-arm64': 0.27.1 - '@esbuild/openbsd-x64': 0.27.1 - '@esbuild/openharmony-arm64': 0.27.1 - '@esbuild/sunos-x64': 0.27.1 - '@esbuild/win32-arm64': 0.27.1 - '@esbuild/win32-ia32': 0.27.1 - '@esbuild/win32-x64': 0.27.1 + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 escalade@3.2.0: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.1(jiti@1.21.7)): + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) - eslint-plugin-react-hooks@7.0.0(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react-hooks@7.0.0(eslint@9.39.4(jiti@1.21.7)): dependencies: '@babel/core': 7.28.4 '@babel/parser': 7.28.4 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) hermes-parser: 0.25.1 zod: 3.25.76 zod-validation-error: 3.4.1(zod@3.25.76) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@1.21.7)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -4886,7 +4566,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -4900,9 +4580,9 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.1(jiti@1.21.7)): + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@1.21.7)): dependencies: - eslint: 9.39.1(jiti@1.21.7) + eslint: 9.39.4(jiti@1.21.7) eslint-scope@8.4.0: dependencies: @@ -4913,21 +4593,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1(jiti@1.21.7): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.4(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.39.1 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.7 - ajv: 6.12.6 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.0 @@ -4946,7 +4628,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -4978,22 +4660,10 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} fast-levenshtein@2.0.6: {} - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -5068,7 +4738,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -5131,7 +4801,7 @@ snapshots: ignore@7.0.5: {} - immutable@5.1.2: {} + immutable@5.1.5: {} import-fresh@3.3.1: dependencies: @@ -5283,6 +4953,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -5331,26 +5005,29 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.546.0(react@19.2.1): + lucide-react@0.546.0(react@19.2.4): dependencies: - react: 19.2.1 + react: 19.2.4 math-intrinsics@1.1.0: {} - merge2@1.4.1: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + optional: true + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 minimatch@3.1.2: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.12 - minimatch@9.0.5: + minimatch@3.1.5: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 1.1.12 ms@2.1.3: {} @@ -5368,6 +5045,8 @@ snapshots: node-releases@2.0.19: {} + node-releases@2.0.36: {} + normalize-path@3.0.0: {} object-assign@4.1.1: {} @@ -5458,7 +5137,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -5466,7 +5145,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.7.4: {} + prettier@3.8.1: {} prop-types@15.8.1: dependencies: @@ -5476,44 +5155,41 @@ snapshots: punycode@2.3.1: {} - queue-microtask@1.2.3: {} - - react-dom@19.2.1(react@19.2.1): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.2.1 + react: 19.2.4 scheduler: 0.27.0 - react-error-boundary@6.0.0(react@19.2.1): + react-error-boundary@6.1.1(react@19.2.4): dependencies: - '@babel/runtime': 7.28.4 - react: 19.2.1 + react: 19.2.4 react-is@16.13.1: {} - react-is@19.2.1: {} + react-is@19.2.4: {} react-refresh@0.18.0: {} - react-transition-group@4.4.5(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@babel/runtime': 7.28.4 + '@babel/runtime': 7.29.2 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - react-virtualized-auto-sizer@1.0.26(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + react-virtualized-auto-sizer@1.0.26(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - react-window@2.2.3(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + react-window@2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - react@19.2.1: {} + react@19.2.4: {} readdirp@3.6.0: dependencies: @@ -5567,8 +5243,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} - rollup@4.52.5: dependencies: '@types/estree': 1.0.8 @@ -5597,10 +5271,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -5620,10 +5290,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 - sass@1.95.0: + sass@1.98.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.2 + immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.1 @@ -5632,15 +5302,13 @@ snapshots: semver@6.3.1: {} - semver@7.7.2: {} + semver@7.7.4: {} - semver@7.7.3: {} - - seroval-plugins@1.4.0(seroval@1.4.0): + seroval-plugins@1.5.1(seroval@1.5.1): dependencies: - seroval: 1.4.0 + seroval: 1.5.1 - seroval@1.4.0: {} + seroval@1.5.1: {} set-function-length@1.2.2: dependencies: @@ -5703,10 +5371,10 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socket.io-client@4.8.1: + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.3.7 + debug: 4.4.3 engine.io-client: 6.6.3 socket.io-parser: 4.2.4 transitivePeerDependencies: @@ -5808,7 +5476,7 @@ snapshots: dependencies: is-number: 7.0.0 - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5820,8 +5488,8 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.1 - get-tsconfig: 4.13.0 + esbuild: 0.27.4 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -5862,13 +5530,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3): + typescript-eslint@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.1(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5897,52 +5565,58 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.1): + use-sync-external-store@1.6.0(react@19.2.4): dependencies: - react: 19.2.1 + react: 19.2.4 - vite-plugin-svgr@4.5.0(rollup@4.52.5)(typescript@5.9.3)(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)): + vite-plugin-svgr@4.5.0(rollup@4.52.5)(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.52.5) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.9.3) optionalDependencies: - vite: 7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0) + vite: 7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color - typescript - vite@7.2.7(@types/node@24.10.2)(jiti@1.21.7)(sass@1.95.0)(tsx@4.21.0): + vite@7.3.1(@types/node@24.12.0)(jiti@1.21.7)(sass@1.98.0)(tsx@4.21.0): dependencies: - esbuild: 0.25.11 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.2 + '@types/node': 24.12.0 fsevents: 2.3.3 jiti: 1.21.7 - sass: 1.95.0 + sass: 1.98.0 tsx: 4.21.0 - wavesurfer.js@7.12.1: {} + wavesurfer.js@7.12.4: {} webpack-virtual-modules@0.6.2: {} diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 63801e85..18740227 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -2,45 +2,13 @@ import { useMemo } from 'react'; import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { useLocalStorage } from '@/components/common/hooks/useLocalStorage'; +import { BeetsSchema } from '@/pythonTypes'; -export interface MinimalConfig { - gui: { - inbox: { - concat_nested_folders: boolean; - expand_files: boolean; - folders: Record< - string, - { - autotag: false | 'preview' | 'auto' | 'bootleg'; - auto_threshold?: number; - name: string; - path: string; - } - >; - }; - library: { - include_paths: boolean; - readonly: boolean; - }; - num_workers_preview: number; - tags: { - recent_days: number; - expand_tags: boolean; - order_by: string; - show_unchanged_tracks: boolean; - }; - }; - import: { - duplicate_action: string; - }; - match: { - medium_rec_thresh: number; - strong_rec_thresh: number; - album_disambig_fields: string[]; - singleton_disambig_fields: string[]; - }; - plugins: Array; - data_sources: Array; +import { APIError } from './common'; + +export interface MinimalConfig extends BeetsSchema { + // extra fields that are not in the schema: + beets_metadata_sources: Array; beets_version: string; } @@ -61,7 +29,7 @@ export const useConfig = () => { /* ---------------------------- Raw config files ---------------------------- */ export const configYamlQueryOptions = (type: 'beets' | 'beetsflask') => - queryOptions({ + queryOptions<{ path: string; content: string }, APIError>({ queryKey: ['config_yaml', type], queryFn: async () => { const url = @@ -88,6 +56,7 @@ type ActionOptionMap = { undo: { delete_files: boolean; }; + upload: undefined; import_best: undefined; import_bootleg: undefined; import_terminal: undefined; @@ -125,7 +94,6 @@ export interface InboxFolderFrontendConfig { export const ACTIONS: Record = { retag: { name: 'retag', - label: 'Retag', options: { group_albums: false, autotag: true, @@ -137,6 +105,9 @@ export const ACTIONS: Record = { delete_files: true, }, }, + upload: { + name: 'upload', + }, import_best: { name: 'import_best', }, @@ -178,7 +149,7 @@ export const DEFAULT_INBOX_FOLDER_FRONTEND_CONFIG: InboxFolderFrontendConfig = { }, extra: { variant: 'text', - actions: [ACTIONS.delete_imported_folders], + actions: [ACTIONS.delete_imported_folders, ACTIONS.upload], }, }, }; diff --git a/frontend/src/api/fileUpload.ts b/frontend/src/api/fileUpload.ts new file mode 100644 index 00000000..cb3eb919 --- /dev/null +++ b/frontend/src/api/fileUpload.ts @@ -0,0 +1,222 @@ +import { Dispatch, SetStateAction, useState } from 'react'; +import { useMutation, UseMutationOptions } from '@tanstack/react-query'; + +import { SerializedException } from '@/pythonTypes'; + +import { APIError } from './common'; + +export interface FileUploadProgress { + name: string; + total: number; // bytes + loaded: number; + started?: number; + finished?: number; +} + +export interface BatchFileUploadProgress { + // overall progress + files: FileUploadProgress[]; + currentIndex: number; + started?: number; + finished?: number; + total: number; // bytes + loaded: number; +} + +export const fileUploadMutationOptions: UseMutationOptions< + { status: string }, + APIError, + { + files: File[] | FileList; + targetDir: string; + setProgress: Dispatch>; + } +> = { + mutationFn: async ({ files, targetDir, setProgress }) => { + let uploadedBytesTotal = 0; + const totalBytes = Array.from(files).reduce( + (acc, file) => acc + file.size, + 0 + ); + console.log( + `Uploading ${files.length} files, total size ${totalBytes} bytes` + ); + + // init progress bars for each file + // Note that you cannot use an object for the batch progress, because + // we might not iterate its individual file progress objects in order. + const started = performance.now(); + setProgress({ + files: Array.from(files).map((file) => ({ + name: file.name, + total: file.size, + loaded: 0, + })), + currentIndex: 0, + total: totalBytes, + loaded: 0, + started: started, + }); + + // We opted to upload files sequentially + // TODO: what happens if one of the files fails? + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + let uploadedBytesFile = 0; + await uploadFile(file, targetDir, (progressUpdate) => { + uploadedBytesTotal += progressUpdate.loaded - uploadedBytesFile; + uploadedBytesFile = progressUpdate.loaded; + + setProgress((prev) => { + return { + ...prev, + files: prev.files.map((f, idx) => + idx === i + ? { + ...f, + loaded: progressUpdate.loaded, + started: f.started ?? performance.now(), + } + : f + ), + currentIndex: i, + loaded: uploadedBytesTotal, + }; + }); + }); + + setProgress((prev) => { + return { + ...prev, + files: prev.files.map((f, idx) => + idx === i + ? { + ...f, + loaded: file.size, + started: f.started ?? performance.now(), + finished: performance.now(), + } + : f + ), + currentIndex: i + 1, + loaded: uploadedBytesTotal, + }; + }); + console.debug( + `Uploaded file ${i + 1}/${files.length}: ${file.name}` + ); + } + + const finished = performance.now(); + setProgress((prev) => { + return { + ...prev, + finished: finished, + loaded: totalBytes, + currentIndex: files.length, + }; + }); + console.log( + `Uploaded ${files.length} files, total size ${totalBytes} bytes in ${ + (finished - started) / 1000 + } seconds` + ); + return { status: 'ok' }; + }, +}; + +/** File level upload + * @param file The file to upload + * @param targetDir The target directory on the server + * @param onProgress Optional callback for progress updates + */ +async function uploadFile( + file: File, + targetDir: string, + onProgress?: (progress: { total: number; loaded: number }) => void +): Promise<{ status: string }> { + // Validate headers (filename and target dir) before uploading + // Raises if invalid + await fetch('/file_upload/validate', { + method: 'POST', + headers: { + 'X-Filename': encodeURIComponent(file.name), + 'X-File-Target-Dir': targetDir, + }, + }); + + return new Promise<{ status: string }>((resolve, reject) => { + const req = new XMLHttpRequest(); + req.responseType = 'json'; + req.open('POST', '/api_v1/file_upload', true); + req.setRequestHeader('X-Filename', encodeURIComponent(file.name)); + req.setRequestHeader( + 'X-File-Target-Dir', + encodeURIComponent(targetDir) + ); + // req.setRequestHeader("Content-Length", String(file.size)); + + req.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress) { + onProgress({ total: event.total, loaded: event.loaded }); + } + }; + + req.onload = () => { + if (req.status >= 200 && req.status < 300) { + // onprogress is not called automatically when finally done + // onProgress?.({ total: file.size, loaded: file.size }); + console.log('File upload resolve'); + resolve({ status: 'ok' }); + } else { + const json_error = req.response as SerializedException; + console.error('File upload error:', json_error); + reject(new APIError(json_error, req.status)); + } + }; + + req.onerror = () => { + const json_error = req.response as SerializedException; + console.error('File upload error:', req); + reject(new APIError(json_error, req.status)); + }; + req.send(file); + }); +} + +export function useFileUpload() { + const { mutate, mutateAsync, reset, ...props } = useMutation( + fileUploadMutationOptions + ); + const [uploadProgress, setProgress] = useState({ + files: [], + currentIndex: 0, + total: 0, + loaded: 0, + }); + + // TODO: Snackbar on success/error + + return { + uploadProgress, + mutate: (files: FileList | File[], targetDir: string) => { + mutate({ files, targetDir, setProgress: setProgress }); + }, + mutateAsync: (files: FileList | File[], targetDir: string) => + mutateAsync({ files, targetDir, setProgress: setProgress }), + reset: () => { + reset(); + // Fully reset progress and filelist + setProgress({ + files: [], + currentIndex: 0, + total: 0, + loaded: 0, + }); + }, + ...props, + }; +} + +export type FileUploadState = ReturnType; diff --git a/frontend/src/components/common/hooks/useDrag.ts b/frontend/src/components/common/hooks/useDrag.ts new file mode 100644 index 00000000..74c5f510 --- /dev/null +++ b/frontend/src/components/common/hooks/useDrag.ts @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react'; + +type Entries = { + [K in keyof T]: [K, T[K]]; +}[keyof T][]; + +/** Custom hook to manage drag and drop interactions. + * + * If elementRef is null, the hook is attached to the window + * + */ +export function useDragAndDrop( + elementRef: React.RefObject | null, + options?: { + onDrop?: (event: DragEvent) => void; + onDragOver?: (event: DragEvent) => void; + onDragEnter?: (event: DragEvent) => void; + onDragLeave?: (event: DragEvent) => void; + onDragStart?: (event: DragEvent) => void; + onDragEnd?: (event: DragEvent) => void; + onDropWindow?: (event: DragEvent) => void; + preventDefault?: boolean; + } +) { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + let element: HTMLDivElement = window as unknown as HTMLDivElement; // cursed typing + if (elementRef) { + if (!elementRef.current) return; + element = elementRef.current; + } + + const abortController = new AbortController(); + + // use the timeout to reset drag state so users dont have to reload the page + // if something goes wrong + let timeout: NodeJS.Timeout | null = null; + const eventHandlers = { + dragend: (e: DragEvent) => { + setIsDragging(false); + options?.onDragEnd?.(e); + }, + dragleave: (e: DragEvent) => { + setIsDragging(false); + options?.onDragLeave?.(e); + }, + dragover: (e: DragEvent) => { + setIsDragging(true); + options?.onDragOver?.(e); + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + setIsDragging(false); + }, 20000); + }, + drop: (e: DragEvent) => { + setIsDragging(false); + options?.onDrop?.(e); + }, + dragstart: options?.onDragStart, + } as const; + + for (const [event, handler] of Object.entries(eventHandlers) as Entries< + typeof eventHandlers + >) { + if (!handler) continue; + element.addEventListener( + event, + (e) => { + if (options?.preventDefault) { + e.preventDefault(); + e.stopPropagation(); + } + handler(e); + }, + { + signal: abortController.signal, + } + ); + } + + return () => { + abortController.abort(); + }; + }, [elementRef, options]); + + return isDragging; +} diff --git a/frontend/src/components/common/inputs/cancle.tsx b/frontend/src/components/common/inputs/cancle.tsx new file mode 100644 index 00000000..611bb71c --- /dev/null +++ b/frontend/src/components/common/inputs/cancle.tsx @@ -0,0 +1,93 @@ +import { XIcon } from 'lucide-react'; +import { useImperativeHandle, useRef, useState } from 'react'; +import { Button, ButtonProps } from '@mui/material'; + +export interface CancelButtonRef { + cancel: () => void; + cancelWithTimer: (timeout: number) => void; +} + +/** Cancel button + * + * Advanced usage: + * + * Allows to set a timeout which shown an animation + * on the button before calling the onCancel function. + * This can be triggered using the CancelButtonRef. This + * does not trigger on the button click, only when the + * triggerCancel function is called. + * + * Example: + * const cancelButtonRef = useRef(null); + * { ... }} /> + * cancelButtonRef.current?.triggerCancel(); + * + */ +export function CancelButton({ + ref, + onCancel, + ...props +}: { + ref?: React.Ref; + onCancel: () => void; +} & Omit) { + const timeoutRef = useRef(null); + const intervalRef = useRef(null); + const [isCancelling, setIsCancelling] = useState(false); + const [remainingTime, setRemainingTime] = useState(0); + + const triggerCancel = (timerDuration: number = 0) => { + if (timerDuration <= 0) { + onCancel(); + return; + } + + setIsCancelling(true); + setRemainingTime(Math.ceil(timerDuration / 1000)); + + // Update countdown every second + intervalRef.current = setInterval(() => { + setRemainingTime((prev) => { + const next = prev - 1; + return next <= 0 ? 0 : next; + }); + }, 1000); + + // Execute cancel after timeout + timeoutRef.current = setTimeout(() => { + if (intervalRef.current) clearInterval(intervalRef.current); + onCancel(); + setIsCancelling(false); + setRemainingTime(0); + }, timerDuration); + }; + + const cancelTimer = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + setIsCancelling(false); + setRemainingTime(0); + }; + + useImperativeHandle(ref, () => ({ + cancel: onCancel, + cancelWithTimer: triggerCancel, + })); + + return ( + + ); +} diff --git a/frontend/src/components/frontpage/navbar.tsx b/frontend/src/components/frontpage/navbar.tsx index 93767806..582dab04 100644 --- a/frontend/src/components/frontpage/navbar.tsx +++ b/frontend/src/components/frontpage/navbar.tsx @@ -6,6 +6,8 @@ import Tab, { tabClasses, TabProps } from '@mui/material/Tab'; import Tabs, { tabsClasses } from '@mui/material/Tabs'; import { createLink, LinkProps, useRouterState } from '@tanstack/react-router'; +import { useConfig } from '@/api/config.ts'; + export const NAVBAR_HEIGHT = { desktop: '48px', mobile: '74px', @@ -106,6 +108,7 @@ const StyledTab = styled(createLink(Tab))(({ theme }) => ({ marginTop: 0, height: NAVBAR_HEIGHT.mobile, display: 'flex', + zIndex: 1, flexDirection: 'column', '& svg': { @@ -141,6 +144,8 @@ function NavItem({ label, ...props }: StyledTabProps) { function NavTabs() { const theme = useTheme(); + const config = useConfig(); + const location = useRouterState({ select: (s) => s.location }); let basePath = location.pathname.split('/')[1]; @@ -149,18 +154,21 @@ function NavTabs() { basePath += '/' + location.pathname.split('/')[2]; } - const navItems = [ - { label: 'Home', icon: , to: '/' as const }, - { label: 'Inbox', icon: , to: '/inbox' as const }, - //{ label: "Session", icon: , to: "/sessiondraft" as const }, - { label: 'Library', icon: , to: '/library/browse' as const }, - { label: 'Search', icon: , to: '/library/search' as const }, - { + const navItems: StyledTabProps[] = [ + { label: 'Home', icon: , to: '/' }, + { label: 'Inbox', icon: , to: '/inbox' }, + //{ label: "Session", icon: , to: '/sessiondraft'}, + { label: 'Library', icon: , to: '/library/browse' }, + { label: 'Search', icon: , to: '/library/search' }, + ]; + + if (config.gui.terminal.enabled) { + navItems.push({ label: '', icon: , - to: '/terminal' as const, - }, - ]; + to: '/terminal', + }); + } const currentIdx = navItems.findIndex((item) => item.to === '/' + basePath); const ref = useRef(null); @@ -196,7 +204,7 @@ export default function NavBar(props: BoxProps) { sx={(theme) => ({ position: 'fixed', bottom: 0, - zIndex: 10, + zIndex: 2, width: '100dvw', height: NAVBAR_HEIGHT.mobile, display: 'flex', diff --git a/frontend/src/components/frontpage/terminal.tsx b/frontend/src/components/frontpage/terminal.tsx index 9ac8d8d9..bfa90004 100644 --- a/frontend/src/components/frontpage/terminal.tsx +++ b/frontend/src/components/frontpage/terminal.tsx @@ -14,6 +14,7 @@ import { Terminal as xTerminal } from '@xterm/xterm'; import useSocket from '@/components/common/websocket/useSocket'; import 'node_modules/@xterm/xterm/css/xterm.css'; +import { useConfig } from '@/api/config.ts'; import { Socket } from 'socket.io-client'; // match our style - this is somewhat redundant with main.css @@ -139,8 +140,37 @@ export function TerminalContextProvider({ }: { children: React.ReactNode; }) { - const { socket, isConnected } = useSocket('terminal'); + const config = useConfig(); + + if (!config.gui.terminal.enabled) { + const noop = () => { + console.warn( + 'Terminal is not available (disabled in server config).' + ); + }; + + return ( + + {children} + + ); + } + return {children}; +} + +function InitTerminalContext({ children }: { children: React.ReactNode }) { + const { socket, isConnected } = useSocket('terminal'); const [open, setOpen] = useState(false); const [term, setTerm] = useState(); diff --git a/frontend/src/components/import/candidates/actions.tsx b/frontend/src/components/import/candidates/actions.tsx index 8a313725..b1d9fc16 100644 --- a/frontend/src/components/import/candidates/actions.tsx +++ b/frontend/src/components/import/candidates/actions.tsx @@ -217,7 +217,7 @@ export function CandidateSearch({ task }: { task: SerializedTaskState }) { const [search, setSearch] = useState({ search_ids: [], search_artist: null, - search_album: null, + search_name: null, }); /** Mutation for the search @@ -274,7 +274,7 @@ export function CandidateSearch({ task }: { task: SerializedTaskState }) { setSearch({ search_ids: [], search_artist: '', - search_album: '', + search_name: '', }); } catch (e) { // dont close the dialog @@ -332,11 +332,11 @@ export function CandidateSearch({ task }: { task: SerializedTaskState }) { id="input-search-artist" label="and album" placeholder="Album" - value={search.search_album || ''} + value={search.search_name || ''} onChange={(e) => { setSearch({ ...search, - search_album: e.target.value, + search_name: e.target.value, }); }} /> diff --git a/frontend/src/components/inbox/actions/buttons.tsx b/frontend/src/components/inbox/actions/buttons.tsx index 82e58b6c..60231776 100644 --- a/frontend/src/components/inbox/actions/buttons.tsx +++ b/frontend/src/components/inbox/actions/buttons.tsx @@ -10,6 +10,7 @@ import { TerminalIcon, Trash2Icon, UngroupIcon, + UploadIcon, } from 'lucide-react'; import { useState } from 'react'; import { @@ -231,6 +232,8 @@ export function ActionIcon({ action }: { action: Action | Action['name'] }) { return ; case 'undo': return ; + case 'upload': + return ; case 'import_bootleg': return ; case 'import_best': diff --git a/frontend/src/components/inbox/actions/descriptions.tsx b/frontend/src/components/inbox/actions/descriptions.tsx index b9eb02df..eb4c9cdc 100644 --- a/frontend/src/components/inbox/actions/descriptions.tsx +++ b/frontend/src/components/inbox/actions/descriptions.tsx @@ -8,6 +8,8 @@ export function getActionDescription(action: Action | Action['name']): string { return 'Retag the selected album(s) this includes fetching candidates from your configured metadata sources.'; case 'undo': return 'Allows you to undo imports, this will remove the imported album(s) from your library.'; + case 'upload': + return 'Open an overlay to select files for upload into the current inbox.'; case 'import_bootleg': return 'Import album without using a candidate, this is useful where online metadata is not available, such as bootlegs or dubs.'; case 'import_best': diff --git a/frontend/src/components/inbox/actions/mutations.tsx b/frontend/src/components/inbox/actions/mutations.tsx index ce4736e1..98a8f410 100644 --- a/frontend/src/components/inbox/actions/mutations.tsx +++ b/frontend/src/components/inbox/actions/mutations.tsx @@ -16,6 +16,10 @@ import { import { EnqueueKind } from '@/pythonTypes'; import { InboxCardContext, useInboxCardContext } from '../cards/inboxCard'; +import { + FileUploadContextType, + useFileUploadContext, +} from '../fileUpload/context'; import { FolderSelectionContext, useFolderSelectionContext, @@ -26,6 +30,7 @@ export function useActionMutation(action: Action) { const selectionContext = useFolderSelectionContext(); const inboxContext = useInboxCardContext(); const terminalContext = useTerminalContext(); + const uploadContext = useFileUploadContext(); const navigate = useNavigate(); const [options, mutationArgs] = actionMutationOptionsAndArgs( @@ -33,6 +38,7 @@ export function useActionMutation(action: Action) { socket, selectionContext, inboxContext, + uploadContext, terminalContext, navigate ); @@ -56,6 +62,7 @@ function actionMutationOptionsAndArgs( socket: StatusSocket | null, selectionContext: FolderSelectionContext, inboxContext: InboxCardContext, + uploadContext: FileUploadContextType, terminalContext: TerminalContextI, navigate: UseNavigateResult ): [UseMutationOptions, T] { @@ -157,6 +164,15 @@ function actionMutationOptionsAndArgs( escape: options?.escape ?? true, // default to escaping paths } as T, ]; + case 'upload': + return [ + uploadDialogMutationOptions as UseMutationOptions< + unknown, + APIError, + T + >, + { ...uploadContext, inboxFolder: inboxContext.folder } as T, + ]; case 'import_terminal': return [ importTerminalMutationOptions as UseMutationOptions< @@ -211,6 +227,22 @@ const copyPathMutationOptions: UseMutationOptions< }, }; +const uploadDialogMutationOptions: UseMutationOptions< + unknown, + APIError, + FileUploadContextType & { inboxFolder: InboxCardContext['folder'] } +> = { + // eslint-disable-next-line @typescript-eslint/require-await + mutationFn: async ({ setUploadTargetDir, setOpenDialog, inboxFolder }) => { + // keep the suggested name consistent with the one used in dropzone + setUploadTargetDir( + inboxFolder.full_path + + `/upload_${formatDate(new Date(), '%Y%m%d_%H%M%S')}` + ); + setOpenDialog(true); + }, +}; + function _escapePathForBash(path: string) { // escaping path is fishy, but this seems to be the best compromise // https://stackoverflow.com/questions/1779858/how-do-i-escape-a-string-for-a-shell-command-in-node diff --git a/frontend/src/components/inbox/fileUpload/context.tsx b/frontend/src/components/inbox/fileUpload/context.tsx new file mode 100644 index 00000000..59a3ca3b --- /dev/null +++ b/frontend/src/components/inbox/fileUpload/context.tsx @@ -0,0 +1,101 @@ +import { createContext, useContext, useState } from 'react'; +import React from 'react'; +import { createPortal } from 'react-dom'; +import { Box } from '@mui/material'; + +import { FileUploadState, useFileUpload } from '@/api/fileUpload'; +import { useDragAndDrop } from '@/components/common/hooks/useDrag'; + +import { UploadDialog } from './dialog'; + +export type FileUploadContextType = Omit< + FileUploadState, + 'mutate' | 'mutateAsync' +> & { + fileList: Array; + setFileList: React.Dispatch>>; + uploadFiles: () => Promise<{ status: string }>; + uploadTargetDir: string | null; + setUploadTargetDir: React.Dispatch>; + openDialog: boolean; + setOpenDialog: React.Dispatch>; + reset: () => void; + + // drag drop + isOverWindow: boolean; +}; + +const FileUploadContext = createContext(null); + +// Provider component which allows to +// upload files via drag and drop or file picker +export function FileUploadProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [fileList, setFileList] = useState>([]); + const [uploadTargetDir, setUploadTargetDir] = useState(null); + const [openDialog, setOpenDialog] = useState(false); + + const isOverWindow = useDragAndDrop(null, { + preventDefault: true, + }); + const { mutateAsync, reset, ...props } = useFileUpload(); + + return ( + { + if (!uploadTargetDir) { + throw new Error('No target directory set for upload'); + } + return await mutateAsync(fileList, uploadTargetDir); + }, + reset: () => { + reset(); + setFileList([]); + setUploadTargetDir(null); + }, + uploadTargetDir, + setUploadTargetDir, + openDialog, + setOpenDialog, + isOverWindow, + }} + > + {children} + + {createPortal( + , + document.body + )} + + ); +} + +export function useFileUploadContext() { + const context = useContext(FileUploadContext); + if (!context) { + throw new Error( + 'useFileUploadContext must be used within a FileUploadProvider' + ); + } + return context; +} diff --git a/frontend/src/components/inbox/fileUpload/dialog.tsx b/frontend/src/components/inbox/fileUpload/dialog.tsx new file mode 100644 index 00000000..5ef7e9c0 --- /dev/null +++ b/frontend/src/components/inbox/fileUpload/dialog.tsx @@ -0,0 +1,504 @@ +/** Upload dialog + * + * Is triggered when files are dropped, there are multiple scenarios: + * ... + */ + +import { CheckIcon, FileMusicIcon, UploadIcon, XIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + alpha, + Box, + Button, + DialogContent, + IconButton, + LinearProgress, + TextField, + Typography, + useTheme, +} from '@mui/material'; + +import { useConfig } from '@/api/config'; +import { Dialog } from '@/components/common/dialogs'; +import { useDragAndDrop } from '@/components/common/hooks/useDrag'; +import { + CancelButton, + CancelButtonRef, +} from '@/components/common/inputs/cancle'; +import { humanizeBytes } from '@/components/common/units/bytes'; +import { humanizeDuration } from '@/components/common/units/time'; + +import { useFileUploadContext } from './context'; + +export function UploadDialog() { + const cancelButtonRef = useRef(null); + const { fileList, reset, openDialog, setOpenDialog } = + useFileUploadContext(); + + const resetDialog = useCallback( + (close: boolean = true) => { + if (close) setOpenDialog(false); + reset(); + }, + [reset, setOpenDialog] + ); + + // SM@PS: Try to not use 'useEffect' for derived state. Kinda an antipattern + // React does a good job at this stuff byitself. + let title = 'Upload files'; + if (fileList.length > 0) { + title = `Upload ${fileList.length} file${fileList.length !== 1 ? 's' : ''}`; + } + + // Open dialog when files are added + useEffect(() => { + if (fileList.length > 0) { + setOpenDialog(true); + } + }, [fileList.length, setOpenDialog]); + + return ( + { + if (reason !== 'backdropClick') resetDialog(); + }} + title_icon={null} + > + + + + + + + + + + { + // Reset dialog when successfully + // uploaded after 3 seconds + cancelButtonRef.current?.cancelWithTimer(3000); + }} + /> + + + + + ); +} + +/** Error and success message component + * + * Displays error or success messages based on the upload state. + */ +function ErrorMessage() { + const { isError, error } = useFileUploadContext(); + + if (!isError) return null; + + return ( + + + {error?.name || 'Error'} + + + {error?.message || + 'An unknown error occurred during file upload.'} + + + ); +} + +/** Selected files list component + * + * This component displays the list of files selected for upload. + * It allows users to remove files from the list before uploading. + */ +function SelectedFilesListAndProgress() { + const { fileList, setFileList } = useFileUploadContext(); + + const handleRemoveFile = (index: number) => { + // Remove file from the selected files + setFileList((prevFiles) => prevFiles.filter((_, i) => i !== index)); + }; + + if (fileList.length === 0) { + return ( + + No files selected for upload. + + ); + } + + return ( + <> + + Files: + + + {fileList.map((file, index) => ( + handleRemoveFile(index)} + key={index} + /> + ))} + + + ); +} + +/** Shows a singular file and allow to remove it + * Also shows upload progress if available + */ +function FileProgressBar({ + file, + removeFile, +}: { + file: string; + removeFile: () => void; +}) { + const theme = useTheme(); + const { uploadProgress, isIdle, isPending } = useFileUploadContext(); + + const fileProgress = useMemo(() => { + return uploadProgress.files.find((f) => f.name === file); + }, [uploadProgress, file]); + + let percent = fileProgress?.total + ? (fileProgress.loaded / fileProgress.total) * 100 + : 0; + + // Workaround for files with 0 bytes + if (fileProgress?.total == 0 && fileProgress?.finished) { + percent = 100; + } + + return ( + + + + + + + {file} + + + + {fileProgress && isPending && ( + + {percent.toFixed(0)}% + + )} + {fileProgress?.finished && ( + + )} + {isIdle && !fileProgress?.finished && ( + + )} + + + + ); +} + +/** Folder selector component + * This component allows users to specify a subfolder within the selected inbox folder + * where the files will be uploaded. + */ +function FolderSelector() { + const { uploadTargetDir, setUploadTargetDir, isIdle } = + useFileUploadContext(); + const config = useConfig(); + + const is_valid = useMemo(() => { + if (!uploadTargetDir) return true; + for (const folder of Object.values(config.gui.inbox.folders)) { + if (uploadTargetDir?.startsWith(folder.path)) return true; + } + return false; + }, [config.gui.inbox.folders, uploadTargetDir]); + + return ( + setUploadTargetDir(e.target.value)} + placeholder="Enter folder name" + size="small" + sx={{ + input: { + fontFamily: 'monospace', + letterSpacing: '0.00938em', + }, + }} + /> + ); +} + +/** Upload button component + * This component displays a button to start the upload process. + * It shows the number of selected files and the target upload path. + */ +function UploadButton({ onUpload }: { onUpload: () => void }) { + const { uploadFiles, isError, isSuccess, isPending, reset, fileList } = + useFileUploadContext(); + + if (isError) return null; + + return ( + + ); +} + +/* -------------------------------- Dropzone -------------------------------- */ + +function FileDropZone() { + const theme = useTheme(); + const { fileList, setFileList, isSuccess, isError } = + useFileUploadContext(); + const ref = useRef(null); + const fileInputRef = useRef(null); + + const isDragOver = useDragAndDrop(ref, { + onDrop: (event) => { + if (!event.dataTransfer) return; + setFileList((prevFiles) => { + if (!event.dataTransfer) return prevFiles; + return [...prevFiles, ...Array.from(event.dataTransfer.files)]; + }); + }, + }); + + // Don't show dropzone when we are done, + // space is taken by upload finished component + if (isSuccess || isError) return null; + + return ( + ({ + border: '2px dashed', + paddingInline: 4, + paddingBlock: 2, + textAlign: 'center', + borderRadius: 1, + gap: 2, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + // On drag over animate the border and slight lift the box + borderColor: isDragOver + ? theme.palette.primary.contrastText + : theme.palette.primary.muted, + boxShadow: isDragOver + ? `0px 4px 10px ${theme.palette.primary.main}33` + : 'none', + backgroundColor: isDragOver + ? theme.palette.primary.muted + : theme.palette.background.paper, + transition: + 'border-color 0.2s, box-shadow 0.2s, background-color 0.2s', + + '&:hover': { + cursor: 'pointer', + backgroundColor: alpha(theme.palette.primary.main, 0.05), + }, + })} + onClick={() => fileInputRef.current?.click()} + > + { + // Add files to the context + const files = Array.from(e.target.files || []); + if (files.length > 0) { + setFileList((prevFiles) => [...prevFiles, ...files]); + } + }} + /> + + {fileList.length == 0 && ( + // big box when no files added yet. + <> + + + Drag and drop files here + + + or click to select files + + + + + + + Allowed file types: any + + + )} + + {fileList.length > 0 && ( + // otherwise smaller box + <> + + Add more files ... + + + )} + + ); +} + +/* ----------------------------- Upload Progress ---------------------------- */ + +function UploadFinished() { + const { isPending, isIdle, uploadProgress } = useFileUploadContext(); + + if ( + !uploadProgress || + !uploadProgress.started || + !uploadProgress.finished || + isPending || + isIdle + ) { + return null; + } + + return ( + ({ + border: '2px solid', + borderColor: theme.palette.primary.main, + color: theme.palette.primary.main, + paddingInline: 4, + paddingBlock: 2, + textAlign: 'center', + borderRadius: 1, + gap: 2, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + })} + > + + Uploaded {uploadProgress.files.length} file + {uploadProgress.files.length > 1 ? 's' : ''} ( + {humanizeBytes(uploadProgress.total)}) in{' '} + {humanizeDuration( + (uploadProgress.finished - uploadProgress.started) / 1000 + )} + + + ); +} diff --git a/frontend/src/components/inbox/fileUpload/dropzone.tsx b/frontend/src/components/inbox/fileUpload/dropzone.tsx new file mode 100644 index 00000000..2b8e313d --- /dev/null +++ b/frontend/src/components/inbox/fileUpload/dropzone.tsx @@ -0,0 +1,95 @@ +import { useRef } from 'react'; +import React from 'react'; +import { alpha, Box } from '@mui/material'; + +import { useDragAndDrop } from '@/components/common/hooks/useDrag'; +import { formatDate } from '@/components/common/units/time'; + +import { useFileUploadContext } from './context'; + +export function DropZone({ + children, + inboxDir, +}: { + children?: React.ReactNode; + inboxDir: string; +}) { + const ref = useRef(null); + const { isOverWindow, setFileList, setUploadTargetDir } = + useFileUploadContext(); + const isOverDropZone = useDragAndDrop(ref, { + onDrop: (event) => { + if (!event.dataTransfer) return; + setUploadTargetDir( + inboxDir + `/upload_${formatDate(new Date(), '%Y%m%d_%H%M%S')}` + ); + setFileList((prevFiles) => { + if (!event.dataTransfer) return prevFiles; + return [...prevFiles, ...Array.from(event.dataTransfer.files)]; + }); + }, + }); + + return ( + + {children} + {isOverDropZone && ( + ({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 2, + border: `2px dashed ${theme.palette.secondary.main}`, + backgroundColor: alpha( + theme.palette.secondary.main, + 0.1 + ), + backdropFilter: 'blur(1px)', + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '2rem', + fontWeight: 'bold', + color: theme.palette.secondary.main, + textShadow: `0 0 8px rgba(0,0,0,0.3)`, + zIndex: 10, + })} + > + Drop to upload + + )} + {!isOverDropZone && isOverWindow && ( + ({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 2, + border: `2px dashed ${theme.palette.primary.main}`, + backgroundColor: alpha( + theme.palette.primary.main, + 0.05 + ), + backdropFilter: 'blur(4px)', + margin: -0.5, + zIndex: 10, + })} + > + )} + + ); +} diff --git a/frontend/src/components/inbox/settings/actionButtonSettings.tsx b/frontend/src/components/inbox/settings/actionButtonSettings.tsx index ddddc0a6..4771d775 100644 --- a/frontend/src/components/inbox/settings/actionButtonSettings.tsx +++ b/frontend/src/components/inbox/settings/actionButtonSettings.tsx @@ -48,6 +48,7 @@ import { ACTIONS, DEFAULT_INBOX_FOLDER_FRONTEND_CONFIG, InboxFolderFrontendConfig, + useConfig, } from '@/api/config'; import { Dialog } from '@/components/common/dialogs'; @@ -775,11 +776,22 @@ function AddActionButton({ onAdd: (action: Action) => void; } & ButtonProps) { const theme = useTheme(); + const config = useConfig(); const [open, setOpen] = useState(false); - const defaultActions: Array = Object.entries(ACTIONS).map( - ([_, a]) => a - ); + function isEnabled([actionName, _action]: [string, Action]) { + switch (actionName) { + case 'import_terminal': + return config.gui.terminal.enabled; + default: + return true; + } + } + + const defaultActions: Array = Object.entries(ACTIONS) + .filter(isEnabled) + .map(([_, a]) => a); + const [action, setAction] = useState(defaultActions[0]); return ( diff --git a/frontend/src/pythonTypes.ts b/frontend/src/pythonTypes.ts index a77dbdba..e343760c 100644 --- a/frontend/src/pythonTypes.ts +++ b/frontend/src/pythonTypes.ts @@ -25,7 +25,7 @@ export interface SerializedProgressState { export interface Search { search_ids: Array; search_artist: null | string; - search_album: null | string; + search_name: null | string; } export interface LibraryStats { @@ -76,6 +76,55 @@ export interface FileSystemUpdate { event: 'file_system_update'; } +export interface MatchSectionSchema { + strong_rec_thresh: number; + medium_rec_thresh: number; +} + +export interface ImportDuplicateKeys { + album: Array | string; + item: Array | string; +} + +export interface ImportSection { + duplicate_action: 'ask' | 'keep' | 'merge' | 'remove' | 'skip'; + move: 'False'; + copy: 'True'; + duplicate_keys: ImportDuplicateKeys; +} + +export interface BeetsSchema { + gui: BeetsFlaskSchema; + directory: string; + ignore: Array; + plugins: Array; + import_: ImportSection; + match: MatchSectionSchema; +} + +export interface TerminalSectionSchema { + enabled: boolean; + start_path: string; +} + +export interface LibrarySectionSchema { + readonly: boolean; + artist_separators: Array; +} + +export interface InboxSectionSchema { + ignore: '_use_beets_ignore' | Array; + debounce_before_autotag: number; + folders: Record; +} + +export interface BeetsFlaskSchema { + inbox: InboxSectionSchema; + library: LibrarySectionSchema; + terminal: TerminalSectionSchema; + num_preview_workers: number; +} + export interface Archive extends FileSystemItem { is_album: boolean; } @@ -180,6 +229,13 @@ export interface SerializedException { trace?: null | string; } +export interface InboxFolderSchema { + path: string; + name: string; + auto_threshold: null | number; + autotag: 'auto' | 'bootleg' | 'off' | 'preview'; +} + export interface Metadata { artist: null | string; album: null | string; diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 7a4b366a..f9c99cc8 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as LibrarySearchRouteImport } from './routes/library/search' import { Route as DebugSortable_multiRouteImport } from './routes/debug/sortable_multi' import { Route as DebugSortableRouteImport } from './routes/debug/sortable' import { Route as DebugJobsRouteImport } from './routes/debug/jobs' +import { Route as DebugFile_dragRouteImport } from './routes/debug/file_drag' import { Route as DebugErrorRouteImport } from './routes/debug/error' import { Route as DebugAudioRouteImport } from './routes/debug/audio' import { Route as LibraryBrowseIndexRouteImport } from './routes/library/browse/index' @@ -91,6 +92,11 @@ const DebugJobsRoute = DebugJobsRouteImport.update({ path: '/debug/jobs', getParentRoute: () => rootRouteImport, } as any) +const DebugFile_dragRoute = DebugFile_dragRouteImport.update({ + id: '/debug/file_drag', + path: '/debug/file_drag', + getParentRoute: () => rootRouteImport, +} as any) const DebugErrorRoute = DebugErrorRouteImport.update({ id: '/debug/error', path: '/debug/error', @@ -212,15 +218,16 @@ export interface FileRoutesByFullPath { '/version': typeof VersionRoute '/debug/audio': typeof DebugAudioRoute '/debug/error': typeof DebugErrorRoute + '/debug/file_drag': typeof DebugFile_dragRoute '/debug/jobs': typeof DebugJobsRoute '/debug/sortable': typeof DebugSortableRoute '/debug/sortable_multi': typeof DebugSortable_multiRoute '/library/search': typeof LibrarySearchRoute '/': typeof FrontpageIndexRoute - '/debug': typeof DebugIndexRoute - '/inbox': typeof InboxIndexRoute - '/sessiondraft': typeof SessiondraftIndexRoute - '/terminal': typeof TerminalIndexRoute + '/debug/': typeof DebugIndexRoute + '/inbox/': typeof InboxIndexRoute + '/sessiondraft/': typeof SessiondraftIndexRoute + '/terminal/': typeof TerminalIndexRoute '/library/browse/artists': typeof LibraryBrowseArtistsRouteRouteWithChildren '/debug/design/buttons': typeof DebugDesignButtonsRoute '/debug/design/icons': typeof DebugDesignIconsRoute @@ -228,7 +235,7 @@ export interface FileRoutesByFullPath { '/inbox/folder/$path': typeof InboxFolderPathRoute '/inbox/task/$taskId': typeof InboxTaskTaskIdRoute '/library/browse/albums': typeof LibraryBrowseAlbumsRoute - '/library/browse': typeof LibraryBrowseIndexRoute + '/library/browse/': typeof LibraryBrowseIndexRoute '/library/album/$albumId': typeof LibraryresourcesAlbumAlbumIdRouteRouteWithChildren '/library/item/$itemId': typeof LibraryresourcesItemItemIdRouteRouteWithChildren '/inbox/folder/$path/$hash': typeof InboxFolderPathHashRoute @@ -245,6 +252,7 @@ export interface FileRoutesByTo { '/version': typeof VersionRoute '/debug/audio': typeof DebugAudioRoute '/debug/error': typeof DebugErrorRoute + '/debug/file_drag': typeof DebugFile_dragRoute '/debug/jobs': typeof DebugJobsRoute '/debug/sortable': typeof DebugSortableRoute '/debug/sortable_multi': typeof DebugSortable_multiRoute @@ -276,6 +284,7 @@ export interface FileRoutesById { '/version': typeof VersionRoute '/debug/audio': typeof DebugAudioRoute '/debug/error': typeof DebugErrorRoute + '/debug/file_drag': typeof DebugFile_dragRoute '/debug/jobs': typeof DebugJobsRoute '/debug/sortable': typeof DebugSortableRoute '/debug/sortable_multi': typeof DebugSortable_multiRoute @@ -311,15 +320,16 @@ export interface FileRouteTypes { | '/version' | '/debug/audio' | '/debug/error' + | '/debug/file_drag' | '/debug/jobs' | '/debug/sortable' | '/debug/sortable_multi' | '/library/search' | '/' - | '/debug' - | '/inbox' - | '/sessiondraft' - | '/terminal' + | '/debug/' + | '/inbox/' + | '/sessiondraft/' + | '/terminal/' | '/library/browse/artists' | '/debug/design/buttons' | '/debug/design/icons' @@ -327,7 +337,7 @@ export interface FileRouteTypes { | '/inbox/folder/$path' | '/inbox/task/$taskId' | '/library/browse/albums' - | '/library/browse' + | '/library/browse/' | '/library/album/$albumId' | '/library/item/$itemId' | '/inbox/folder/$path/$hash' @@ -344,6 +354,7 @@ export interface FileRouteTypes { | '/version' | '/debug/audio' | '/debug/error' + | '/debug/file_drag' | '/debug/jobs' | '/debug/sortable' | '/debug/sortable_multi' @@ -374,6 +385,7 @@ export interface FileRouteTypes { | '/version' | '/debug/audio' | '/debug/error' + | '/debug/file_drag' | '/debug/jobs' | '/debug/sortable' | '/debug/sortable_multi' @@ -408,6 +420,7 @@ export interface RootRouteChildren { VersionRoute: typeof VersionRoute DebugAudioRoute: typeof DebugAudioRoute DebugErrorRoute: typeof DebugErrorRoute + DebugFile_dragRoute: typeof DebugFile_dragRoute DebugJobsRoute: typeof DebugJobsRoute DebugSortableRoute: typeof DebugSortableRoute DebugSortable_multiRoute: typeof DebugSortable_multiRoute @@ -442,28 +455,28 @@ declare module '@tanstack/react-router' { '/terminal/': { id: '/terminal/' path: '/terminal' - fullPath: '/terminal' + fullPath: '/terminal/' preLoaderRoute: typeof TerminalIndexRouteImport parentRoute: typeof rootRouteImport } '/sessiondraft/': { id: '/sessiondraft/' path: '/sessiondraft' - fullPath: '/sessiondraft' + fullPath: '/sessiondraft/' preLoaderRoute: typeof SessiondraftIndexRouteImport parentRoute: typeof rootRouteImport } '/inbox/': { id: '/inbox/' path: '/inbox' - fullPath: '/inbox' + fullPath: '/inbox/' preLoaderRoute: typeof InboxIndexRouteImport parentRoute: typeof rootRouteImport } '/debug/': { id: '/debug/' path: '/debug' - fullPath: '/debug' + fullPath: '/debug/' preLoaderRoute: typeof DebugIndexRouteImport parentRoute: typeof rootRouteImport } @@ -502,6 +515,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DebugJobsRouteImport parentRoute: typeof rootRouteImport } + '/debug/file_drag': { + id: '/debug/file_drag' + path: '/debug/file_drag' + fullPath: '/debug/file_drag' + preLoaderRoute: typeof DebugFile_dragRouteImport + parentRoute: typeof rootRouteImport + } '/debug/error': { id: '/debug/error' path: '/debug/error' @@ -519,7 +539,7 @@ declare module '@tanstack/react-router' { '/library/browse/': { id: '/library/browse/' path: '/library/browse' - fullPath: '/library/browse' + fullPath: '/library/browse/' preLoaderRoute: typeof LibraryBrowseIndexRouteImport parentRoute: typeof rootRouteImport } @@ -713,6 +733,7 @@ const rootRouteChildren: RootRouteChildren = { VersionRoute: VersionRoute, DebugAudioRoute: DebugAudioRoute, DebugErrorRoute: DebugErrorRoute, + DebugFile_dragRoute: DebugFile_dragRoute, DebugJobsRoute: DebugJobsRoute, DebugSortableRoute: DebugSortableRoute, DebugSortable_multiRoute: DebugSortable_multiRoute, diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 20a100bd..4735aab6 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -72,7 +72,6 @@ function RootComponent() { id="main-content" sx={(theme) => ({ [theme.breakpoints.down('laptop')]: { - position: 'fixed', height: '100dvh', }, width: '100%', diff --git a/frontend/src/routes/debug/file_drag.tsx b/frontend/src/routes/debug/file_drag.tsx new file mode 100644 index 00000000..01c2dac5 --- /dev/null +++ b/frontend/src/routes/debug/file_drag.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import React from 'react'; +import { Box } from '@mui/material'; +import { useMutation } from '@tanstack/react-query'; +import { createFileRoute } from '@tanstack/react-router'; + +import { fileUploadMutationOptions } from '@/api/fileUpload'; + +export const Route = createFileRoute('/debug/file_drag')({ + component: RouteComponent, +}); + +function RouteComponent() { + return ( + + + + ); +} + +function DropZone({ children }: { children?: React.ReactNode }) { + const ref = useRef(null); + const [isOverZone, setIsOverZone] = useState(false); + const [isOverWindow, setIsOverWindow] = useState(false); + + const resetDragState = useCallback(() => { + setIsOverZone(false); + setIsOverWindow(false); + }, []); + + const { mutate } = useMutation(fileUploadMutationOptions); + + useEffect(() => { + if (!ref.current) return; + + const dropzoneEl = ref.current; + const abortController = new AbortController(); + + const handleDragOver = (event: DragEvent) => { + setIsOverZone(true); + event.preventDefault(); + console.log('File(s) in drop zone'); + // Optionally, you can add visual feedback here + }; + + const handleDrop = (event: DragEvent) => { + event.preventDefault(); + resetDragState(); + const files = event.dataTransfer?.files; + if (files && files.length > 0) { + console.log('Dropped files:', files); + mutate({ + files: files, + targetDir: '/music/upload/', + setProgress: () => {}, + }); // Upload all files + } + }; + + // Dropzone related drag events + dropzoneEl.addEventListener('dragover', handleDragOver, { + signal: abortController.signal, + }); + dropzoneEl.addEventListener('drop', handleDrop, { + signal: abortController.signal, + }); + dropzoneEl.addEventListener('dragleave', () => setIsOverZone(false), { + signal: abortController.signal, + }); + + // Windows level drag events + window.addEventListener('dragover', () => setIsOverWindow(true), { + signal: abortController.signal, + }); + window.addEventListener('dragend', () => resetDragState(), { + signal: abortController.signal, + }); + window.addEventListener('dragleave', () => setIsOverWindow(false), { + signal: abortController.signal, + }); + window.addEventListener('drop', (e) => e.preventDefault(), { + signal: abortController.signal, + }); + + return () => { + // unregister event listeners on cleanup + abortController.abort(); + }; + }, [ref, setIsOverZone, setIsOverWindow, resetDragState, mutate]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/routes/inbox/index.tsx b/frontend/src/routes/inbox/index.tsx index 231b08dd..080710a8 100644 --- a/frontend/src/routes/inbox/index.tsx +++ b/frontend/src/routes/inbox/index.tsx @@ -11,7 +11,7 @@ import { import { useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; -import { Action } from '@/api/config'; +import { Action, useConfig } from '@/api/config'; import { inboxQueryOptions } from '@/api/inbox'; import { MatchChip, StyledChip } from '@/components/common/chips'; import { Dialog } from '@/components/common/dialogs'; @@ -28,6 +28,8 @@ import { } from '@/components/inbox/actions/buttons'; import { getActionDescription } from '@/components/inbox/actions/descriptions'; import { InboxCard } from '@/components/inbox/cards/inboxCard'; +import { FileUploadProvider } from '@/components/inbox/fileUpload/context'; +import { DropZone } from '@/components/inbox/fileUpload/dropzone'; import { FolderSelectionProvider } from '@/components/inbox/folderSelectionContext'; import { Folder } from '@/pythonTypes'; @@ -44,39 +46,44 @@ function RouteComponent() { const { data: inboxes } = useSuspenseQuery(inboxQueryOptions()); return ( - <> - ({ + ({ + display: 'flex', + flexDirection: 'column', + minHeight: '100%', + alignItems: 'center', + paddingTop: theme.spacing(1), + paddingInline: theme.spacing(0.5), + [theme.breakpoints.up('laptop')]: { + height: 'auto', + paddingTop: theme.spacing(2), + paddingInline: theme.spacing(1), + }, + })} + > + + - - + {inboxes.map((folder) => ( - + + + ))} - - - + + + ); } @@ -121,7 +128,6 @@ function PageHeader({ inboxes, ...props }: { inboxes: Folder[] } & BoxProps) { alignSelf: 'center', display: 'flex', gap: 1, - zIndex: 1, borderRadius: 1, color: 'secondary.muted', gridColumn: '1', @@ -136,7 +142,6 @@ function PageHeader({ inboxes, ...props }: { inboxes: Folder[] } & BoxProps) { alignSelf: 'center', display: 'flex', gap: 1, - zIndex: 1, borderRadius: 1, color: 'secondary.muted', gridColumn: '1', @@ -153,6 +158,8 @@ function PageHeader({ inboxes, ...props }: { inboxes: Folder[] } & BoxProps) { /** Description of the inbox page, shown as modal on click */ function InfoDescription() { const theme = useTheme(); + const config = useConfig(); + const [open, setOpen] = useState(false); const { data } = useSuspenseQuery(inboxQueryOptions()); @@ -252,10 +259,13 @@ function InfoDescription() { - + {config.gui.terminal.enabled ?? ( + + )} + {/* Tree view */} @@ -308,6 +318,29 @@ function InfoDescription() { Music album (identified as such by beets) + + + + + Archive file (identified as such by beets) + + + + Terminal Disabled + + The terminal is not enabled in the server configuration. + + + + ); + } + return ( - {config.data_sources.map((source) => ( + {config.beets_metadata_sources.map((source) => ( { "^/api_v1/.*": { target: "http://localhost:5001", changeOrigin: true, + // proxyTimeout: 60000, // 60 seconds, possibly needed for file uploads + // timeout: 60000, }, "^/socket.io/.*": { target: "http://localhost:5001",