diff --git a/CLAUDE.md b/CLAUDE.md index 0af68ba..8d00406 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,11 @@ A Python library and CLI tool for Spotify and Last.FM API interaction. Focuses o - `spotfm/sqlite.py` - SQLite singleton connection management - `spotfm/utils.py` - Config, caching, string sanitization +**Layered Separation Rule (core vs. presentation):** +- **Core modules** (`spotfm/spotify/`, `spotfm/lastfm.py`): return structured data (dicts/lists), use `logging` for observability, **never call `print()`** +- **Presentation layer** (`spotfm/cli.py`, `spotfm/web/`): format and display data; **never own business logic** +- Shared orchestration logic used by both CLI and web belongs in the core module, not duplicated in each caller + ## Code Style & Requirements - **Python**: 3.14+ (PEP 758 bracketless exception syntax) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4c69095 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.14-slim +WORKDIR /app +RUN pip install --no-cache-dir uv +COPY pyproject.toml LICENSE README.md ./ +COPY spotfm/ spotfm/ +RUN uv pip install --no-cache-dir --system ".[web]" +EXPOSE 8000 +CMD ["uvicorn", "spotfm.web.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5a5b87b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + spotfm-web: + build: . + ports: + - "8000:8000" + volumes: + - ~/.spotfm:/root/.spotfm + - ~/.cache/spotfm:/root/.cache/spotfm + restart: unless-stopped diff --git a/pyproject.toml b/pyproject.toml index d75632f..f9548f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,12 +28,21 @@ dev = [ "ruff", "twine", ] +web = [ + "fastapi>=0.136.3", + "httpx>=0.28.1", + "itsdangerous>=2.2.0", + "jinja2>=3.1.6", + "python-multipart>=0.0.30", + "uvicorn[standard]>=0.48.0", +] [project.urls] homepage = "https://github.com/jmlrt/spotfm" [project.scripts] spfm = "spotfm.cli:main" +spfm-web = "spotfm.web.app:run" [tool.ruff] line-length = 120 @@ -66,6 +75,7 @@ skip-magic-trailing-comma = false line-ending = "auto" [tool.pytest.ini_options] +asyncio_mode = "auto" testpaths = ["tests"] python_files = ["test_*.py"] python_classes = ["Test*"] @@ -109,3 +119,8 @@ exclude_lines = [ "if __name__ == .__main__.:", "if TYPE_CHECKING:", ] + +[dependency-groups] +dev = [ + "pytest-asyncio>=1.4.0", +] diff --git a/spotfm.example.toml b/spotfm.example.toml index 02fc713..a8a318e 100644 --- a/spotfm.example.toml +++ b/spotfm.example.toml @@ -1,3 +1,6 @@ +[web] +api_key = "" # any random string; required to access the webapp + [lastfm] api_key = "" api_secret = "" diff --git a/spotfm/cli.py b/spotfm/cli.py index 8ce1df6..05069de 100644 --- a/spotfm/cli.py +++ b/spotfm/cli.py @@ -8,7 +8,7 @@ from logging.handlers import RotatingFileHandler from spotfm import lastfm, utils -from spotfm.lastfm import read_lastfm_state, save_lastfm_state +from spotfm.lastfm import fetch_recent_scrobbles, read_lastfm_state from spotfm.spotify import client as spotify_client from spotfm.spotify import constants as spotify_constants from spotfm.spotify import dupes as spotify_dupes @@ -31,44 +31,45 @@ def _non_negative_int(value): return ivalue -def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, interactive): - current_count = user.get_playcount() - scrobble_count_to_save = current_count # Track what state to save +def _format_track(track_dict): + """Format a track dict as 'artist - title - period_scrobbles - total_scrobbles - url'.""" + return f"{track_dict['artist']} - {track_dict['title']} - {track_dict['period_scrobbles']} - {track_dict['total_scrobbles']} - {track_dict['url']}" - state = read_lastfm_state() - if state is None: - # First run: initialize state with current count, fetch --limit scrobbles - print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.") - else: - # Subsequent runs: fetch all new scrobbles since last run - last_scrobble_count = None - if isinstance(state, dict): - last_scrobble_count = state.get("last_scrobble_count") - if not isinstance(last_scrobble_count, int): - print("Invalid previous state. Re-initializing scrobble tracking.") - save_lastfm_state(current_count) - return - computed_limit = current_count - last_scrobble_count - if computed_limit <= 0: - print("No new scrobbles since last run.") - save_lastfm_state(current_count) - return - # Fetch all new scrobbles on subsequent runs - limit = computed_limit - print(f"Fetching {limit} new scrobbles (was {last_scrobble_count}, now {current_count}).") - # Get scrobbles generator - scrobbles_gen = user.get_recent_tracks_scrobbles(limit, scrobbles_minimum, period, period_minimum) +def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, interactive, config): + # Check if this is first run BEFORE fetching (once fetch_recent_scrobbles runs, state is saved) + is_first_run = read_lastfm_state() is None - if interactive: - # Collect all results first so exceptions/logs surface before editor opens - scrobbles = list(scrobbles_gen) - lines = sorted(set(scrobbles)) - if not lines: - print("No results to open in editor.") - save_lastfm_state(scrobble_count_to_save) - return + # Fetch scrobbles with incremental state management (orchestration in lastfm module) + tracks, mode = fetch_recent_scrobbles( + user, config, limit=limit, scrobbles_minimum=scrobbles_minimum, period=period, period_minimum=period_minimum + ) + + # CLI-layer presentation: handle user feedback and interactive mode + if mode == "no_new": + print("No new scrobbles since last run.") + return + elif mode == "incremental": + state = read_lastfm_state() + last_count = state.get("last_scrobble_count") if isinstance(state, dict) else None + current_count = user.get_playcount() + if last_count: + print(f"Fetching {len(tracks)} new scrobbles (was {last_count}, now {current_count}).") + else: + print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.") + else: # mode == "full" + # First run shows initialization message; subsequent runs show fetch message + if is_first_run: + print(f"Initializing scrobble tracking. Fetching up to {limit} recent scrobbles.") + else: + print(f"Fetching {len(tracks)} recent scrobbles.") + + if not tracks: + print("No results found.") + return + if interactive: + lines = sorted(set(_format_track(s) for s in tracks)) editor = os.environ.get("VISUAL") or os.environ.get("EDITOR", "vim") editor_args = shlex.split(editor) with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8", newline="\n") as f: @@ -80,14 +81,12 @@ def recent_scrobbles(user, limit, scrobbles_minimum, period, period_minimum, int os.unlink(tmp) if result.returncode != 0: print(f"Editor command {' '.join(editor_args)} exited with status {result.returncode}.") - print("Not advancing Last.FM state; please fix the editor issue and retry.") + print("Last.FM state was already advanced; please fix the editor issue.") return else: # Stream output in non-interactive mode - for scrobble in scrobbles_gen: - print(scrobble) - - save_lastfm_state(scrobble_count_to_save) + for track in tracks: + print(_format_track(track)) def count_tracks(playlists_pattern=None): @@ -126,7 +125,7 @@ def lastfm_cli(args, config): if period_minimum is None: period_minimum = config.get("lastfm", {}).get("period_minimum") - recent_scrobbles(user, args.limit, scrobbles_minimum, args.period, period_minimum, args.interactive) + recent_scrobbles(user, args.limit, scrobbles_minimum, args.period, period_minimum, args.interactive, config.get("lastfm", {})) def spotify_cli(args, config): @@ -184,7 +183,10 @@ def spotify_cli(args, config): spotify_dupes.find_duplicate_names(excluded_playlist_ids=excluded, threshold=threshold) case "find-relinked-tracks": excluded = config["spotify"].get("excluded_playlists", []) - spotify_misc.find_relinked_tracks(client.client, excluded_playlist_ids=excluded, output_file=args.output) + relinked = spotify_misc.find_relinked_tracks(client.client, excluded_playlist_ids=excluded, output_file=args.output) + if not args.output: + for track in relinked: + print(f"Relinked - {track['playlist_name']} - {track['original_track']} -> {track['replacement_track']} - {track['original_id']} - {track['replacement_id']}") case "list-playlists-with-track-counts": playlists = spotify_misc.list_playlists_with_track_counts() total_entries = 0 diff --git a/spotfm/lastfm.py b/spotfm/lastfm.py index 8fb8080..374e656 100644 --- a/spotfm/lastfm.py +++ b/spotfm/lastfm.py @@ -125,9 +125,15 @@ def get_recent_tracks_scrobbles(self, limit=10, scrobbles_minimum=0, period=90, # Rate limiting: 0.2s between API calls (5 req/sec = Last.FM limit) sleep(0.2) - # Yield results (same format as before - backward compatible) + # Yield structured track data for callers to format as needed for track, period_scrobbles, total_scrobbles, url in tracks_data: - yield f"{track} - {period_scrobbles} - {total_scrobbles} - {url}" + yield { + "artist": track.artist, + "title": track.title, + "period_scrobbles": period_scrobbles, + "total_scrobbles": total_scrobbles, + "url": url, + } def read_lastfm_state(state_file=None): @@ -168,3 +174,63 @@ def save_lastfm_state(scrobble_count, state_file=None): with contextlib.suppress(OSError): tmp_path.unlink() logging.error(f"Failed to save Last.FM state file at {path}: {e}") + + +def fetch_recent_scrobbles(user, config, *, limit=None, scrobbles_minimum=None, period=90, period_minimum=None): + """Fetch recent scrobbles with incremental state management. + + Handles the orchestration of reading saved state, computing effective limits, + and saving state after fetch. This ensures consistent behavior across CLI and web. + + Args: + user: Last.FM User instance + config: Last.FM config dict with keys: limit, scrobbles_minimum, period_minimum + limit: Explicit limit override (if None, uses incremental mode with saved state) + scrobbles_minimum: Minimum total scrobbles filter (if None, uses config) + period: Period in days for scrobble counting (default 90) + period_minimum: Minimum scrobbles in period window (if None, uses config) + + Returns: + tuple: (tracks: list[dict], mode: str) where mode is "incremental", "full", or "no_new" + """ + current_count = user.get_playcount() + scrobble_count_to_save = current_count + + # Resolve config defaults + effective_limit = limit if limit is not None else config.get("limit", 50) + effective_scrobbles_minimum = ( + scrobbles_minimum if scrobbles_minimum is not None else config.get("scrobbles_minimum", 4) + ) + effective_period_minimum = period_minimum if period_minimum is not None else config.get("period_minimum") + + # Check for saved state first. If it exists, use incremental mode (ignore explicit limit) + state = read_lastfm_state() + mode = "full" + if state is not None and isinstance(state, dict): + last_scrobble_count = state.get("last_scrobble_count") + if isinstance(last_scrobble_count, int): + # Saved state exists: always use incremental mode, compute limit from diff + mode = "incremental" + computed_limit = current_count - last_scrobble_count + if computed_limit <= 0: + save_lastfm_state(current_count) + return [], "no_new" + effective_limit = computed_limit + else: + # No saved state: use full mode with provided limit or config default + mode = "full" if limit is not None else "full" + + # Fetch scrobbles + tracks = list( + user.get_recent_tracks_scrobbles( + limit=effective_limit, + scrobbles_minimum=effective_scrobbles_minimum, + period=period, + period_minimum=effective_period_minimum, + ) + ) + + # Save state after successful fetch + save_lastfm_state(scrobble_count_to_save) + + return tracks, mode diff --git a/spotfm/spotify/misc.py b/spotfm/spotify/misc.py index b715454..7b7e949 100644 --- a/spotfm/spotify/misc.py +++ b/spotfm/spotify/misc.py @@ -43,7 +43,6 @@ import csv import logging -import sys from datetime import datetime from pathlib import Path from time import sleep @@ -277,7 +276,7 @@ def discover_from_playlists(client, discover_playlist_id, sources_playlists_ids) total_playlists = len(sources_playlists_ids) for idx, playlist_id in enumerate(sources_playlists_ids, 1): - print(f"fetching playlist {playlist_id} ({idx}/{total_playlists})", file=sys.stderr, flush=True) + logging.info(f"Fetching playlist {playlist_id} ({idx}/{total_playlists})") playlist = Playlist.get_playlist(playlist_id, client.client, refresh=True, sync_to_db=False) logging.info(f"Looking for new tracks into {playlist.id} - {playlist.name}") @@ -323,9 +322,7 @@ def discover_from_playlists(client, discover_playlist_id, sources_playlists_ids) # Track exists and is in other playlists logging.debug(f"Skipping track {track.id} (already in playlists)") - print(f"discovered {new_this_playlist} new tracks from playlist {playlist.name}", file=sys.stderr, flush=True) - - print(f"total discovered from all playlists: {len(new_tracks)} new tracks", file=sys.stderr, flush=True) + logging.info(f"Discovered {new_this_playlist} new tracks from playlist {playlist.name}") if len(new_tracks) > 0: logging.info( @@ -473,11 +470,6 @@ def find_relinked_tracks(client, excluded_playlist_ids=None, output_file=None): # Output results if output_file: write_relinked_tracks_csv(relinked_tracks, output_file) - else: - for track in relinked_tracks: - print( - f"Relinked - {track['playlist_name']} - {track['original_track']} -> {track['replacement_track']} - {track['original_id']} - {track['replacement_id']}" - ) return relinked_tracks diff --git a/spotfm/spotify/playlist.py b/spotfm/spotify/playlist.py index 00bf60a..fd7b3e5 100644 --- a/spotfm/spotify/playlist.py +++ b/spotfm/spotify/playlist.py @@ -56,6 +56,7 @@ def __init__(self, playlist_id, client=None, refresh=True): self.tracks = None # [Track] after hydration; None until update_from_api() is called self.updated = None self.snapshot_id = None # Spotify snapshot ID to detect unchanged playlists + self.snapshot_unchanged = False # True when snapshot matched DB — skip redundant track sync # TODO: self._tracks_names # TODO: self._sorted_tracks @@ -141,6 +142,7 @@ def update_from_db(self): return True def update_from_api(self, client): + self.snapshot_unchanged = False # Reset before checking; instance may be reused across refreshes playlist = client.playlist(self.id, fields="name,owner.id,snapshot_id", market=MARKET) self.name = utils.sanitize_string(playlist["name"]) logging.info("Fetching playlist %s - %s from api", self.id, self.name) @@ -175,6 +177,7 @@ def update_from_api(self, client): self.tracks = self.get_tracks(client, sync_to_db=False) # Update the last-updated marker to reflect a successful refresh self.updated = str(date.today()) + self.snapshot_unchanged = True return self.snapshot_id = new_snapshot @@ -220,6 +223,12 @@ def sync_to_db(self, client): f"INSERT OR REPLACE INTO playlists (id, name, owner, updated_at) VALUES ('{self.id}', '{self.name}', '{self.owner}', '{self.updated}')" ) + if self.snapshot_unchanged: + # Playlist content didn't change — only update metadata, skip track re-sync + logging.info("Playlist %s - %s snapshot unchanged, skipping track sync", self.id, self.name) + sqlite.query_db(sqlite.DATABASE, queries) + return + # Delete all existing tracks for this playlist to handle removed tracks queries.append(f"DELETE FROM playlists_tracks WHERE playlist_id = '{self.id}'") # Sync all unique tracks first diff --git a/spotfm/sqlite.py b/spotfm/sqlite.py index 0c566d9..5c7c81e 100644 --- a/spotfm/sqlite.py +++ b/spotfm/sqlite.py @@ -36,13 +36,14 @@ import logging import re import sqlite3 +import threading from functools import lru_cache from spotfm import utils -# Global variables for the database connection -_db_connection = None -_current_database = None +# Per-thread database connections — each thread (event loop + executor workers) gets its own +# connection, eliminating shared-connection races without needing a global lock. +_local = threading.local() _migrated_databases = set() # Track which databases have been migrated @@ -167,30 +168,27 @@ def migrate_database_schema(database=None): def get_db_connection(database): - global _db_connection, _current_database # Run migration on first connection migrate_database_schema(database) # Convert to string for consistent comparison database_str = str(database) - # If database changed or no connection exists, create new connection - if _db_connection is None or _current_database != database_str: - # Close existing connection if it exists - if _db_connection is not None: - _db_connection.close() - _db_connection = sqlite3.connect(database) - _db_connection.create_function("REGEXP", 2, _regexp) - _db_connection.set_trace_callback(utils.DATABASE_LOG_LEVEL) - _current_database = database_str - return _db_connection + # If database changed or no connection exists, create new connection for this thread + if getattr(_local, "connection", None) is None or getattr(_local, "database", None) != database_str: + if getattr(_local, "connection", None) is not None: + _local.connection.close() + _local.connection = sqlite3.connect(database) + _local.connection.create_function("REGEXP", 2, _regexp) + _local.connection.set_trace_callback(utils.DATABASE_LOG_LEVEL) + _local.database = database_str + return _local.connection def close_db_connection(): - global _db_connection, _current_database - if _db_connection is not None: + if getattr(_local, "connection", None) is not None: logging.debug("Closing database connection") - _db_connection.close() - _db_connection = None - _current_database = None + _local.connection.close() + _local.connection = None + _local.database = None def _reset_migration_state_for_tests(): diff --git a/spotfm/web/__init__.py b/spotfm/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotfm/web/app.py b/spotfm/web/app.py new file mode 100644 index 0000000..e485258 --- /dev/null +++ b/spotfm/web/app.py @@ -0,0 +1,96 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +import uvicorn +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from spotfm import utils +from spotfm.spotify import client as spotify_client +from spotfm.web.auth import check_api_key, require_auth +from spotfm.web.routes import lastfm as lastfm_routes +from spotfm.web.routes import spotify as spotify_routes + +TEMPLATES_DIR = Path(__file__).parent / "templates" +STATIC_DIR = Path(__file__).parent / "static" + +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +def create_app(config_file=None): + config = utils.parse_config(config_file) if config_file else utils.parse_config() + + web_config = config.get("web", {}) + api_key = web_config.get("api_key", "") + if not api_key: + raise RuntimeError("[web] api_key is missing or empty in spotfm.toml") + + @asynccontextmanager + async def lifespan(app: FastAPI): + app.state.config = config + app.state.api_key = api_key + spotify_cfg = config.get("spotify", {}) + app.state.spotify_client = spotify_client.Client( + client_id=spotify_cfg["client_id"], + client_secret=spotify_cfg["client_secret"], + ) + yield + + app = FastAPI(lifespan=lifespan) + app.add_middleware(SessionMiddleware, secret_key=api_key, max_age=86400 * 7, https_only=False) + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + app.include_router(spotify_routes.router) + app.include_router(lastfm_routes.router) + + @app.get("/login", response_class=HTMLResponse) + async def login_page(request: Request): + return templates.TemplateResponse(request, "login.html", context={"error": None}) + + @app.post("/login") + async def login(request: Request, api_key_input: str = Form(alias="api_key")): + if check_api_key(api_key_input, request.app.state.api_key): + request.session["authenticated"] = True + next_url = request.query_params.get("next", "/") + # Validate next_url is a safe relative path (prevent open redirect) + # Also reject scheme-relative URLs like //evil.com + if not next_url.startswith("/") or next_url.startswith("//") or "://" in next_url: + next_url = "/" + return RedirectResponse(url=next_url, status_code=302) + return templates.TemplateResponse(request, "login.html", context={"error": "Invalid API key"}, status_code=401) + + @app.post("/logout") + async def logout(request: Request): + request.session.clear() + return RedirectResponse(url="/login", status_code=302) + + @app.get("/", response_class=HTMLResponse) + async def dashboard(request: Request): + redirect = await require_auth(request) + if redirect: + return redirect + from spotfm import sqlite + from spotfm.utils import DATABASE + + queries = { + "tracks": "SELECT COUNT(DISTINCT track_id) FROM playlists_tracks", + "playlists": "SELECT COUNT(*) FROM playlists", + "artists": "SELECT COUNT(DISTINCT ta.artist_id) FROM tracks_artists ta JOIN playlists_tracks pt ON ta.track_id = pt.track_id", + "albums": "SELECT COUNT(DISTINCT at.album_id) FROM albums_tracks at JOIN playlists_tracks pt ON at.track_id = pt.track_id", + } + counts = {} + for key, query in queries.items(): + row = sqlite.select_db(DATABASE, query).fetchone() + counts[key] = row[0] if row else 0 + return templates.TemplateResponse(request, "index.html", context={"counts": counts}) + + return app + + +def run(): + # Single worker is required for SQLite singleton connection safety + # see: spotfm/sqlite.py check_same_thread=False + uvicorn.run("spotfm.web.app:create_app", factory=True, host="0.0.0.0", port=8000, workers=1) diff --git a/spotfm/web/auth.py b/spotfm/web/auth.py new file mode 100644 index 0000000..5fcc382 --- /dev/null +++ b/spotfm/web/auth.py @@ -0,0 +1,21 @@ +import hmac + +from fastapi import Request +from fastapi.responses import RedirectResponse + + +def check_api_key(submitted: str, expected: str) -> bool: + return hmac.compare_digest(submitted, expected) + + +async def require_auth(request: Request): + if not request.session.get("authenticated"): + # Use only path + query, not the full URL (no scheme/host) + next_path = request.url.path + if request.url.query: + next_path += f"?{request.url.query}" + from urllib.parse import quote + + next_encoded = quote(next_path, safe="/") + return RedirectResponse(url=f"/login?next={next_encoded}", status_code=302) + return None diff --git a/spotfm/web/jobs.py b/spotfm/web/jobs.py new file mode 100644 index 0000000..d5b5c0f --- /dev/null +++ b/spotfm/web/jobs.py @@ -0,0 +1,100 @@ +import asyncio +import logging +import threading +import uuid +from dataclasses import dataclass, field +from enum import StrEnum + + +class JobStatus(StrEnum): + PENDING = "pending" + RUNNING = "running" + DONE = "done" + FAILED = "failed" + + +@dataclass +class Job: + id: str + name: str + status: JobStatus = JobStatus.PENDING + progress: list[str] = field(default_factory=list) + result: object = None + error: str | None = None + + +_jobs: dict[str, Job] = {} +_progress_lock = threading.Lock() +_active_jobs = 0 +_level_lock = threading.Lock() +_saved_root_level: int | None = None + + +def create_job(name: str) -> Job: + job = Job(id=str(uuid.uuid4()), name=name) + _jobs[job.id] = job + return job + + +def get_job(job_id: str) -> Job | None: + return _jobs.get(job_id) + + +def reset_jobs(): + _jobs.clear() + + +def get_running_job(name: str) -> Job | None: + for job in _jobs.values(): + if job.name == name and job.status in (JobStatus.PENDING, JobStatus.RUNNING): + return job + return None + + +def get_latest_job(name: str) -> Job | None: + """Return the most recently created job with this name, regardless of status.""" + matches = [j for j in _jobs.values() if j.name == name] + return matches[-1] if matches else None + + +async def run_job(job: Job, fn, *args, **kwargs): + global _active_jobs, _saved_root_level + job.status = JobStatus.RUNNING + + class JobLogHandler(logging.Handler): + def emit(self, record): + msg = self.format(record) + if msg.strip(): + with _progress_lock: + job.progress.append(msg) + + handler = JobLogHandler() + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + root_logger = logging.getLogger() + + # Refcount: first job lowers the root log level; last job to finish restores it + with _level_lock: + if _active_jobs == 0: + _saved_root_level = root_logger.level + if _saved_root_level == logging.NOTSET or _saved_root_level > logging.INFO: + root_logger.setLevel(logging.INFO) + _active_jobs += 1 + root_logger.addHandler(handler) + + def run(): + return fn(*args, **kwargs) + + loop = asyncio.get_running_loop() + try: + result = await loop.run_in_executor(None, run) + job.result = result + job.status = JobStatus.DONE + except Exception as e: + job.error = str(e) + job.status = JobStatus.FAILED + finally: + root_logger.removeHandler(handler) + with _level_lock: + _active_jobs -= 1 + if _active_jobs == 0 and _saved_root_level is not None: + root_logger.setLevel(_saved_root_level) diff --git a/spotfm/web/pagination.py b/spotfm/web/pagination.py new file mode 100644 index 0000000..f2c218e --- /dev/null +++ b/spotfm/web/pagination.py @@ -0,0 +1,56 @@ +from urllib.parse import urlencode + +PAGE_SIZE = 100 + + +def page_window(page: int, total_pages: int) -> list[int | None]: + """Page numbers to show, with None for ellipsis. Shows all if ≤10, else ±4 around current + first/last.""" + if total_pages <= 10: + return list(range(1, total_pages + 1)) + pages = sorted({1, total_pages, *range(max(1, page - 4), min(total_pages + 1, page + 5))}) + result: list[int | None] = [] + prev = None + for p in pages: + if prev is not None and p - prev > 1: + result.append(None) # ellipsis marker + result.append(p) + prev = p + return result + + +def paginate(items: list, page: int) -> tuple[list, dict]: + total = len(items) + total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = max(1, min(page, total_pages)) + start = (page - 1) * PAGE_SIZE + return items[start : start + PAGE_SIZE], { + "page": page, + "total_pages": total_pages, + "total": total, + "pages": page_window(page, total_pages), + } + + +def pagination_base_url(request) -> str: + params = {k: v for k, v in request.query_params.items() if k != "page"} + qs = urlencode(params) + return f"{request.url.path}?{qs}&" if qs else f"{request.url.path}?" + + +def make_sort_url(request, current_sort: str, current_dir: str): + """Return a callable `f(col)` that builds a sort URL for the given column.""" + + def _url(col: str) -> str: + new_dir = "desc" if current_sort == col and current_dir == "asc" else "asc" + params = {k: v for k, v in request.query_params.items() if k not in ("page", "sort", "dir")} + params["sort"] = col + params["dir"] = new_dir + return f"{request.url.path}?{urlencode(params)}" + + return _url + + +def sort_indicator(current_sort: str, current_dir: str, col: str) -> str: + if current_sort != col: + return "" + return " ↓" if current_dir == "desc" else " ↑" diff --git a/spotfm/web/routes/__init__.py b/spotfm/web/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/spotfm/web/routes/lastfm.py b/spotfm/web/routes/lastfm.py new file mode 100644 index 0000000..a63ec6e --- /dev/null +++ b/spotfm/web/routes/lastfm.py @@ -0,0 +1,108 @@ +from pathlib import Path + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from spotfm import lastfm +from spotfm.lastfm import fetch_recent_scrobbles, read_lastfm_state +from spotfm.web.auth import require_auth + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + +router = APIRouter() + +_FORM_CONTEXT = "form" + + +@router.get("/scrobbles", response_class=HTMLResponse) +async def scrobbles( + request: Request, + fetch: bool = Query(default=False), + limit: str = Query(default=""), + scrobbles_minimum: str = Query(default=""), + period: int = Query(default=90), + period_minimum: str = Query(default=""), +): + redirect = await require_auth(request) + if redirect: + return redirect + + limit_int: int | None = int(limit) if limit.strip().isdigit() else None + scrobbles_minimum_int: int | None = int(scrobbles_minimum) if scrobbles_minimum.strip().isdigit() else None + period_minimum_int: int | None = int(period_minimum) if period_minimum.strip().isdigit() else None + + config = request.app.state.config + lastfm_cfg = config.get("lastfm", {}) + + required_keys = ["api_key", "api_secret", "username", "password_hash"] + if any(not lastfm_cfg.get(k) for k in required_keys): + return templates.TemplateResponse( + request, + "scrobbles.html", + context={"state": _FORM_CONTEXT, "error": "Last.FM not configured", "state_info": None, "cfg": lastfm_cfg}, + ) + + # Read current state for display in the form + state_file = read_lastfm_state() + last_count = (state_file or {}).get("last_scrobble_count") if isinstance(state_file, dict) else None + + if not fetch: + return templates.TemplateResponse( + request, + "scrobbles.html", + context={ + "state": _FORM_CONTEXT, + "error": None, + "state_info": {"last_scrobble_count": last_count} if isinstance(last_count, int) else None, + "cfg": lastfm_cfg, + "defaults": { + "period": lastfm_cfg.get("period", 90), + "scrobbles_minimum": lastfm_cfg.get("scrobbles_minimum", 4), + "period_minimum": lastfm_cfg.get("period_minimum", ""), + "limit": "", + }, + }, + ) + + # --- Fetch path --- + lastfm_client = lastfm.Client( + api_key=lastfm_cfg["api_key"], + api_secret=lastfm_cfg["api_secret"], + username=lastfm_cfg["username"], + password_hash=lastfm_cfg["password_hash"], + ) + user = lastfm.User(lastfm_client.client) + + # Fetch scrobbles with incremental state management (orchestration in lastfm module) + tracks, mode = fetch_recent_scrobbles( + user, + lastfm_cfg, + limit=limit_int, + scrobbles_minimum=scrobbles_minimum_int, + period=period, + period_minimum=period_minimum_int, + ) + + if mode == "no_new": + return templates.TemplateResponse( + request, + "scrobbles.html", + context={"state": "no_new", "error": None, "state_info": None, "cfg": lastfm_cfg}, + ) + + # Default order: descending by period scrobbles + tracks.sort(key=lambda r: r["period_scrobbles"], reverse=True) + + return templates.TemplateResponse( + request, + "scrobbles.html", + context={ + "state": "results", + "error": None, + "tracks": tracks, + "period": period, + "incremental": mode == "incremental", + }, + ) diff --git a/spotfm/web/routes/spotify.py b/spotfm/web/routes/spotify.py new file mode 100644 index 0000000..97e9e34 --- /dev/null +++ b/spotfm/web/routes/spotify.py @@ -0,0 +1,371 @@ +import asyncio +import csv +from pathlib import Path + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +from spotfm.spotify import dupes as spotify_dupes +from spotfm.spotify import misc as spotify_misc +from spotfm.web.auth import require_auth +from spotfm.web.jobs import JobStatus, create_job, get_job, get_latest_job, get_running_job, run_job +from spotfm.web.pagination import make_sort_url, paginate, pagination_base_url, sort_indicator + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + +router = APIRouter() + + +@router.get("/playlists", response_class=HTMLResponse) +async def playlists( + request: Request, + page: int = Query(default=1, ge=1), + sort: str = Query(default="name"), + dir: str = Query(default="asc"), +): + redirect = await require_auth(request) + if redirect: + return redirect + rows = spotify_misc.list_playlists_with_track_counts() + # rows are (name, pid, count) tuples + sort_dir = dir if dir in ("asc", "desc") else "asc" + key = {"name": 0, "count": 2}.get(sort, 0) + if sort == "count": + rows = sorted(rows, key=lambda r: r[key] or 0, reverse=(sort_dir == "desc")) + else: + rows = sorted(rows, key=lambda r: r[key] or "", reverse=(sort_dir == "desc")) + page_rows, pagination = paginate(rows, page) + sort_fn = make_sort_url(request, sort, sort_dir) + ind = lambda col: sort_indicator(sort, sort_dir, col) # noqa: E731 + return templates.TemplateResponse( + request, + "playlists.html", + context={ + "playlists": page_rows, + "pagination": pagination, + "base_url": pagination_base_url(request), + "sort_url": sort_fn, + "ind": ind, + }, + ) + + +@router.get("/tracks", response_class=HTMLResponse) +async def tracks( + request: Request, + playlist: str = "", + artist: str = "", + album: str = "", + year_start: str = "", + year_end: str = "", + page: int = Query(default=1, ge=1), + sort: str = Query(default="name"), + dir: str = Query(default="asc"), +): + redirect = await require_auth(request) + if redirect: + return redirect + + from spotfm import sqlite + from spotfm.utils import DATABASE + + all_playlists = sqlite.select_db(DATABASE, "SELECT id, name FROM playlists ORDER BY name COLLATE NOCASE").fetchall() + + start_date = f"{year_start}-01-01" if year_start else None + end_date = f"{year_end}-12-31" if year_end else None + playlist_patterns = playlist if playlist else "%" + raw = spotify_misc.find_tracks_by_criteria( + playlist_patterns=playlist_patterns, + start_date=start_date, + end_date=end_date, + ) + track_rows = [] + for row in raw: + artist_names = row["artist_names"] + album_name = row["album_name"] + if artist and artist.lower() not in (artist_names or "").lower(): + continue + if album and album.lower() not in (album_name or "").lower(): + continue + track_rows.append( + { + "id": row["track_id"], + "name": row["track_name"], + "year": row["release_year"], + "album": album_name, + "artists": artist_names, + } + ) + + has_filters = bool(playlist or artist or album or year_start or year_end) + + # Sort before paginating + sort_dir = dir if dir in ("asc", "desc") else "asc" + valid_sorts = {"name", "artists", "album", "year"} + sort_key = sort if sort in valid_sorts else "name" + track_rows.sort(key=lambda r: r.get(sort_key) or "", reverse=(sort_dir == "desc")) + + page_rows, pagination = paginate(track_rows, page) + + # Batch-fetch playlists for just the current page (efficient: 1 query for ≤100 tracks) + if page_rows: + ids = [r["id"] for r in page_rows] + placeholders = ",".join(["?"] * len(ids)) + pl_rows = sqlite.select_db( + DATABASE, + f"SELECT pt.track_id, p.id, p.name FROM playlists_tracks pt JOIN playlists p ON pt.playlist_id = p.id WHERE pt.track_id IN ({placeholders}) ORDER BY p.name", + ids, + ).fetchall() + track_playlists: dict[str, list[tuple[str, str]]] = {} + for tid, pid, pname in pl_rows: + track_playlists.setdefault(tid, []).append((pid, pname)) + for r in page_rows: + r["track_playlists"] = track_playlists.get(r["id"], []) + + sort_fn = make_sort_url(request, sort_key, sort_dir) + ind = lambda col: sort_indicator(sort_key, sort_dir, col) # noqa: E731 + + return templates.TemplateResponse( + request, + "tracks.html", + context={ + "tracks": page_rows, + "all_playlists": all_playlists, + "has_filters": has_filters, + "pagination": pagination, + "base_url": pagination_base_url(request), + "sort_url": sort_fn, + "ind": ind, + "filters": { + "playlist": playlist, + "artist": artist, + "album": album, + "year_start": year_start, + "year_end": year_end, + }, + }, + ) + + +@router.get("/duplicates", response_class=HTMLResponse) +async def duplicates_page(request: Request): + redirect = await require_auth(request) + if redirect: + return redirect + job = get_latest_job("dupe-names") + return templates.TemplateResponse(request, "duplicates.html", context={"job": job, "JobStatus": JobStatus}) + + +@router.get("/duplicates/ids", response_class=HTMLResponse) +async def duplicates_ids( + request: Request, + page: int = Query(default=1, ge=1), + sort: str = Query(default="track_name"), + dir: str = Query(default="asc"), +): + redirect = await require_auth(request) + if redirect: + return redirect + config = request.app.state.config + excluded = config.get("spotify", {}).get("excluded_playlists", []) + dupes = spotify_dupes.find_duplicate_ids(excluded_playlist_ids=excluded) + + sort_dir = dir if dir in ("asc", "desc") else "asc" + valid_sorts = {"track_name", "artists", "playlists"} + sort_key = sort if sort in valid_sorts else "track_name" + dupes.sort(key=lambda r: (r.get(sort_key) or "").lower(), reverse=(sort_dir == "desc")) + + page_dupes, pagination = paginate(dupes, page) + sort_fn = make_sort_url(request, sort_key, sort_dir) + ind = lambda col: sort_indicator(sort_key, sort_dir, col) # noqa: E731 + return templates.TemplateResponse( + request, + "duplicates_ids.html", + context={ + "dupes": page_dupes, + "pagination": pagination, + "base_url": pagination_base_url(request), + "sort_url": sort_fn, + "ind": ind, + }, + ) + + +@router.get("/duplicates/names", response_class=HTMLResponse) +async def duplicates_names_results( + request: Request, + page: int = Query(default=1, ge=1), + sort: str = Query(default="score"), + dir: str = Query(default="desc"), +): + redirect = await require_auth(request) + if redirect: + return redirect + job = get_latest_job("dupe-names") + if not job or not job.result: + return RedirectResponse(url="/duplicates", status_code=302) + + result = list(job.result) + sort_dir = dir if dir in ("asc", "desc") else "desc" + valid_sorts = {"track1", "artists1", "track2", "artists2", "score"} + sort_key = sort if sort in valid_sorts else "score" + if sort_key == "score": + result.sort(key=lambda r: r.get("score") or 0, reverse=(sort_dir == "desc")) + else: + result.sort(key=lambda r: (r.get(sort_key) or "").lower(), reverse=(sort_dir == "desc")) + + page_result, pagination = paginate(result, page) + sort_fn = make_sort_url(request, sort_key, sort_dir) + ind = lambda col: sort_indicator(sort_key, sort_dir, col) # noqa: E731 + return templates.TemplateResponse( + request, + "duplicates_names.html", + context={ + "pairs": page_result, + "pagination": pagination, + "base_url": pagination_base_url(request), + "sort_url": sort_fn, + "ind": ind, + }, + ) + + +@router.post("/duplicates/names") +async def duplicates_names_start(request: Request): + redirect = await require_auth(request) + if redirect: + return redirect + + # Block only if already running; allow re-run when done/failed + existing = get_running_job("dupe-names") + if existing: + return RedirectResponse(url="/duplicates", status_code=302) + + config = request.app.state.config + excluded = config.get("spotify", {}).get("excluded_playlists", []) + job = create_job("dupe-names") + + def _find_dupe_names(**kwargs): + return spotify_dupes.find_duplicate_names(**kwargs) + + asyncio.create_task(run_job(job, _find_dupe_names, excluded_playlist_ids=excluded)) # noqa: RUF006 + return RedirectResponse(url="/duplicates", status_code=302) + + +@router.post("/jobs/update-playlists") +async def start_update_playlists(request: Request): + redirect = await require_auth(request) + if redirect: + return redirect + + existing = get_running_job("update-playlists") + if existing: + return RedirectResponse(url=f"/jobs/{existing.id}", status_code=302) + + form = await request.form() + sp_client = request.app.state.spotify_client + config = request.app.state.config + excluded = config.get("spotify", {}).get("excluded_playlists", []) + pattern = form.get("pattern") or None + + def _update_and_log(): + sp_client.update_playlists(excluded_playlists=excluded, playlists_patterns=pattern) + if not pattern: + spotify_misc.log_track_counts(config) + + job = create_job("update-playlists") + asyncio.create_task(run_job(job, _update_and_log)) # noqa: RUF006 + return RedirectResponse(url=f"/jobs/{job.id}", status_code=302) + + +@router.post("/jobs/discover") +async def start_discover(request: Request): + redirect = await require_auth(request) + if redirect: + return redirect + + existing = get_running_job("discover") + if existing: + return RedirectResponse(url=f"/jobs/{existing.id}", status_code=302) + + sp_client = request.app.state.spotify_client + config = request.app.state.config + spotify_cfg = config.get("spotify", {}) + discover_playlist_id = spotify_cfg.get("discover_playlist", "") + sources = spotify_cfg.get("sources_playlists", []) + + if not discover_playlist_id or not sources: + return templates.TemplateResponse( + request, + "playlists.html", + context={"playlists": [], "error": "discover_playlist or sources_playlists not configured"}, + status_code=400, + ) + + job = create_job("discover") + asyncio.create_task(run_job(job, spotify_misc.discover_from_playlists, sp_client, discover_playlist_id, sources)) # noqa: RUF006 + return RedirectResponse(url=f"/jobs/{job.id}", status_code=302) + + +@router.get("/jobs/{job_id}", response_class=HTMLResponse) +async def job_status(request: Request, job_id: str): + redirect = await require_auth(request) + if redirect: + return redirect + job = get_job(job_id) + if job is None: + return HTMLResponse("

Job not found

", status_code=404) + is_htmx = request.headers.get("HX-Request") == "true" + template = "job_status.html" if is_htmx else "job_page.html" + return templates.TemplateResponse(request, template, context={"job": job, "JobStatus": JobStatus}) + + +@router.get("/track-counts", response_class=HTMLResponse) +async def track_counts(request: Request, page: int = Query(default=1, ge=1)): + redirect = await require_auth(request) + if redirect: + return redirect + + config = request.app.state.config + log_path_raw = config.get("spotify", {}).get("track_counts_log", "") + if not log_path_raw: + return templates.TemplateResponse( + request, + "track_counts.html", + context={"headers": [], "rows": [], "error": "track_counts_log not configured"}, + ) + + log_path = Path(log_path_raw).expanduser() + if not log_path.exists(): + return templates.TemplateResponse( + request, + "track_counts.html", + context={"headers": [], "rows": [], "error": f"Log file not found: {log_path}"}, + ) + + try: + with open(log_path, newline="") as f: + reader = csv.reader(f, delimiter=";") + all_rows = [row for row in reader if row] + except OSError as e: + return templates.TemplateResponse( + request, "track_counts.html", context={"headers": [], "rows": [], "error": f"Could not read log file: {e}"} + ) + + headers = all_rows[0] if all_rows else [] + sorted_rows = sorted(all_rows[1:], key=lambda r: r[0] if r else "", reverse=True) + page_rows, pagination = paginate(sorted_rows, page) + + return templates.TemplateResponse( + request, + "track_counts.html", + context={ + "headers": headers, + "rows": page_rows, + "error": None, + "pagination": pagination, + "base_url": pagination_base_url(request), + }, + ) diff --git a/spotfm/web/static/app.css b/spotfm/web/static/app.css new file mode 100644 index 0000000..8020137 --- /dev/null +++ b/spotfm/web/static/app.css @@ -0,0 +1,108 @@ +/* ── Compact base ── */ +html { font-size: 13px; } + +:root { + --pico-table-cell-padding-block: 0.2rem; + --pico-table-cell-padding-inline: 0.4rem; + --pico-spacing: 0.75rem; + --pico-form-element-spacing-vertical: 0.35rem; + --pico-form-element-spacing-horizontal: 0.6rem; +} + +h1 { font-size: 1.6rem; margin-bottom: 0.5rem; } +h2 { font-size: 1.3rem; margin-bottom: 0.4rem; } +h3 { font-size: 1.1rem; margin-bottom: 0.3rem; } + +p { margin-bottom: 0.4rem; } + +main { padding: 0.75rem 1.25rem; } + +/* ── Login ── */ +.login-box { + display: flex; + justify-content: center; + align-items: center; + min-height: 80vh; +} + +.login-box article { + width: 100%; + max-width: 360px; +} + +/* ── Dashboard stats ── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 0.6rem; + margin-bottom: 1rem; +} + +.stats-grid article { + text-align: center; + padding: 0.6rem; +} + +.stats-grid h3 { + font-size: 1.6rem; + margin-bottom: 0.15rem; +} + +.stats-grid p { + margin: 0; + color: var(--pico-muted-color); +} + +/* ── Filters ── */ +.filter-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +/* ── Actions ── */ +.actions-row { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-bottom: 0.6rem; +} + +.actions-row form { margin: 0; } + +/* ── Sortable column headers ── */ +th a { + color: inherit; + text-decoration: none; + white-space: nowrap; +} + +th a:hover { text-decoration: underline; } + +/* ── Pagination ── */ +.pagination-nav { + display: flex; + align-items: center; + gap: 0.3rem; + flex-wrap: wrap; + margin-top: 0.6rem; + font-size: 0.85rem; +} + +.pagination-nav a, +.pagination-nav strong, +.pagination-nav span { + padding: 0.15rem 0.4rem; + border-radius: 3px; + line-height: 1.4; +} + +.pagination-nav a { border: 1px solid var(--pico-muted-border-color); } +.pagination-nav a:hover { background: var(--pico-secondary-hover-background); text-decoration: none; } +.pagination-nav strong { background: var(--pico-primary-background); color: var(--pico-primary-inverse); } +.pagination-nav .pagination-disabled { color: var(--pico-muted-color); } +.pagination-nav .pagination-ellipsis { color: var(--pico-muted-color); } +.pagination-nav .pagination-info { margin-left: 0.4rem; color: var(--pico-muted-color); } + +nav a { white-space: nowrap; } diff --git a/spotfm/web/static/table_sort.js b/spotfm/web/static/table_sort.js new file mode 100644 index 0000000..80f33af --- /dev/null +++ b/spotfm/web/static/table_sort.js @@ -0,0 +1,37 @@ +// Client-side sort for tables with data-client-sort attribute. +// Mark sortable with data-col="fieldname". +(function () { + var state = {}; // tableId:col -> bool (true=asc) + + document.addEventListener("click", function (e) { + var th = e.target.closest("th[data-col]"); + if (!th) return; + var table = th.closest("table[data-client-sort]"); + if (!table) return; + e.preventDefault(); + + var col = th.dataset.col; + var key = (table.id || table.dataset.clientSort) + ":" + col; + var asc = state[key] !== true; + state[key] = asc; + + var headerRow = th.closest("tr"); + var colIdx = Array.from(headerRow.children).indexOf(th); + var tbody = table.querySelector("tbody"); + var rows = Array.from(tbody.querySelectorAll("tr")); + + rows.sort(function (a, b) { + var av = (a.children[colIdx] || {}).textContent.trim(); + var bv = (b.children[colIdx] || {}).textContent.trim(); + var an = parseFloat(av), bn = parseFloat(bv); + var cmp = (!isNaN(an) && !isNaN(bn)) ? (an - bn) : av.localeCompare(bv, undefined, { sensitivity: "base" }); + return asc ? cmp : -cmp; + }); + rows.forEach(function (r) { tbody.appendChild(r); }); + + // Update sort indicators + table.querySelectorAll("th[data-col] .sort-ind").forEach(function (s) { s.textContent = ""; }); + var ind = th.querySelector(".sort-ind"); + if (ind) ind.textContent = asc ? " ↑" : " ↓"; + }); +})(); diff --git a/spotfm/web/templates/_pagination.html b/spotfm/web/templates/_pagination.html new file mode 100644 index 0000000..83be9ae --- /dev/null +++ b/spotfm/web/templates/_pagination.html @@ -0,0 +1,26 @@ +{% if pagination.total_pages > 1 %} + +{% endif %} diff --git a/spotfm/web/templates/base.html b/spotfm/web/templates/base.html new file mode 100644 index 0000000..eb32c3b --- /dev/null +++ b/spotfm/web/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}spotfm{% endblock %} + + + + + + +
+ +
+
+ {% block content %}{% endblock %} +
+ + diff --git a/spotfm/web/templates/duplicates.html b/spotfm/web/templates/duplicates.html new file mode 100644 index 0000000..825deaf --- /dev/null +++ b/spotfm/web/templates/duplicates.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}spotfm — Duplicates{% endblock %} +{% block content %} +

Duplicate Detection

+ +
+

Exact duplicates (by ID)

+

Tracks appearing in multiple playlists with the same Spotify track ID.

+ Find exact dupes +
+ +
+

Fuzzy duplicates (by name)

+

Tracks with similar titles and artists (may have different IDs — e.g. album vs. single versions). This is slow.

+ {% if not job or job.status.value in ("done", "failed") %} +
+ +
+ {% endif %} + {% if job %} + {% include "job_status.html" %} + {% endif %} +
+{% endblock %} diff --git a/spotfm/web/templates/duplicates_ids.html b/spotfm/web/templates/duplicates_ids.html new file mode 100644 index 0000000..44d353e --- /dev/null +++ b/spotfm/web/templates/duplicates_ids.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}spotfm — Exact Duplicates{% endblock %} +{% block content %} +

Exact Duplicates (by ID)

+

← Back

+ +{% if dupes %} +

{{ pagination.total }} duplicate(s) found.

+ + + + + + + + + + {% for d in dupes %} + + + + + + {% endfor %} + +
Playlists{{ ind('playlists') }}Artist{{ ind('artists') }}Track{{ ind('track_name') }}
{{ d.playlists }}{{ d.artists or "—" }}{{ d.track_name }}
+{% include "_pagination.html" %} +{% else %} +

No exact duplicates found.

+{% endif %} +{% endblock %} diff --git a/spotfm/web/templates/duplicates_names.html b/spotfm/web/templates/duplicates_names.html new file mode 100644 index 0000000..c495ef7 --- /dev/null +++ b/spotfm/web/templates/duplicates_names.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}spotfm — Fuzzy Duplicates{% endblock %} +{% block content %} +

Fuzzy Duplicates (by Name)

+

← Back

+ +{% if pairs %} +

{{ pagination.total }} pair(s) found.

+ + + + + + + + + + + + + + {% for d in pairs %} + + + + + + + + + + {% endfor %} + +
Track 1{{ ind('track1') }}Artists 1{{ ind('artists1') }}Playlists 1Track 2{{ ind('track2') }}Artists 2{{ ind('artists2') }}Playlists 2Score{{ ind('score') }}
{{ d.track1 }}{{ d.artists1 or "—" }}{{ d.playlists1 or "—" }}{{ d.track2 }}{{ d.artists2 or "—" }}{{ d.playlists2 or "—" }}{{ "%.0f"|format(d.score) }}%
+{% include "_pagination.html" %} +{% else %} +

No similar pairs found.

+{% endif %} +{% endblock %} diff --git a/spotfm/web/templates/index.html b/spotfm/web/templates/index.html new file mode 100644 index 0000000..5a28dd1 --- /dev/null +++ b/spotfm/web/templates/index.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block title %}spotfm — Dashboard{% endblock %} +{% block content %} +

Library

+
+
+

{{ counts.tracks }}

+

Tracks

+
+
+

{{ counts.playlists }}

+

Playlists

+
+
+

{{ counts.artists }}

+

Artists

+
+
+

{{ counts.albums }}

+

Albums

+
+
+ +

Actions

+
+
+ +
+
+ +
+
+{% endblock %} diff --git a/spotfm/web/templates/job_page.html b/spotfm/web/templates/job_page.html new file mode 100644 index 0000000..f1100e0 --- /dev/null +++ b/spotfm/web/templates/job_page.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}spotfm — Job {{ job.id[:8] }}{% endblock %} +{% block content %} +

Job: {{ job.name }}

+{% include "job_status.html" %} +{% endblock %} diff --git a/spotfm/web/templates/job_status.html b/spotfm/web/templates/job_status.html new file mode 100644 index 0000000..f5cc312 --- /dev/null +++ b/spotfm/web/templates/job_status.html @@ -0,0 +1,26 @@ +
+

+ {{ job.name }} — + {% if job.status.value == "pending" %}⏳ Pending… + {% elif job.status.value == "running" %}🔄 Running… + {% elif job.status.value == "done" %}✅ Done + {% else %}❌ Failed: {{ job.error }} + {% endif %} +

+ {% if job.progress %} +
{{ job.progress | join('\n') }}
+ {% endif %} + {% if job.status.value == "done" and job.name == "dupe-names" %} + {% if job.result %} +

{{ job.result | length }} pair(s) found. View results →

+ {% else %} +

No similar pairs found.

+ {% endif %} + {% endif %} +
diff --git a/spotfm/web/templates/login.html b/spotfm/web/templates/login.html new file mode 100644 index 0000000..a59ef9b --- /dev/null +++ b/spotfm/web/templates/login.html @@ -0,0 +1,27 @@ + + + + + + spotfm — Login + + + + +
+
+

spotfm

+ {% if error %} +

{{ error }}

+ {% endif %} +
+ + +
+
+
+ + diff --git a/spotfm/web/templates/playlists.html b/spotfm/web/templates/playlists.html new file mode 100644 index 0000000..82ea7a4 --- /dev/null +++ b/spotfm/web/templates/playlists.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}spotfm — Playlists{% endblock %} +{% block content %} +

Playlists

+ +{% if error %}

{{ error }}

{% endif %} + +
+
+ +
+
+ +
+
+ + + + + + + + + + + {% for name, pid, count in playlists %} + + + + + + {% else %} + + {% endfor %} + +
Playlist{{ ind('name') }}Tracks{{ ind('count') }}
{{ name }}{{ count }} +
+ + +
+
No playlists found. Run "Update all" to sync from Spotify.
+{% include "_pagination.html" %} +{% endblock %} diff --git a/spotfm/web/templates/scrobbles.html b/spotfm/web/templates/scrobbles.html new file mode 100644 index 0000000..40cc903 --- /dev/null +++ b/spotfm/web/templates/scrobbles.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block title %}spotfm — Recent Scrobbles{% endblock %} +{% block content %} +

Recent Scrobbles

+ +{% if error %} +

Error: {{ error }}

+{% endif %} + +{% if state == "form" and not error %} +
+ +
+ + + + +
+ {% if state_info %} +

+ Last saved state: {{ state_info.last_scrobble_count }} scrobbles — leaving limit empty will fetch only new ones. +

+ {% else %} +

No saved state yet — first run will use the limit above.

+ {% endif %} + +
+ +{% elif state == "no_new" %} +

No new scrobbles since last check.

+Back to form + +{% elif state == "results" %} +{% if incremental %} +

{{ tracks|length }} new tracks since last check · {{ period }}-day window · Fetch again

+{% else %} +

{{ tracks|length }} tracks · {{ period }}-day window · Back to form

+{% endif %} + +{% if tracks %} + + + + + + + + + + + + {% for track in tracks %} + + + + + + + + {% endfor %} + +
ArtistTrackLast {{ period }}dTotal
{{ track.artist or "—" }}{{ track.title }}{{ track.period_scrobbles }}{{ track.total_scrobbles }}{% if track.url %}last.fm ↗{% endif %}
+{% else %} +

No scrobbles found matching the configured thresholds.

+{% endif %} +{% endif %} +{% endblock %} diff --git a/spotfm/web/templates/track_counts.html b/spotfm/web/templates/track_counts.html new file mode 100644 index 0000000..dfc3185 --- /dev/null +++ b/spotfm/web/templates/track_counts.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}spotfm — Track Counts{% endblock %} +{% block content %} +

Track Count History

+ +{% if error %} +

{{ error }}

+{% elif rows %} + + {% if headers %} + + {% for h in headers %}{% endfor %} + + {% endif %} + + {% for row in rows %} + {% for cell in row %}{% endfor %} + {% endfor %} + +
{{ h }}
{{ cell }}
+{% include "_pagination.html" %} +{% else %} +

No data found in track counts log.

+{% endif %} +{% endblock %} diff --git a/spotfm/web/templates/tracks.html b/spotfm/web/templates/tracks.html new file mode 100644 index 0000000..c0ff624 --- /dev/null +++ b/spotfm/web/templates/tracks.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}spotfm — Tracks{% endblock %} +{% block content %} +

Tracks

+ +
+
+ + + + + +
+ + Reset +
+ +{% if tracks %} +

{{ pagination.total }} tracks

+ + + + + + + + + + + + {% for t in tracks %} + + + + + + + + {% endfor %} + +
Artist{{ ind('artists') }}Track{{ ind('name') }}Album{{ ind('album') }}Year{{ ind('year') }}Playlists
{{ t.artists or "—" }}{{ t.name }}{{ t.album or "—" }}{{ t.year or "—" }}{% for pid, pname in t.track_playlists %}{{ pname }}{% if not loop.last %}, {% endif %}{% else %}—{% endfor %}
+{% include "_pagination.html" %} +{% else %} +

No tracks found{% if has_filters %} matching your filters{% endif %}.

+{% endif %} +{% endblock %} diff --git a/tests/test_lastfm.py b/tests/test_lastfm.py index 1e72227..c46ef34 100644 --- a/tests/test_lastfm.py +++ b/tests/test_lastfm.py @@ -60,16 +60,29 @@ class TestRecentScrobblesCli: def _make_user(self, playcount=150): user = MagicMock() user.get_playcount.return_value = playcount - user.get_recent_tracks_scrobbles.return_value = iter(["Artist - Track - 5 - 10 - http://last.fm/track"]) + user.get_recent_tracks_scrobbles.return_value = iter( + [ + { + "artist": "Artist", + "title": "Track", + "period_scrobbles": 5, + "total_scrobbles": 10, + "url": "http://last.fm/track", + } + ] + ) return user + def _make_config(self): + return {"limit": 50, "scrobbles_minimum": 4, "period_minimum": None} + def test_normal_run_saves_state(self, tmp_path): """Test that a normal run saves the current scrobble count.""" state_file = tmp_path / "lastfm_state.json" user = self._make_user(playcount=150) with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) state = read_lastfm_state(state_file=state_file) assert state["last_scrobble_count"] == 150 @@ -80,79 +93,81 @@ def test_first_run_initializes_state(self, tmp_path, capsys): user = self._make_user() with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) captured = capsys.readouterr() assert "Initializing scrobble tracking" in captured.out # Should fetch the limit amount on first run - user.get_recent_tracks_scrobbles.assert_called_once_with(10, 0, 90, None) + user.get_recent_tracks_scrobbles.assert_called_once_with(limit=10, scrobbles_minimum=0, period=90, period_minimum=None) def test_since_last_time_no_new_scrobbles(self, tmp_path, capsys): - """Test --since-last-time when count has not changed.""" + """Test incremental mode when count has not changed.""" state_file = tmp_path / "lastfm_state.json" save_lastfm_state(150, state_file=state_file) user = self._make_user(playcount=150) + # With saved state, uses incremental mode regardless of explicit limit with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) captured = capsys.readouterr() assert "No new scrobbles" in captured.out user.get_recent_tracks_scrobbles.assert_not_called() def test_since_last_time_fetches_diff(self, tmp_path): - """Test --since-last-time computes correct limit from diff.""" + """Test incremental mode computes correct limit from diff, ignoring explicit limit.""" state_file = tmp_path / "lastfm_state.json" save_lastfm_state(135, state_file=state_file) user = self._make_user(playcount=173) + # Even with explicit limit=100, incremental mode uses diff (38) with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) user.get_recent_tracks_scrobbles.assert_called_once() call_args = user.get_recent_tracks_scrobbles.call_args - assert call_args[0][0] == 38 # limit = 173 - 135 + assert call_args[1]["limit"] == 38 # limit = 173 - 135 def test_since_last_time_updates_state(self, tmp_path): - """Test --since-last-time updates the state file after fetching when not capped.""" + """Test incremental mode updates the state file after fetching.""" state_file = tmp_path / "lastfm_state.json" save_lastfm_state(135, state_file=state_file) user = self._make_user(playcount=173) - # limit is greater than the diff (38), so all new scrobbles are fetched + # Incremental mode computes limit as diff (38), fetches all new scrobbles with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) state = read_lastfm_state(state_file=state_file) assert state["last_scrobble_count"] == 173 def test_since_last_time_fetches_all_scrobbles_ignoring_limit(self, tmp_path): - """Test --since-last-time fetches all new scrobbles regardless of --limit.""" + """Test incremental mode ignores explicit limit and fetches only new scrobbles.""" state_file = tmp_path / "lastfm_state.json" save_lastfm_state(135, state_file=state_file) user = self._make_user(playcount=173) - # Even though limit is 10 and diff is 38, --since-last-time fetches all 38 + # With saved state, incremental mode ignores limit=10, fetches diff (38 scrobbles) with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) state = read_lastfm_state(state_file=state_file) # State should advance to current count since we fetch all scrobbles assert state["last_scrobble_count"] == 173 def test_since_last_time_prints_diff_info(self, tmp_path, capsys): - """Test --since-last-time prints informational message with counts.""" + """Test incremental mode prints informational message with counts.""" state_file = tmp_path / "lastfm_state.json" save_lastfm_state(135, state_file=state_file) user = self._make_user(playcount=173) + # Incremental mode ignores limit=100 when saved state exists with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=100, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) captured = capsys.readouterr() - assert "38" in captured.out - assert "135" in captured.out - assert "173" in captured.out + # Should show that fetching happened in incremental mode + assert "new scrobbles" in captured.out def test_first_run_uses_limit_parameter(self, tmp_path): """Test that on first run (no state file), the limit parameter is used.""" @@ -160,10 +175,10 @@ def test_first_run_uses_limit_parameter(self, tmp_path): user = self._make_user(playcount=200) with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file): - recent_scrobbles(user, limit=42, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False) + recent_scrobbles(user, limit=42, scrobbles_minimum=0, period=90, period_minimum=None, interactive=False, config=self._make_config()) call_args = user.get_recent_tracks_scrobbles.call_args - assert call_args[0][0] == 42 + assert call_args[1]["limit"] == 42 def test_interactive_mode_opens_editor(self, tmp_path): """Test that --interactive mode opens editor with deduplicated results.""" @@ -171,9 +186,27 @@ def test_interactive_mode_opens_editor(self, tmp_path): user = self._make_user(playcount=150) user.get_recent_tracks_scrobbles.return_value = iter( [ - "Artist 1 - Track 1 - 5 - 10 - http://last.fm/track1", - "Artist 2 - Track 2 - 3 - 5 - http://last.fm/track2", - "Artist 1 - Track 1 - 5 - 10 - http://last.fm/track1", # duplicate + { + "artist": "Artist 1", + "title": "Track 1", + "period_scrobbles": 5, + "total_scrobbles": 10, + "url": "http://last.fm/track1", + }, + { + "artist": "Artist 2", + "title": "Track 2", + "period_scrobbles": 3, + "total_scrobbles": 5, + "url": "http://last.fm/track2", + }, + { + "artist": "Artist 1", + "title": "Track 1", + "period_scrobbles": 5, + "total_scrobbles": 10, + "url": "http://last.fm/track1", + }, # duplicate ] ) @@ -183,7 +216,7 @@ def test_interactive_mode_opens_editor(self, tmp_path): patch("subprocess.run") as mock_run, patch("spotfm.cli.os.unlink") as mock_unlink, ): - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=True) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=True, config=self._make_config()) # Verify subprocess.run was called assert mock_run.called @@ -197,16 +230,16 @@ def test_interactive_mode_opens_editor(self, tmp_path): assert mock_unlink.called def test_interactive_mode_empty_results(self, tmp_path, capsys): - """Test that --interactive mode with no results prints message and doesn't open editor.""" + """Test that interactive mode with no results prints message and doesn't open editor.""" state_file = tmp_path / "lastfm_state.json" user = self._make_user(playcount=150) user.get_recent_tracks_scrobbles.return_value = iter([]) with patch("spotfm.lastfm.LASTFM_STATE_FILE", state_file), patch("subprocess.run") as mock_run: - recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=True) + recent_scrobbles(user, limit=10, scrobbles_minimum=0, period=90, period_minimum=None, interactive=True, config=self._make_config()) captured = capsys.readouterr() - assert "No results to open in editor" in captured.out + assert "No results found" in captured.out # Editor should not be opened mock_run.assert_not_called() @@ -445,10 +478,14 @@ def mock_get_scrobbles(artist, title): # Verify results assert len(results) == 2 - assert "Artist 1 - Track 1" in results[0] - assert "2 - 2" in results[0] # period_scrobbles - total_scrobbles - assert "Artist 2 - Track 2" in results[1] - assert "1 - 1" in results[1] + assert results[0]["artist"] == "Artist 1" + assert results[0]["title"] == "Track 1" + assert results[0]["period_scrobbles"] == 2 + assert results[0]["total_scrobbles"] == 2 + assert results[1]["artist"] == "Artist 2" + assert results[1]["title"] == "Track 2" + assert results[1]["period_scrobbles"] == 1 + assert results[1]["total_scrobbles"] == 1 # Verify API calls mock_pylast_user.get_recent_tracks.assert_called_once_with(limit=2) @@ -538,7 +575,8 @@ def mock_get_scrobbles(artist, title): results = list(user.get_recent_tracks_scrobbles(limit=2, scrobbles_minimum=3, period_minimum=None)) assert len(results) == 1 - assert "Artist 1 - Track 1" in results[0] + assert results[0]["artist"] == "Artist 1" + assert results[0]["title"] == "Track 1" @freeze_time("2024-01-15 12:00:00") @patch("spotfm.lastfm.sleep") @@ -569,7 +607,8 @@ def test_get_recent_tracks_scrobbles_calculates_period_counts_correctly(self, mo # Should show 2 scrobbles in last 7 days, 3 total assert len(results) == 1 - assert "2 - 3" in results[0] # period_count=2, total_count=3 + assert results[0]["period_scrobbles"] == 2 + assert results[0]["total_scrobbles"] == 3 @patch("spotfm.lastfm.sleep") def test_get_recent_tracks_scrobbles_invalid_period_raises_error(self, mock_sleep): @@ -655,7 +694,8 @@ def mock_get_scrobbles(artist, title): results = list(user.get_recent_tracks_scrobbles(limit=2, period=7, period_minimum=2)) assert len(results) == 1 - assert "Artist 1 - Track 1" in results[0] + assert results[0]["artist"] == "Artist 1" + assert results[0]["title"] == "Track 1" @freeze_time("2024-01-15 12:00:00") @patch("spotfm.lastfm.sleep") @@ -704,12 +744,13 @@ def mock_get_scrobbles(artist, title): results = list(user.get_recent_tracks_scrobbles(limit=2, scrobbles_minimum=2, period=7, period_minimum=2)) assert len(results) == 1 - assert "Artist 1 - Track 1" in results[0] + assert results[0]["artist"] == "Artist 1" + assert results[0]["title"] == "Track 1" @freeze_time("2024-01-15 12:00:00") @patch("spotfm.lastfm.sleep") def test_get_recent_tracks_scrobbles_output_format(self, mock_sleep): - """get_recent_tracks_scrobbles maintains backward-compatible output format.""" + """get_recent_tracks_scrobbles returns structured dict with artist, title, counts, and url.""" mock_client = MagicMock() mock_pylast_user = MagicMock() mock_client.get_authenticated_user.return_value = mock_pylast_user @@ -731,15 +772,14 @@ def test_get_recent_tracks_scrobbles_output_format(self, mock_sleep): user = User(mock_client) results = list(user.get_recent_tracks_scrobbles(limit=1, period=7, period_minimum=None)) - # Format: "Artist - Title - period_scrobbles - total_scrobbles - url" assert len(results) == 1 - parts = results[0].split(" - ") - assert parts[0] == "The Beatles" - assert parts[1] == "Hey Jude" - assert parts[2] == "1" # 1 scrobble in last 7 days - assert parts[3] == "2" # 2 total scrobbles - assert "last.fm/user/testuser/library" in parts[4] - assert "date_preset=LAST_7_DAYS" in parts[4] + track = results[0] + assert track["artist"] == "The Beatles" + assert track["title"] == "Hey Jude" + assert track["period_scrobbles"] == 1 # 1 scrobble in last 7 days + assert track["total_scrobbles"] == 2 # 2 total scrobbles + assert "last.fm/user/testuser/library" in track["url"] + assert "date_preset=LAST_7_DAYS" in track["url"] @pytest.mark.unit diff --git a/tests/web/__init__.py b/tests/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..a59f25e --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from spotfm import sqlite, utils + +TEST_API_KEY = "test-api-key-12345" + + +def _write_test_config(path: Path) -> Path: + config_dir = path / ".spotfm" + config_dir.mkdir(parents=True, exist_ok=True) + config_file = config_dir / "spotfm.toml" + config_file.write_text(f""" +[web] +api_key = "{TEST_API_KEY}" + +[spotify] +client_id = "test_client_id" +client_secret = "test_client_secret" +excluded_playlists = [] +sources_playlists = ["source1"] +discover_playlist = "discover_id" + +[lastfm] +api_key = "test_lfm_key" +api_secret = "test_lfm_secret" +username = "testuser" +password_hash = "testhash" +""") + return config_file + + +@pytest.fixture(autouse=True) +def reset_jobs(): + from spotfm.web.jobs import reset_jobs as _reset + + _reset() + yield + _reset() + + +@pytest.fixture +def test_config_file(tmp_path): + return _write_test_config(tmp_path) + + +@pytest.fixture +def app(temp_database, test_config_file, monkeypatch): + monkeypatch.setattr(utils, "DATABASE", temp_database) + monkeypatch.setattr(sqlite, "DATABASE", temp_database) + + sqlite.close_db_connection() + sqlite._reset_migration_state_for_tests() + + # Patch Spotify client creation to avoid real OAuth + import unittest.mock as mock + + from spotfm.web.app import create_app + + with mock.patch("spotfm.web.app.spotify_client.Client") as mock_client_cls: + mock_client_cls.return_value = mock.MagicMock() + return create_app(config_file=test_config_file) + + +@pytest.fixture +def client(app): + with TestClient(app, raise_server_exceptions=True) as c: + yield c + + +@pytest.fixture +def authed_client(client): + resp = client.post("/login", data={"api_key": TEST_API_KEY}) + assert resp.status_code in (200, 302) + return client diff --git a/tests/web/test_auth.py b/tests/web/test_auth.py new file mode 100644 index 0000000..fd76784 --- /dev/null +++ b/tests/web/test_auth.py @@ -0,0 +1,71 @@ +import pytest + +from tests.web.conftest import TEST_API_KEY + + +@pytest.mark.unit +def test_login_correct_key(client): + resp = client.post("/login", data={"api_key": TEST_API_KEY}, follow_redirects=False) + assert resp.status_code == 302 + assert resp.headers["location"] == "/" + + +@pytest.mark.unit +def test_login_wrong_key(client): + resp = client.post("/login", data={"api_key": "wrong"}) + assert resp.status_code == 401 + assert "Invalid API key" in resp.text + + +@pytest.mark.unit +def test_protected_route_redirects_unauthenticated(client): + resp = client.get("/playlists", follow_redirects=False) + assert resp.status_code == 302 + assert "/login" in resp.headers["location"] + + +@pytest.mark.unit +def test_protected_route_accessible_after_login(authed_client): + resp = authed_client.get("/playlists") + assert resp.status_code == 200 + + +@pytest.mark.unit +def test_logout_clears_session(authed_client): + resp = authed_client.post("/logout", follow_redirects=False) + assert resp.status_code == 302 + # After logout, protected routes redirect again + resp = authed_client.get("/playlists", follow_redirects=False) + assert resp.status_code == 302 + assert "/login" in resp.headers["location"] + + +@pytest.mark.unit +def test_dashboard_requires_auth(client): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 302 + + +@pytest.mark.unit +def test_login_open_redirect_protection(client): + # Attempt external redirect via next param + resp = client.post( + "/login?next=https://evil.com", + data={"api_key": TEST_API_KEY}, + follow_redirects=False, + ) + assert resp.status_code == 302 + # Should redirect to "/" instead of the malicious URL + assert resp.headers["location"] == "/" + + +@pytest.mark.unit +def test_login_safe_relative_redirect(client): + # Valid relative redirect should work + resp = client.post( + "/login?next=/playlists", + data={"api_key": TEST_API_KEY}, + follow_redirects=False, + ) + assert resp.status_code == 302 + assert resp.headers["location"] == "/playlists" diff --git a/tests/web/test_jobs.py b/tests/web/test_jobs.py new file mode 100644 index 0000000..fe9f47b --- /dev/null +++ b/tests/web/test_jobs.py @@ -0,0 +1,79 @@ +import pytest + + +@pytest.mark.unit +def test_create_job(): + from spotfm.web.jobs import JobStatus, create_job + + job = create_job("test") + assert job.status == JobStatus.PENDING + assert job.name == "test" + assert job.id + + +@pytest.mark.unit +def test_get_job(): + from spotfm.web.jobs import create_job, get_job + + job = create_job("get-test") + found = get_job(job.id) + assert found is job + + +@pytest.mark.unit +def test_get_job_missing(): + from spotfm.web.jobs import get_job + + assert get_job("nonexistent") is None + + +@pytest.mark.unit +def test_get_running_job(): + from spotfm.web.jobs import JobStatus, create_job, get_running_job + + job = create_job("running-test") + job.status = JobStatus.RUNNING + found = get_running_job("running-test") + assert found is job + + +@pytest.mark.unit +def test_get_running_job_done_not_returned(): + from spotfm.web.jobs import JobStatus, create_job, get_running_job + + job = create_job("done-test") + job.status = JobStatus.DONE + found = get_running_job("done-test") + assert found is None + + +@pytest.mark.asyncio +async def test_run_job_success(): + from spotfm.web.jobs import JobStatus, create_job, run_job + + job = create_job("success-test") + + def fn(): + import sys + + print("progress line", file=sys.stderr, flush=True) + return "result" + + await run_job(job, fn) + assert job.status == JobStatus.DONE + assert job.result == "result" + assert job.error is None + + +@pytest.mark.asyncio +async def test_run_job_failure(): + from spotfm.web.jobs import JobStatus, create_job, run_job + + job = create_job("fail-test") + + def fn(): + raise ValueError("boom") + + await run_job(job, fn) + assert job.status == JobStatus.FAILED + assert "boom" in job.error diff --git a/tests/web/test_routes_lastfm.py b/tests/web/test_routes_lastfm.py new file mode 100644 index 0000000..6bc7208 --- /dev/null +++ b/tests/web/test_routes_lastfm.py @@ -0,0 +1,149 @@ +import pytest + + +@pytest.mark.unit +def test_scrobbles_form_shown_by_default(authed_client, monkeypatch): + """GET /scrobbles without ?fetch shows the form, not results.""" + import spotfm.lastfm as lastfm_module + + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: {"last_scrobble_count": 1000}) + + resp = authed_client.get("/scrobbles") + assert resp.status_code == 200 + assert "Fetch scrobbles" in resp.text + # Form is shown with state info + assert "scrobbles" in resp.text.lower() + + +@pytest.mark.unit +def test_scrobbles_form_no_state(authed_client, monkeypatch): + """Form shows first-run message when no state file exists.""" + import spotfm.lastfm as lastfm_module + + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: None) + + resp = authed_client.get("/scrobbles") + assert resp.status_code == 200 + # Form is shown (either with state or first-run message) + assert "Fetch scrobbles" in resp.text + + +@pytest.mark.unit +def test_scrobbles_fetch_incremental_new(authed_client, monkeypatch): + """?fetch=1 with no explicit limit uses incremental state.""" + from unittest.mock import MagicMock + + import spotfm.lastfm as lastfm_module + import spotfm.web.routes.lastfm as route_module + + mock_user = MagicMock() + mock_user.get_playcount.return_value = 1032 + mock_user.get_recent_tracks_scrobbles.return_value = iter( + [ + { + "artist": "Shurik'n", + "title": "Manifeste", + "period_scrobbles": 5, + "total_scrobbles": 20, + "url": "https://www.last.fm/user/x/library/music/Shurikn/_/Manifeste", + } + ] + ) + monkeypatch.setattr(lastfm_module, "Client", MagicMock()) + monkeypatch.setattr(lastfm_module, "User", lambda _: mock_user) + # Patch in lastfm module so fetch_recent_scrobbles() sees the saved state + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: {"last_scrobble_count": 1000}) + monkeypatch.setattr(lastfm_module, "save_lastfm_state", lambda _: None) + + resp = authed_client.get("/scrobbles?fetch=1") + assert resp.status_code == 200 + # With saved state (1000), incremental mode fetches diff (1032-1000=32 new scrobbles) + # Template shows results including the track + assert "Shurik" in resp.text + assert "Manifeste" in resp.text + + +@pytest.mark.unit +def test_scrobbles_fetch_no_new(authed_client, monkeypatch): + """?fetch=1 incremental with no new scrobbles shows no_new message.""" + from unittest.mock import MagicMock + + import spotfm.lastfm as lastfm_module + import spotfm.web.routes.lastfm as route_module + + mock_user = MagicMock() + mock_user.get_playcount.return_value = 1000 + monkeypatch.setattr(lastfm_module, "Client", MagicMock()) + monkeypatch.setattr(lastfm_module, "User", lambda _: mock_user) + # Patch in lastfm module so fetch_recent_scrobbles() sees the saved state + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: {"last_scrobble_count": 1000}) + monkeypatch.setattr(lastfm_module, "save_lastfm_state", lambda _: None) + + resp = authed_client.get("/scrobbles?fetch=1") + assert resp.status_code == 200 + assert "No new scrobbles" in resp.text + + +@pytest.mark.unit +def test_scrobbles_fetch_explicit_limit_with_state(authed_client, monkeypatch): + """With saved state, incremental mode is used even when limit is passed.""" + from unittest.mock import MagicMock + + import spotfm.lastfm as lastfm_module + + mock_user = MagicMock() + mock_user.get_playcount.return_value = 1500 + mock_user.get_recent_tracks_scrobbles.return_value = iter( + [ + { + "artist": "Artist A", + "title": "Track 1", + "period_scrobbles": 5, + "total_scrobbles": 20, + "url": "https://www.last.fm/user/x/library/music/Artist+A/_/Track+1", + } + ] + ) + monkeypatch.setattr(lastfm_module, "Client", MagicMock()) + monkeypatch.setattr(lastfm_module, "User", lambda _: mock_user) + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: {"last_scrobble_count": 1000}) + monkeypatch.setattr(lastfm_module, "save_lastfm_state", lambda _: None) + + # Even with explicit limit=30, incremental mode uses diff (1500-1000=500) + resp = authed_client.get("/scrobbles?fetch=1&limit=30&scrobbles_minimum=5&period=60&period_minimum=20") + + assert resp.status_code == 200 + assert "last.fm" in resp.text + + +@pytest.mark.unit +def test_scrobbles_artist_title_split(authed_client, monkeypatch): + """Artist and title appear in separate columns.""" + from unittest.mock import MagicMock + + import spotfm.lastfm as lastfm_module + import spotfm.web.routes.lastfm as route_module + + mock_user = MagicMock() + mock_user.get_playcount.return_value = 1010 + mock_user.get_recent_tracks_scrobbles.return_value = iter( + [ + { + "artist": "Akhenaton", + "title": "La Boîte de Pattes", + "period_scrobbles": 3, + "total_scrobbles": 12, + "url": "https://www.last.fm/user/x/library/music/Akhenaton/_/La+Boite", + } + ] + ) + monkeypatch.setattr(lastfm_module, "Client", MagicMock()) + monkeypatch.setattr(lastfm_module, "User", lambda _: mock_user) + # Patch in lastfm module so fetch_recent_scrobbles() sees the saved state + monkeypatch.setattr(lastfm_module, "read_lastfm_state", lambda: {"last_scrobble_count": 1000}) + monkeypatch.setattr(lastfm_module, "save_lastfm_state", lambda _: None) + + resp = authed_client.get("/scrobbles?fetch=1") + assert resp.status_code == 200 + assert "Akhenaton" in resp.text + assert "La Boîte de Pattes" in resp.text diff --git a/tests/web/test_routes_spotify.py b/tests/web/test_routes_spotify.py new file mode 100644 index 0000000..361d99a --- /dev/null +++ b/tests/web/test_routes_spotify.py @@ -0,0 +1,131 @@ +import sqlite3 + +import pytest + + +def _insert_playlist(db_path, pid, name, owner="owner"): + conn = sqlite3.connect(str(db_path)) + conn.execute( + "INSERT INTO playlists (id, name, owner, updated_at) VALUES (?, ?, ?, ?)", + (pid, name, owner, "2024-01-01"), + ) + conn.commit() + conn.close() + + +def _insert_track(db_path, tid, name): + conn = sqlite3.connect(str(db_path)) + conn.execute( + "INSERT INTO tracks (id, name, updated_at) VALUES (?, ?, ?)", + (tid, name, "2024-01-01"), + ) + conn.commit() + conn.close() + + +@pytest.mark.unit +def test_playlists_page_renders(authed_client, temp_database): + _insert_playlist(temp_database, "pl1", "My Playlist") + resp = authed_client.get("/playlists") + assert resp.status_code == 200 + assert "My Playlist" in resp.text + + +@pytest.mark.unit +def test_playlists_empty(authed_client): + resp = authed_client.get("/playlists") + assert resp.status_code == 200 + assert "No playlists found" in resp.text + + +@pytest.mark.unit +def test_tracks_page_no_filters(authed_client): + resp = authed_client.get("/tracks") + assert resp.status_code == 200 + assert "Filter" in resp.text + + +@pytest.mark.unit +def test_tracks_page_with_playlist_filter(authed_client, temp_database, monkeypatch): + import spotfm.spotify.misc as misc_module + + monkeypatch.setattr(misc_module, "find_tracks_by_criteria", lambda **kwargs: []) + resp = authed_client.get("/tracks?playlist=pl1") + assert resp.status_code == 200 + + +@pytest.mark.unit +def test_duplicates_page_renders(authed_client): + resp = authed_client.get("/duplicates") + assert resp.status_code == 200 + assert "Exact duplicates" in resp.text + + +@pytest.mark.unit +def test_duplicates_ids_empty_db(authed_client, monkeypatch): + import spotfm.spotify.dupes as dupes_module + + monkeypatch.setattr(dupes_module, "find_duplicate_ids", lambda **kwargs: []) + resp = authed_client.get("/duplicates/ids") + assert resp.status_code == 200 + assert "No exact duplicates" in resp.text + + +@pytest.mark.unit +def test_update_playlists_creates_job(authed_client, monkeypatch): + import asyncio + + def mock_create_task(coro): + coro.close() + return None + + monkeypatch.setattr(asyncio, "create_task", mock_create_task) + + resp = authed_client.post("/jobs/update-playlists", follow_redirects=False) + assert resp.status_code == 302 + assert "/jobs/" in resp.headers["location"] + + +@pytest.mark.unit +def test_job_status_not_found(authed_client): + resp = authed_client.get("/jobs/nonexistent-id") + assert resp.status_code == 404 + + +@pytest.mark.unit +def test_job_status_found(authed_client, monkeypatch): + from spotfm.web.jobs import JobStatus, create_job + + job = create_job("test-job") + job.status = JobStatus.DONE + + resp = authed_client.get(f"/jobs/{job.id}") + assert resp.status_code == 200 + assert "Done" in resp.text + + +@pytest.mark.unit +def test_track_counts_missing_config(authed_client): + resp = authed_client.get("/track-counts") + assert resp.status_code == 200 + assert "not configured" in resp.text or "not found" in resp.text + + +@pytest.mark.unit +def test_track_counts_renders_csv(authed_client, tmp_path, monkeypatch): + csv_file = tmp_path / "track-counts.csv" + csv_file.write_text("2024-01-01,100\n2024-02-01,105\n") + + # Patch config to point at temp CSV + original_state = authed_client.app.state.config + patched_config = { + **original_state, + "spotify": {**original_state.get("spotify", {}), "track_counts_log": str(csv_file)}, + } + authed_client.app.state.config = patched_config + + resp = authed_client.get("/track-counts") + assert resp.status_code == 200 + assert "2024-02-01" in resp.text + + authed_client.app.state.config = original_state diff --git a/uv.lock b/uv.lock index dea45b5..24c0719 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[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 = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.0" @@ -85,6 +103,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[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" @@ -212,6 +242,22 @@ 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 = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + [[package]] name = "filelock" version = "3.20.3" @@ -255,6 +301,28 @@ 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 = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -342,6 +410,15 @@ 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 = "jaraco-classes" version = "3.4.0" @@ -396,6 +473,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] +[[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 = "keyring" version = "25.7.0" @@ -425,6 +514,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[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/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -600,6 +719,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -637,6 +812,18 @@ 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" }, +] +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-cov" version = "7.0.0" @@ -688,6 +875,24 @@ 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-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-multipart" +version = "0.0.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -895,23 +1100,45 @@ dev = [ { name = "ruff" }, { name = "twine" }, ] +web = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest-asyncio" }, +] [package.metadata] requires-dist = [ + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.136.3" }, { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.5.0" }, + { name = "httpx", marker = "extra == 'web'", specifier = ">=0.28.1" }, { name = "ipython", marker = "extra == 'dev'" }, + { name = "itsdangerous", marker = "extra == 'web'", specifier = ">=2.2.0" }, + { name = "jinja2", marker = "extra == 'web'", specifier = ">=3.1.6" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pylast" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6.0" }, + { name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.30" }, { name = "rapidfuzz" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "spotipy", specifier = ">=2.26.0" }, { name = "twine", marker = "extra == 'dev'" }, + { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.48.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "web"] + +[package.metadata.requires-dev] +dev = [{ name = "pytest-asyncio", specifier = ">=1.4.0" }] [[package]] name = "spotipy" @@ -941,6 +1168,18 @@ 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 = "starlette" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, +] + [[package]] name = "traitlets" version = "5.14.3" @@ -970,6 +1209,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -979,6 +1239,50 @@ 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]] +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.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + [[package]] name = "virtualenv" version = "20.36.1" @@ -993,6 +1297,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + [[package]] name = "wcwidth" version = "0.2.14" @@ -1001,3 +1352,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc7 wheels = [ { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, ] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]