From 0661865d1624b7041f91eb3f4fa8a628eb7badc9 Mon Sep 17 00:00:00 2001 From: Xoconoch Date: Sat, 23 Aug 2025 23:00:11 -0600 Subject: [PATCH] fix(ui): Queue and deezspot callbacks --- routes/migrations/runner.py | 363 +--------- routes/migrations/v3_2_0.py | 100 --- routes/migrations/v3_2_1.py | 41 -- routes/migrations/v3_3_0.py | 69 ++ routes/system/progress.py | 212 ++++-- routes/utils/album.py | 8 +- routes/utils/celery_config.py | 2 +- routes/utils/playlist.py | 8 +- routes/utils/track.py | 8 +- routes/utils/watch/manager.py | 8 +- spotizerr-ui/package.json | 2 +- spotizerr-ui/src/components/Queue.tsx | 2 +- .../src/components/config/AccountsTab.tsx | 1 + spotizerr-ui/src/contexts/QueueProvider.tsx | 456 +++++++------ spotizerr-ui/src/contexts/queue-context.ts | 37 +- spotizerr-ui/src/routes/root.tsx | 7 + spotizerr-ui/src/types/callbacks.ts | 11 + tests/migration/__init__.py | 1 - tests/migration/test_v3_0_6.py | 633 ------------------ tests/migration/test_v3_1_0.py | 65 -- tests/migration/test_v3_1_1.py | 135 ---- 21 files changed, 509 insertions(+), 1660 deletions(-) delete mode 100644 routes/migrations/v3_2_0.py delete mode 100644 routes/migrations/v3_2_1.py create mode 100644 routes/migrations/v3_3_0.py delete mode 100644 tests/migration/__init__.py delete mode 100644 tests/migration/test_v3_0_6.py delete mode 100644 tests/migration/test_v3_1_0.py delete mode 100644 tests/migration/test_v3_1_1.py diff --git a/routes/migrations/runner.py b/routes/migrations/runner.py index 4981ac9..6a12991 100644 --- a/routes/migrations/runner.py +++ b/routes/migrations/runner.py @@ -3,16 +3,11 @@ import sqlite3 from pathlib import Path from typing import Optional -from .v3_2_0 import MigrationV3_2_0 -from .v3_2_1 import log_noop_migration_detected +from .v3_3_0 import MigrationV3_3_0 logger = logging.getLogger(__name__) DATA_DIR = Path("./data") -HISTORY_DB = DATA_DIR / "history" / "download_history.db" -WATCH_DIR = DATA_DIR / "watch" -PLAYLISTS_DB = WATCH_DIR / "playlists.db" -ARTISTS_DB = WATCH_DIR / "artists.db" # Credentials CREDS_DIR = DATA_DIR / "creds" @@ -20,89 +15,6 @@ ACCOUNTS_DB = CREDS_DIR / "accounts.db" BLOBS_DIR = CREDS_DIR / "blobs" SEARCH_JSON = CREDS_DIR / "search.json" -# Expected children table columns for history (album_/playlist_) -CHILDREN_EXPECTED_COLUMNS: dict[str, str] = { - "id": "INTEGER PRIMARY KEY AUTOINCREMENT", - "title": "TEXT NOT NULL", - "artists": "TEXT", - "album_title": "TEXT", - "duration_ms": "INTEGER", - "track_number": "INTEGER", - "disc_number": "INTEGER", - "explicit": "BOOLEAN", - "status": "TEXT NOT NULL", - "external_ids": "TEXT", - "genres": "TEXT", - "isrc": "TEXT", - "timestamp": "REAL NOT NULL", - "position": "INTEGER", - "metadata": "TEXT", -} - -# 3.2.0 expected schemas for Watch DBs (kept here to avoid importing modules with side-effects) -EXPECTED_WATCHED_PLAYLISTS_COLUMNS: dict[str, str] = { - "spotify_id": "TEXT PRIMARY KEY", - "name": "TEXT", - "owner_id": "TEXT", - "owner_name": "TEXT", - "total_tracks": "INTEGER", - "link": "TEXT", - "snapshot_id": "TEXT", - "last_checked": "INTEGER", - "added_at": "INTEGER", - "is_active": "INTEGER DEFAULT 1", -} - -EXPECTED_PLAYLIST_TRACKS_COLUMNS: dict[str, str] = { - "spotify_track_id": "TEXT PRIMARY KEY", - "title": "TEXT", - "artist_names": "TEXT", - "album_name": "TEXT", - "album_artist_names": "TEXT", - "track_number": "INTEGER", - "album_spotify_id": "TEXT", - "duration_ms": "INTEGER", - "added_at_playlist": "TEXT", - "added_to_db": "INTEGER", - "is_present_in_spotify": "INTEGER DEFAULT 1", - "last_seen_in_spotify": "INTEGER", - "snapshot_id": "TEXT", - "final_path": "TEXT", -} - -EXPECTED_WATCHED_ARTISTS_COLUMNS: dict[str, str] = { - "spotify_id": "TEXT PRIMARY KEY", - "name": "TEXT", - "link": "TEXT", - "total_albums_on_spotify": "INTEGER", - "last_checked": "INTEGER", - "added_at": "INTEGER", - "is_active": "INTEGER DEFAULT 1", - "genres": "TEXT", - "popularity": "INTEGER", - "image_url": "TEXT", -} - -EXPECTED_ARTIST_ALBUMS_COLUMNS: dict[str, str] = { - "album_spotify_id": "TEXT PRIMARY KEY", - "artist_spotify_id": "TEXT", - "name": "TEXT", - "album_group": "TEXT", - "album_type": "TEXT", - "release_date": "TEXT", - "release_date_precision": "TEXT", - "total_tracks": "INTEGER", - "link": "TEXT", - "image_url": "TEXT", - "added_to_db": "INTEGER", - "last_seen_on_spotify": "INTEGER", - "download_task_id": "TEXT", - "download_status": "INTEGER DEFAULT 0", - "is_fully_downloaded_managed_by_app": "INTEGER DEFAULT 0", -} - -m320 = MigrationV3_2_0() - def _safe_connect(path: Path) -> Optional[sqlite3.Connection]: try: @@ -115,245 +27,6 @@ def _safe_connect(path: Path) -> Optional[sqlite3.Connection]: return None -def _ensure_table_schema( - conn: sqlite3.Connection, - table_name: str, - expected_columns: dict[str, str], - table_description: str, -) -> None: - try: - cur = conn.execute(f"PRAGMA table_info({table_name})") - existing_info = cur.fetchall() - existing_names = {row[1] for row in existing_info} - for col_name, col_type in expected_columns.items(): - if col_name in existing_names: - continue - col_type_for_add = ( - col_type.replace("PRIMARY KEY", "") - .replace("AUTOINCREMENT", "") - .replace("NOT NULL", "") - .strip() - ) - try: - conn.execute( - f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_for_add}" - ) - logger.info( - f"Added missing column '{col_name} {col_type_for_add}' to {table_description} table '{table_name}'." - ) - except sqlite3.OperationalError as e: - logger.warning( - f"Could not add column '{col_name}' to {table_description} table '{table_name}': {e}" - ) - except Exception as e: - logger.error( - f"Error ensuring schema for {table_description} table '{table_name}': {e}", - exc_info=True, - ) - - -def _create_or_update_children_table(conn: sqlite3.Connection, table_name: str) -> None: - conn.execute( - f""" - CREATE TABLE IF NOT EXISTS {table_name} ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - artists TEXT, - album_title TEXT, - duration_ms INTEGER, - track_number INTEGER, - disc_number INTEGER, - explicit BOOLEAN, - status TEXT NOT NULL, - external_ids TEXT, - genres TEXT, - isrc TEXT, - timestamp REAL NOT NULL, - position INTEGER, - metadata TEXT - ) - """ - ) - _ensure_table_schema( - conn, table_name, CHILDREN_EXPECTED_COLUMNS, "children history" - ) - - -# --- Helper to validate instance is at least 3.1.2 on history DB --- - - -def _history_children_tables(conn: sqlite3.Connection) -> list[str]: - tables: set[str] = set() - try: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'album_%' OR name LIKE 'playlist_%') AND name != 'download_history'" - ) - for row in cur.fetchall(): - if row and row[0]: - tables.add(row[0]) - except sqlite3.Error as e: - logger.warning(f"Failed to scan sqlite_master for children tables: {e}") - - try: - cur = conn.execute( - "SELECT DISTINCT children_table FROM download_history WHERE children_table IS NOT NULL AND TRIM(children_table) != ''" - ) - for row in cur.fetchall(): - t = row[0] - if t: - tables.add(t) - except sqlite3.Error as e: - logger.warning(f"Failed to scan download_history for children tables: {e}") - - return sorted(tables) - - -def _is_history_at_least_3_2_0(conn: sqlite3.Connection) -> bool: - required_cols = {"service", "quality_format", "quality_bitrate"} - tables = _history_children_tables(conn) - if not tables: - # Nothing to migrate implies OK - return True - for t in tables: - try: - cur = conn.execute(f"PRAGMA table_info({t})") - cols = {row[1] for row in cur.fetchall()} - if not required_cols.issubset(cols): - return False - except sqlite3.OperationalError: - return False - return True - - -# --- 3.2.0 verification helpers for Watch DBs --- - - -def _update_watch_playlists_db(conn: sqlite3.Connection) -> None: - try: - # Ensure core watched_playlists table exists and has expected schema - conn.execute( - """ - CREATE TABLE IF NOT EXISTS watched_playlists ( - spotify_id TEXT PRIMARY KEY, - name TEXT, - owner_id TEXT, - owner_name TEXT, - total_tracks INTEGER, - link TEXT, - snapshot_id TEXT, - last_checked INTEGER, - added_at INTEGER, - is_active INTEGER DEFAULT 1 - ) - """ - ) - _ensure_table_schema( - conn, - "watched_playlists", - EXPECTED_WATCHED_PLAYLISTS_COLUMNS, - "watched playlists", - ) - - # Upgrade all dynamic playlist_ tables - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'playlist_%'" - ) - for row in cur.fetchall(): - table_name = row[0] - conn.execute( - f""" - CREATE TABLE IF NOT EXISTS {table_name} ( - spotify_track_id TEXT PRIMARY KEY, - title TEXT, - artist_names TEXT, - album_name TEXT, - album_artist_names TEXT, - track_number INTEGER, - album_spotify_id TEXT, - duration_ms INTEGER, - added_at_playlist TEXT, - added_to_db INTEGER, - is_present_in_spotify INTEGER DEFAULT 1, - last_seen_in_spotify INTEGER, - snapshot_id TEXT, - final_path TEXT - ) - """ - ) - _ensure_table_schema( - conn, - table_name, - EXPECTED_PLAYLIST_TRACKS_COLUMNS, - f"playlist tracks ({table_name})", - ) - except Exception: - logger.error( - "Failed to upgrade watch playlists DB to 3.2.0 base schema", exc_info=True - ) - - -def _update_watch_artists_db(conn: sqlite3.Connection) -> None: - try: - # Ensure core watched_artists table exists and has expected schema - conn.execute( - """ - CREATE TABLE IF NOT EXISTS watched_artists ( - spotify_id TEXT PRIMARY KEY, - name TEXT, - link TEXT, - total_albums_on_spotify INTEGER, - last_checked INTEGER, - added_at INTEGER, - is_active INTEGER DEFAULT 1, - genres TEXT, - popularity INTEGER, - image_url TEXT - ) - """ - ) - _ensure_table_schema( - conn, "watched_artists", EXPECTED_WATCHED_ARTISTS_COLUMNS, "watched artists" - ) - - # Upgrade all dynamic artist_ tables - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'artist_%'" - ) - for row in cur.fetchall(): - table_name = row[0] - conn.execute( - f""" - CREATE TABLE IF NOT EXISTS {table_name} ( - album_spotify_id TEXT PRIMARY KEY, - artist_spotify_id TEXT, - name TEXT, - album_group TEXT, - album_type TEXT, - release_date TEXT, - release_date_precision TEXT, - total_tracks INTEGER, - link TEXT, - image_url TEXT, - added_to_db INTEGER, - last_seen_on_spotify INTEGER, - download_task_id TEXT, - download_status INTEGER DEFAULT 0, - is_fully_downloaded_managed_by_app INTEGER DEFAULT 0 - ) - """ - ) - _ensure_table_schema( - conn, - table_name, - EXPECTED_ARTIST_ALBUMS_COLUMNS, - f"artist albums ({table_name})", - ) - except Exception: - logger.error( - "Failed to upgrade watch artists DB to 3.2.0 base schema", exc_info=True - ) - - def _ensure_creds_filesystem() -> None: try: BLOBS_DIR.mkdir(parents=True, exist_ok=True) @@ -374,35 +47,10 @@ def run_migrations_if_needed(): return try: - # Require instance to be at least 3.2.0 on history DB; otherwise abort - with _safe_connect(HISTORY_DB) as history_conn: - if history_conn and not _is_history_at_least_3_2_0(history_conn): - logger.error( - "Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.3.0." - ) - raise RuntimeError( - "Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.3.0." - ) + # Validate configuration version strictly at 3.3.0 + MigrationV3_3_0.assert_config_version_is_3_3_0() - # Watch playlists DB - with _safe_connect(PLAYLISTS_DB) as conn: - if conn: - _update_watch_playlists_db(conn) - # Apply 3.2.0 additions (batch progress columns) - if not m320.check_watch_playlists(conn): - m320.update_watch_playlists(conn) - conn.commit() - - # Watch artists DB (if exists) - if ARTISTS_DB.exists(): - with _safe_connect(ARTISTS_DB) as conn: - if conn: - _update_watch_artists_db(conn) - if not m320.check_watch_artists(conn): - m320.update_watch_artists(conn) - conn.commit() - - # Accounts DB (no changes for this migration path) + # No schema changes in 3.3.0 path; just ensure Accounts DB can be opened with _safe_connect(ACCOUNTS_DB) as conn: if conn: conn.commit() @@ -412,5 +60,4 @@ def run_migrations_if_needed(): raise else: _ensure_creds_filesystem() - log_noop_migration_detected() - logger.info("Database migrations check completed (3.2.0 -> 3.3.0 path)") + logger.info("Migration validation completed (3.3.0 gate)") diff --git a/routes/migrations/v3_2_0.py b/routes/migrations/v3_2_0.py deleted file mode 100644 index 3849210..0000000 --- a/routes/migrations/v3_2_0.py +++ /dev/null @@ -1,100 +0,0 @@ -import sqlite3 -import logging - -logger = logging.getLogger(__name__) - - -class MigrationV3_2_0: - """ - Migration for version 3.2.0 (upgrade path 3.2.0 -> 3.3.0). - - Adds per-item batch progress columns to Watch DBs to support page-by-interval processing. - - Enforces prerequisite: previous instance version must be 3.1.2 (validated by runner). - """ - - # New columns to add to watched tables - PLAYLISTS_ADDED_COLUMNS: dict[str, str] = { - "batch_next_offset": "INTEGER DEFAULT 0", - "batch_processing_snapshot_id": "TEXT", - } - - ARTISTS_ADDED_COLUMNS: dict[str, str] = { - "batch_next_offset": "INTEGER DEFAULT 0", - } - - # --- No-op for history/accounts in 3.3.0 --- - - def check_history(self, conn: sqlite3.Connection) -> bool: - return True - - def update_history(self, conn: sqlite3.Connection) -> None: - pass - - def check_accounts(self, conn: sqlite3.Connection) -> bool: - return True - - def update_accounts(self, conn: sqlite3.Connection) -> None: - pass - - # --- Watch: playlists --- - - def check_watch_playlists(self, conn: sqlite3.Connection) -> bool: - try: - cur = conn.execute("PRAGMA table_info(watched_playlists)") - cols = {row[1] for row in cur.fetchall()} - return set(self.PLAYLISTS_ADDED_COLUMNS.keys()).issubset(cols) - except sqlite3.OperationalError: - # Table missing means not ready - return False - - def update_watch_playlists(self, conn: sqlite3.Connection) -> None: - # Add new columns if missing - try: - cur = conn.execute("PRAGMA table_info(watched_playlists)") - existing = {row[1] for row in cur.fetchall()} - for col_name, col_type in self.PLAYLISTS_ADDED_COLUMNS.items(): - if col_name in existing: - continue - try: - conn.execute( - f"ALTER TABLE watched_playlists ADD COLUMN {col_name} {col_type}" - ) - logger.info( - f"Added column '{col_name} {col_type}' to watched_playlists for 3.3.0 batch progress." - ) - except sqlite3.OperationalError as e: - logger.warning( - f"Could not add column '{col_name}' to watched_playlists: {e}" - ) - except Exception: - logger.error("Failed to update watched_playlists for 3.3.0", exc_info=True) - - # --- Watch: artists --- - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - try: - cur = conn.execute("PRAGMA table_info(watched_artists)") - cols = {row[1] for row in cur.fetchall()} - return set(self.ARTISTS_ADDED_COLUMNS.keys()).issubset(cols) - except sqlite3.OperationalError: - return False - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - try: - cur = conn.execute("PRAGMA table_info(watched_artists)") - existing = {row[1] for row in cur.fetchall()} - for col_name, col_type in self.ARTISTS_ADDED_COLUMNS.items(): - if col_name in existing: - continue - try: - conn.execute( - f"ALTER TABLE watched_artists ADD COLUMN {col_name} {col_type}" - ) - logger.info( - f"Added column '{col_name} {col_type}' to watched_artists for 3.3.0 batch progress." - ) - except sqlite3.OperationalError as e: - logger.warning( - f"Could not add column '{col_name}' to watched_artists: {e}" - ) - except Exception: - logger.error("Failed to update watched_artists for 3.3.0", exc_info=True) diff --git a/routes/migrations/v3_2_1.py b/routes/migrations/v3_2_1.py deleted file mode 100644 index d8cad20..0000000 --- a/routes/migrations/v3_2_1.py +++ /dev/null @@ -1,41 +0,0 @@ -import logging -import sqlite3 - -logger = logging.getLogger(__name__) - - -class MigrationV3_2_1: - """ - No-op migration for version 3.2.1 (upgrade path 3.2.1 -> 3.3.0). - No database schema changes are required. - """ - - def check_history(self, conn: sqlite3.Connection) -> bool: - return True - - def update_history(self, conn: sqlite3.Connection) -> None: - pass - - def check_accounts(self, conn: sqlite3.Connection) -> bool: - return True - - def update_accounts(self, conn: sqlite3.Connection) -> None: - pass - - def check_watch_playlists(self, conn: sqlite3.Connection) -> bool: - return True - - def update_watch_playlists(self, conn: sqlite3.Connection) -> None: - pass - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - return True - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - pass - - -def log_noop_migration_detected() -> None: - logger.info( - "No migration performed: detected schema for 3.2.1; no changes needed for 3.2.1 -> 3.3.0." - ) diff --git a/routes/migrations/v3_3_0.py b/routes/migrations/v3_3_0.py new file mode 100644 index 0000000..b36a4b0 --- /dev/null +++ b/routes/migrations/v3_3_0.py @@ -0,0 +1,69 @@ +import json +import logging +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +CONFIG_PATH = Path("./data/config/main.json") +REQUIRED_VERSION = "3.3.0" +TARGET_VERSION = "3.3.1" + + +def _load_config(config_path: Path) -> Optional[dict]: + try: + if not config_path.exists(): + logger.error(f"Configuration file not found at {config_path}") + return None + content = config_path.read_text(encoding="utf-8") + return json.loads(content) + except Exception: + logger.error("Failed to read configuration file for migration", exc_info=True) + return None + + +def _save_config(config_path: Path, cfg: dict) -> None: + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(json.dumps(cfg, indent=4) + "\n", encoding="utf-8") + + +class MigrationV3_3_0: + """ + 3.3.0 migration gate. This migration verifies the configuration indicates + version 3.3.0, then bumps it to 3.3.1. + + If the `version` key is missing or not equal to 3.3.0, execution aborts and + prompts the user to update their instance to 3.3.0. + """ + + @staticmethod + def assert_config_version_is_3_3_0() -> None: + cfg = _load_config(CONFIG_PATH) + if not cfg or "version" not in cfg: + raise RuntimeError( + "Missing 'version' in data/config/main.json. Please update your configuration to 3.3.0." + ) + version = str(cfg.get("version", "")).strip() + # Case 1: exactly 3.3.0 -> bump to 3.3.1 + if version == REQUIRED_VERSION: + cfg["version"] = TARGET_VERSION + try: + _save_config(CONFIG_PATH, cfg) + logger.info( + f"Configuration version bumped from {REQUIRED_VERSION} to {TARGET_VERSION}." + ) + except Exception: + logger.error( + "Failed to bump configuration version to 3.3.1", exc_info=True + ) + raise + return + # Case 2: already 3.3.1 -> OK + if version == TARGET_VERSION: + logger.info("Configuration version 3.3.1 detected. Proceeding.") + return + # Case 3: anything else -> abort and instruct to update to 3.3.0 first + raise RuntimeError( + f"Unsupported configuration version '{version}'. Please update to {REQUIRED_VERSION}." + ) diff --git a/routes/system/progress.py b/routes/system/progress.py index a2b1a66..104a617 100755 --- a/routes/system/progress.py +++ b/routes/system/progress.py @@ -4,7 +4,7 @@ import logging import time import json import asyncio -from typing import Set +from typing import Set, Optional import redis import threading @@ -42,12 +42,12 @@ class SSEBroadcaster: """Add a new SSE client""" self.clients.add(queue) logger.debug(f"SSE: Client connected (total: {len(self.clients)})") - + async def remove_client(self, queue: asyncio.Queue): """Remove an SSE client""" self.clients.discard(queue) logger.debug(f"SSE: Client disconnected (total: {len(self.clients)})") - + async def broadcast_event(self, event_data: dict): """Broadcast an event to all connected clients""" logger.debug( @@ -118,26 +118,22 @@ def start_sse_redis_subscriber(): # Handle different event types if event_type == "progress_update": - # Transform callback data into task format expected by frontend - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - broadcast_data = loop.run_until_complete( - transform_callback_to_task_format( - task_id, event_data - ) - ) - if broadcast_data: + # Transform callback data into standardized update format expected by frontend + standardized = standardize_incoming_event(event_data) + if standardized: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: loop.run_until_complete( - sse_broadcaster.broadcast_event(broadcast_data) + sse_broadcaster.broadcast_event(standardized) ) logger.debug( - f"SSE Redis Subscriber: Broadcasted callback to {len(sse_broadcaster.clients)} clients" + f"SSE Redis Subscriber: Broadcasted standardized progress update to {len(sse_broadcaster.clients)} clients" ) - finally: - loop.close() + finally: + loop.close() elif event_type == "summary_update": - # Task summary update - use existing trigger_sse_update logic + # Task summary update - use standardized trigger loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: @@ -152,18 +148,20 @@ def start_sse_redis_subscriber(): finally: loop.close() else: - # Unknown event type - broadcast as-is - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete( - sse_broadcaster.broadcast_event(event_data) - ) - logger.debug( - f"SSE Redis Subscriber: Broadcasted {event_type} to {len(sse_broadcaster.clients)} clients" - ) - finally: - loop.close() + # Unknown event type - attempt to standardize and broadcast + standardized = standardize_incoming_event(event_data) + if standardized: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + sse_broadcaster.broadcast_event(standardized) + ) + logger.debug( + f"SSE Redis Subscriber: Broadcasted standardized {event_type} to {len(sse_broadcaster.clients)} clients" + ) + finally: + loop.close() except Exception as e: logger.error( @@ -180,6 +178,85 @@ def start_sse_redis_subscriber(): logger.debug("SSE Redis Subscriber: Background thread started") +def build_task_object_from_callback( + task_id: str, callback_data: dict +) -> Optional[dict]: + """Build a standardized task object from callback payload and task info.""" + try: + task_info = get_task_info(task_id) + if not task_info: + return None + return { + "task_id": task_id, + "original_url": f"http://localhost:7171/api/{task_info.get('download_type', 'track')}/download/{task_info.get('url', '').split('/')[-1] if task_info.get('url') else ''}", + "last_line": callback_data, + "timestamp": time.time(), + "download_type": task_info.get("download_type", "track"), + "type": task_info.get("type", task_info.get("download_type", "track")), + "name": task_info.get("name", "Unknown"), + "artist": task_info.get("artist", ""), + "created_at": task_info.get("created_at"), + } + except Exception as e: + logger.error( + f"Error building task object from callback for {task_id}: {e}", + exc_info=True, + ) + return None + + +def standardize_incoming_event(event_data: dict) -> Optional[dict]: + """ + Convert various incoming event shapes into a standardized SSE payload: + { + 'change_type': 'update' | 'heartbeat', + 'tasks': [...], + 'current_timestamp': float, + 'trigger_reason': str (optional) + } + """ + try: + # Heartbeat passthrough (ensure tasks array exists) + if event_data.get("change_type") == "heartbeat": + return { + "change_type": "heartbeat", + "tasks": [], + "current_timestamp": time.time(), + } + + # If already has tasks, just coerce change_type + if isinstance(event_data.get("tasks"), list): + return { + "change_type": event_data.get("change_type", "update"), + "tasks": event_data["tasks"], + "current_timestamp": time.time(), + "trigger_reason": event_data.get("trigger_reason"), + } + + # If it's a callback-shaped event + callback_data = event_data.get("callback_data") + task_id = event_data.get("task_id") + if callback_data and task_id: + task_obj = build_task_object_from_callback(task_id, callback_data) + if task_obj: + return { + "change_type": "update", + "tasks": [task_obj], + "current_timestamp": time.time(), + "trigger_reason": event_data.get("event_type", "callback_update"), + } + + # Fallback to empty update + return { + "change_type": "update", + "tasks": [], + "current_timestamp": time.time(), + } + except Exception as e: + logger.error(f"Failed to standardize incoming event: {e}", exc_info=True) + return None + + async def transform_callback_to_task_format(task_id: str, event_data: dict) -> dict: """Transform callback event data into the task format expected by frontend""" try: @@ -210,7 +287,7 @@ async def transform_callback_to_task_format(task_id: str, event_data: dict) -> d # Build minimal event data - global counts will be added at broadcast time return { - "change_type": "update", # Use "update" so it gets processed by existing frontend logic + "change_type": "update", "tasks": [task_object], # Frontend expects tasks array "current_timestamp": time.time(), "updated_count": 1, @@ -253,12 +330,12 @@ async def trigger_sse_update(task_id: str, reason: str = "task_update"): task_info, last_status, task_id, current_time, dummy_request ) - # Create minimal event data - global counts will be added at broadcast time + # Create standardized event data - global counts will be added at broadcast time event_data = { "tasks": [task_response], "current_timestamp": current_time, "since_timestamp": current_time, - "change_type": "realtime", + "change_type": "update", "trigger_reason": reason, } @@ -419,6 +496,14 @@ def add_global_task_counts_to_event(event_data): event_data["active_tasks"] = global_task_counts["active"] event_data["all_tasks_count"] = sum(global_task_counts.values()) + # Ensure tasks array is present for schema consistency + if "tasks" not in event_data: + event_data["tasks"] = [] + + # Ensure change_type is present + if "change_type" not in event_data: + event_data["change_type"] = "update" + return event_data except Exception as e: @@ -495,7 +580,11 @@ def _build_task_response( try: item_id = item_url.split("/")[-1] if item_id: - base_url = str(request.base_url).rstrip("/") if request else "http://localhost:7171" + base_url = ( + str(request.base_url).rstrip("/") + if request + else "http://localhost:7171" + ) dynamic_original_url = ( f"{base_url}/api/{download_type}/download/{item_id}" ) @@ -573,7 +662,9 @@ def _build_task_response( return task_response -async def get_paginated_tasks(page=1, limit=20, active_only=False, request: Optional[Request] = None): +async def get_paginated_tasks( + page=1, limit=20, active_only=False, request: Optional[Request] = None +): """ Get paginated list of tasks. """ @@ -1066,47 +1157,18 @@ async def stream_task_updates( try: # Register this client with the broadcaster - logger.debug(f"SSE Stream: New client connecting...") + logger.debug("SSE Stream: New client connecting...") await sse_broadcaster.add_client(client_queue) - logger.debug(f"SSE Stream: Client registered successfully, total clients: {len(sse_broadcaster.clients)}") - - # Send initial data immediately upon connection + logger.debug( + f"SSE Stream: Client registered successfully, total clients: {len(sse_broadcaster.clients)}" + ) + + # Send initial data immediately upon connection (standardized 'update') initial_data = await generate_task_update_event( time.time(), active_only, request ) yield initial_data - # Also send any active tasks as callback-style events to newly connected clients - all_tasks = get_all_tasks() - for task_summary in all_tasks: - task_id = task_summary.get("task_id") - if not task_id: - continue - - task_info = get_task_info(task_id) - if not task_info: - continue - - last_status = get_last_task_status(task_id) - task_status = get_task_status_from_last_status(last_status) - - # Send recent callback data for active or recently completed tasks - if is_task_active(task_status) or ( - last_status and last_status.get("timestamp", 0) > time.time() - 30 - ): - if last_status and "raw_callback" in last_status: - callback_event = { - "task_id": task_id, - "callback_data": last_status["raw_callback"], - "timestamp": last_status.get("timestamp", time.time()), - "change_type": "callback", - "event_type": "progress_update", - "replay": True, # Mark as replay for client - } - event_json = json.dumps(callback_event) - yield f"data: {event_json}\n\n" - logger.debug(f"SSE Stream: Sent replay callback for task {task_id}") - # Send periodic heartbeats and listen for real-time events last_heartbeat = time.time() heartbeat_interval = 30.0 @@ -1173,6 +1235,7 @@ async def stream_task_updates( + task_counts["retrying"], "task_counts": task_counts, "change_type": "heartbeat", + "tasks": [], } event_json = json.dumps(heartbeat_data) @@ -1187,6 +1250,7 @@ async def stream_task_updates( "error": "Internal server error", "timestamp": time.time(), "change_type": "error", + "tasks": [], } ) yield f"data: {error_data}\n\n" @@ -1289,6 +1353,7 @@ async def generate_task_update_event( "current_timestamp": current_time, "updated_count": len(updated_tasks), "since_timestamp": since_timestamp, + "change_type": "update", "initial": True, # Mark as initial load } @@ -1301,7 +1366,12 @@ async def generate_task_update_event( except Exception as e: logger.error(f"Error generating initial SSE event: {e}", exc_info=True) error_data = json.dumps( - {"error": "Failed to load initial data", "timestamp": time.time()} + { + "error": "Failed to load initial data", + "timestamp": time.time(), + "tasks": [], + "change_type": "error", + } ) return f"data: {error_data}\n\n" diff --git a/routes/utils/album.py b/routes/utils/album.py index b6fb6e5..be67078 100755 --- a/routes/utils/album.py +++ b/routes/utils/album.py @@ -101,7 +101,7 @@ def download_album( ) dl.download_albumspo( link_album=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, # Deezer quality recursive_quality=recursive_quality, recursive_download=False, @@ -159,7 +159,7 @@ def download_album( ) spo.download_album( link_album=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=fall_quality, # Spotify quality recursive_quality=recursive_quality, recursive_download=False, @@ -216,7 +216,7 @@ def download_album( ) spo.download_album( link_album=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, recursive_download=False, @@ -260,7 +260,7 @@ def download_album( ) dl.download_albumdee( # Deezer URL, download via Deezer link_album=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, recursive_download=False, diff --git a/routes/utils/celery_config.py b/routes/utils/celery_config.py index 7cd852b..83814fd 100644 --- a/routes/utils/celery_config.py +++ b/routes/utils/celery_config.py @@ -28,7 +28,7 @@ CONFIG_FILE_PATH = Path("./data/config/main.json") DEFAULT_MAIN_CONFIG = { "service": "spotify", - "version": "3.3.0", + "version": "3.3.1", "spotify": "", "deezer": "", "fallback": False, diff --git a/routes/utils/playlist.py b/routes/utils/playlist.py index b19bd7c..efdec27 100755 --- a/routes/utils/playlist.py +++ b/routes/utils/playlist.py @@ -98,7 +98,7 @@ def download_playlist( ) dl.download_playlistspo( link_playlist=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, # Deezer quality recursive_quality=recursive_quality, recursive_download=False, @@ -161,7 +161,7 @@ def download_playlist( ) spo.download_playlist( link_playlist=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=fall_quality, # Spotify quality recursive_quality=recursive_quality, recursive_download=False, @@ -224,7 +224,7 @@ def download_playlist( ) spo.download_playlist( link_playlist=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, recursive_download=False, @@ -268,7 +268,7 @@ def download_playlist( ) dl.download_playlistdee( # Deezer URL, download via Deezer link_playlist=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, # Usually False for playlists to get individual track qualities recursive_download=False, diff --git a/routes/utils/track.py b/routes/utils/track.py index 7499d31..6259482 100755 --- a/routes/utils/track.py +++ b/routes/utils/track.py @@ -94,7 +94,7 @@ def download_track( # download_trackspo means: Spotify URL, download via Deezer dl.download_trackspo( link_track=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, # Deezer quality recursive_quality=recursive_quality, recursive_download=False, @@ -153,7 +153,7 @@ def download_track( ) spo.download_track( link_track=url, # Spotify URL - output_dir="/app/downloads", + output_dir="./downloads", quality_download=fall_quality, # Spotify quality recursive_quality=recursive_quality, recursive_download=False, @@ -211,7 +211,7 @@ def download_track( ) spo.download_track( link_track=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, recursive_download=False, @@ -254,7 +254,7 @@ def download_track( ) dl.download_trackdee( # Deezer URL, download via Deezer link_track=url, - output_dir="/app/downloads", + output_dir="./downloads", quality_download=quality, recursive_quality=recursive_quality, recursive_download=False, diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index a4e7aa6..4a10e78 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -1098,7 +1098,7 @@ def update_playlist_m3u_file(playlist_spotify_id: str): # Get configuration settings output_dir = ( - "/app/downloads" # This matches the output_dir used in download functions + "./downloads" # This matches the output_dir used in download functions ) # Get all tracks for the playlist @@ -1125,14 +1125,14 @@ def update_playlist_m3u_file(playlist_spotify_id: str): skipped_missing_final_path = 0 for track in tracks: - # Use final_path from deezspot summary and convert from /app/downloads to ../ relative path + # Use final_path from deezspot summary and convert from ./downloads to ../ relative path final_path = track.get("final_path") if not final_path: skipped_missing_final_path += 1 continue normalized = str(final_path).replace("\\", "/") - if normalized.startswith("/app/downloads/"): - relative_path = normalized.replace("/app/downloads/", "../", 1) + if normalized.startswith("./downloads/"): + relative_path = normalized.replace("./downloads/", "../", 1) elif "/downloads/" in normalized.lower(): idx = normalized.lower().rfind("/downloads/") relative_path = "../" + normalized[idx + len("/downloads/") :] diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json index 66fe2e1..8cc0b17 100644 --- a/spotizerr-ui/package.json +++ b/spotizerr-ui/package.json @@ -1,7 +1,7 @@ { "name": "spotizerr-ui", "private": true, - "version": "3.3.0", + "version": "3.3.1", "type": "module", "scripts": { "dev": "vite", diff --git a/spotizerr-ui/src/components/Queue.tsx b/spotizerr-ui/src/components/Queue.tsx index 19cbb1f..b62f7c2 100644 --- a/spotizerr-ui/src/components/Queue.tsx +++ b/spotizerr-ui/src/components/Queue.tsx @@ -772,7 +772,7 @@ export const Queue = () => { const priorities = { "real-time": 1, downloading: 2, processing: 3, initializing: 4, retrying: 5, queued: 6, done: 7, completed: 7, error: 8, cancelled: 9, skipped: 10 - }; + } as Record; return priorities[status as keyof typeof priorities] || 10; }; diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx index 29f8f94..c8297c7 100644 --- a/spotizerr-ui/src/components/config/AccountsTab.tsx +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -103,6 +103,7 @@ export function AccountsTab() { }, onError: (error) => { const msg = extractApiErrorMessage(error); + toast.error(msg); }, }); diff --git a/spotizerr-ui/src/contexts/QueueProvider.tsx b/spotizerr-ui/src/contexts/QueueProvider.tsx index 9c793c4..17d8555 100644 --- a/spotizerr-ui/src/contexts/QueueProvider.tsx +++ b/spotizerr-ui/src/contexts/QueueProvider.tsx @@ -10,7 +10,7 @@ import { } from "./queue-context"; import { toast } from "sonner"; import { v4 as uuidv4 } from "uuid"; -import type { CallbackObject } from "@/types/callbacks"; +import type { CallbackObject, SummaryObject, IDs } from "@/types/callbacks"; import { useAuth } from "@/contexts/auth-context"; export function QueueProvider({ children }: { children: ReactNode }) { @@ -43,54 +43,89 @@ export function QueueProvider({ children }: { children: ReactNode }) { return items.filter(item => isActiveStatus(getStatus(item))).length; }, [items]); - // Improved deduplication - check both id and taskId fields - const itemExists = useCallback((taskId: string, items: QueueItem[]): boolean => { - return items.some(item => - item.id === taskId || - item.taskId === taskId || - // Also check spotify ID to prevent same track being added multiple times - (item.spotifyId && item.spotifyId === taskId) - ); + const extractIDs = useCallback((cb?: CallbackObject): IDs | undefined => { + if (!cb) return undefined; + if ((cb as any).track) return (cb as any).track.ids as IDs; + if ((cb as any).album) return (cb as any).album.ids as IDs; + if ((cb as any).playlist) return (cb as any).playlist.ids as IDs; + return undefined; }, []); // Convert SSE task data to QueueItem const createQueueItemFromTask = useCallback((task: any): QueueItem => { - const spotifyId = task.original_url?.split("/").pop() || ""; + const lastCallback = task.last_line as CallbackObject | undefined; + const ids = extractIDs(lastCallback); + + // Determine container type up-front + const downloadType = (task.download_type || task.type || "track") as DownloadType; + + // Compute spotifyId fallback chain + const fallbackFromUrl = task.original_url?.split("/").pop() || ""; + const spotifyId = ids?.spotify || fallbackFromUrl || ""; // Extract display info from callback - let name = task.name || "Unknown"; - let artist = task.artist || ""; + let name: string = task.name || "Unknown"; + let artist: string = task.artist || ""; - // Handle different callback structures - if (task.last_line) { - try { - if ("track" in task.last_line) { - name = task.last_line.track.title || name; - artist = task.last_line.track.artists?.[0]?.name || artist; - } else if ("album" in task.last_line) { - name = task.last_line.album.title || name; - artist = task.last_line.album.artists?.map((a: any) => a.name).join(", ") || artist; - } else if ("playlist" in task.last_line) { - name = task.last_line.playlist.title || name; - artist = task.last_line.playlist.owner?.name || artist; + try { + if (lastCallback) { + if ((lastCallback as any).track) { + // Prefer parent container title if this is an album/playlist operation + const parent = (lastCallback as any).parent; + if (downloadType === "playlist" && parent && (parent as any).title) { + name = (parent as any).title || name; + artist = (parent as any).owner?.name || artist; + } else if (downloadType === "album" && parent && (parent as any).title) { + name = (parent as any).title || name; + const arts = (parent as any).artists || []; + artist = Array.isArray(arts) && arts.length > 0 ? (arts.map((a: any) => a.name).filter(Boolean).join(", ")) : artist; + } else { + // Fallback to the current track's info for standalone track downloads + name = (lastCallback as any).track.title || name; + const arts = (lastCallback as any).track.artists || []; + artist = Array.isArray(arts) && arts.length > 0 ? (arts.map((a: any) => a.name).filter(Boolean).join(", ")) : artist; + } + } else if ((lastCallback as any).album) { + name = (lastCallback as any).album.title || name; + const arts = (lastCallback as any).album.artists || []; + artist = Array.isArray(arts) && arts.length > 0 ? (arts.map((a: any) => a.name).filter(Boolean).join(", ")) : artist; + } else if ((lastCallback as any).playlist) { + name = (lastCallback as any).playlist.title || name; + artist = (lastCallback as any).playlist.owner?.name || artist; + } else if ((lastCallback as any).status === "processing") { + name = (lastCallback as any).name || name; + artist = (lastCallback as any).artist || artist; } - } catch (error) { - console.warn(`createQueueItemFromTask: Error parsing callback for task ${task.task_id}:`, error); } + } catch (error) { + console.warn(`createQueueItemFromTask: Error parsing callback for task ${task.task_id}:`, error); + } + + // Prefer summary from callback status_info if present; fallback to task.summary + let summary: SummaryObject | undefined = undefined; + try { + const statusInfo = (lastCallback as any)?.status_info; + if (statusInfo && typeof statusInfo === "object" && "summary" in statusInfo) { + summary = (statusInfo as any).summary || undefined; + } + } catch {} + if (!summary && task.summary) { + summary = task.summary as SummaryObject; } const queueItem: QueueItem = { id: task.task_id, taskId: task.task_id, - downloadType: task.download_type || task.type || "track", + downloadType, spotifyId, - lastCallback: task.last_line as CallbackObject, + ids, + lastCallback: lastCallback as CallbackObject, name, artist, - summary: task.summary, + summary, error: task.error, }; - + // Debug log for status detection issues const status = getStatus(queueItem); if (status === "unknown" || !status) { @@ -98,7 +133,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { } return queueItem; - }, []); + }, [extractIDs]); // Schedule auto-removal for completed tasks const scheduleRemoval = useCallback((taskId: string, delay: number = 10000) => { @@ -162,7 +197,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { sseConnection.current = eventSource; - eventSource.onopen = () => { + eventSource.onopen = () => { console.log("SSE connected successfully"); reconnectAttempts.current = 0; lastHeartbeat.current = Date.now(); @@ -172,47 +207,47 @@ export function QueueProvider({ children }: { children: ReactNode }) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } - }; + }; - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - // Debug logging for all SSE events - console.log("🔄 SSE Event Received:", { - timestamp: new Date().toISOString(), - changeType: data.change_type || "update", - totalTasks: data.total_tasks, - taskCounts: data.task_counts, - tasksCount: data.tasks?.length || 0, - taskIds: data.tasks?.map((t: any) => { - const tempItem = createQueueItemFromTask(t); - const status = getStatus(tempItem); - // Special logging for playlist/album track progress - if (t.last_line?.current_track && t.last_line?.total_tracks) { - return { - id: t.task_id, - status, - type: t.download_type, - track: `${t.last_line.current_track}/${t.last_line.total_tracks}`, - trackStatus: t.last_line.status_info?.status - }; - } - return { id: t.task_id, status, type: t.download_type }; - }) || [], - rawData: data - }); - - if (data.error) { + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Debug logging for all SSE events + console.log("🔄 SSE Event Received:", { + timestamp: new Date().toISOString(), + changeType: data.change_type || "update", + totalTasks: data.total_tasks, + taskCounts: data.task_counts, + tasksCount: data.tasks?.length || 0, + taskIds: data.tasks?.map((t: any) => { + const tempItem = createQueueItemFromTask(t); + const status = getStatus(tempItem); + // Special logging for playlist/album track progress + if (t.last_line?.current_track && t.last_line?.total_tracks) { + return { + id: t.task_id, + status, + type: t.download_type, + track: `${t.last_line.current_track}/${t.last_line.total_tracks}`, + trackStatus: t.last_line.status_info?.status + }; + } + return { id: t.task_id, status, type: t.download_type }; + }) || [], + rawData: data + }); + + if (data.error) { console.error("SSE error:", data.error); toast.error("Connection error"); - return; - } + return; + } - // Handle different message types from optimized backend + // Handle message types from backend const changeType = data.change_type || "update"; const triggerReason = data.trigger_reason || ""; - + if (changeType === "heartbeat") { // Heartbeat - just update counts, no task processing const { total_tasks, task_counts } = data; @@ -221,7 +256,6 @@ export function QueueProvider({ children }: { children: ReactNode }) { (total_tasks || 0); setTotalTasks(calculatedTotal); lastHeartbeat.current = Date.now(); - // Reduce heartbeat logging noise - only log every 10th heartbeat if (Math.random() < 0.1) { console.log("SSE: Connection active (heartbeat)"); } @@ -249,9 +283,10 @@ export function QueueProvider({ children }: { children: ReactNode }) { setItems(prev => { // Create improved deduplication maps - const existingTaskIds = new Set(); - const existingSpotifyIds = new Set(); - const existingItemsMap = new Map(); + const existingTaskIds = new Set(); + const existingSpotifyIds = new Set(); + const existingDeezerIds = new Set(); + const existingItemsMap = new Map(); prev.forEach(item => { if (item.id) { @@ -263,6 +298,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { existingItemsMap.set(item.taskId, item); } if (item.spotifyId) existingSpotifyIds.add(item.spotifyId); + if (item.ids?.deezer) existingDeezerIds.add(item.ids.deezer); }); // Process each updated task @@ -271,33 +307,37 @@ export function QueueProvider({ children }: { children: ReactNode }) { const newTasksToAdd: QueueItem[] = []; for (const task of updatedTasks) { - const taskId = task.task_id; - const spotifyId = task.original_url?.split("/").pop(); + const taskId = task.task_id as string; // Skip if already processed (shouldn't happen but safety check) if (processedTaskIds.has(taskId)) continue; processedTaskIds.add(taskId); // Check if this task exists in current queue - const existingItem = existingItemsMap.get(taskId) || - Array.from(existingItemsMap.values()).find(item => - item.spotifyId === spotifyId - ); + const existingItem = existingItemsMap.get(taskId); + const newItemCandidate = createQueueItemFromTask(task); + const candidateSpotify = newItemCandidate.spotifyId; + const candidateDeezer = newItemCandidate.ids?.deezer; - if (existingItem) { + // If not found by id, try to match by identifiers + const existingById = existingItem || Array.from(existingItemsMap.values()).find(item => + (candidateSpotify && item.spotifyId === candidateSpotify) || + (candidateDeezer && item.ids?.deezer === candidateDeezer) + ); + + if (existingById) { // Skip SSE updates for items that are already cancelled by user action - const existingStatus = getStatus(existingItem); - if (existingStatus === "cancelled" && existingItem.error === "Cancelled by user") { + const existingStatus = getStatus(existingById); + if (existingStatus === "cancelled" && existingById.error === "Cancelled by user") { console.log(`SSE: Skipping update for user-cancelled task ${taskId}`); continue; } // Update existing item - const updatedItem = createQueueItemFromTask(task); + const updatedItem = newItemCandidate; const status = getStatus(updatedItem); - const previousStatus = getStatus(existingItem); + const previousStatus = getStatus(existingById); - // Only log significant status changes if (previousStatus !== status) { console.log(`SSE: Status change ${taskId}: ${previousStatus} → ${status}`); } @@ -305,33 +345,32 @@ export function QueueProvider({ children }: { children: ReactNode }) { // Schedule removal for terminal states if (isTerminalStatus(status)) { const delay = status === "cancelled" ? 5000 : 10000; - scheduleRemoval(existingItem.id, delay); + scheduleRemoval(existingById.id, delay); console.log(`SSE: Scheduling removal for terminal task ${taskId} (${status}) in ${delay}ms`); } updatedItems.push(updatedItem); } else { // This is a new task from SSE - const newItem = createQueueItemFromTask(task); + const newItem = newItemCandidate; const status = getStatus(newItem); - // Check for duplicates by spotify ID - if (spotifyId && existingSpotifyIds.has(spotifyId)) { - console.log(`SSE: Skipping duplicate by spotify ID: ${spotifyId}`); + // Check for duplicates by identifiers + if ((candidateSpotify && existingSpotifyIds.has(candidateSpotify)) || + (candidateDeezer && existingDeezerIds.has(candidateDeezer))) { + console.log(`SSE: Skipping duplicate by identifier: ${candidateSpotify || candidateDeezer}`); continue; } - // Check if this is a pending download - if (pendingDownloads.current.has(spotifyId || taskId)) { + // Check if this is a pending download (by spotify id for now) + if (pendingDownloads.current.has(candidateSpotify || newItem.id)) { console.log(`SSE: Skipping pending download: ${taskId}`); continue; } - // For terminal tasks from SSE, these should be tasks that just transitioned - // (backend now filters out already-terminal tasks) + // For terminal tasks from SSE if (isTerminalStatus(status)) { console.log(`SSE: Adding recently completed task: ${taskId} (${status})`); - // Schedule immediate removal for terminal tasks const delay = status === "cancelled" ? 5000 : 10000; scheduleRemoval(newItem.id, delay); } else if (isActiveStatus(status)) { @@ -349,7 +388,9 @@ export function QueueProvider({ children }: { children: ReactNode }) { const finalItems = prev.map(item => { const updated = updatedItems.find(u => u.id === item.id || u.taskId === item.id || - u.id === item.taskId || u.taskId === item.taskId + u.id === item.taskId || u.taskId === item.taskId || + (u.spotifyId && u.spotifyId === item.spotifyId) || + (u.ids?.deezer && u.ids.deezer === item.ids?.deezer) ); return updated || item; }); @@ -360,69 +401,69 @@ export function QueueProvider({ children }: { children: ReactNode }) { } else if (changeType === "update") { // Update received but no tasks - might be count updates only console.log("SSE: Received update with count changes only"); - } - } catch (error) { - console.error("Failed to parse SSE message:", error, event.data); } - }; + } catch (error) { + console.error("Failed to parse SSE message:", error, event.data); + } + }; eventSource.onerror = (error) => { - // Use appropriate logging level - first attempt failures are common and expected - if (reconnectAttempts.current === 0) { - console.log("SSE initial connection failed, will retry shortly..."); - } else { - console.warn("SSE connection error:", error); - } - - // Only check for auth errors if auth is enabled - if (authEnabled) { - const token = authApiClient.getToken(); - if (!token) { - console.warn("SSE: Connection error and no auth token - stopping reconnection attempts"); - eventSource.close(); - sseConnection.current = null; - stopHealthCheck(); - return; - } - } - - eventSource.close(); - sseConnection.current = null; - - if (reconnectAttempts.current < maxReconnectAttempts) { - reconnectAttempts.current++; - // Use shorter delays for faster recovery, especially on first attempts - const baseDelay = reconnectAttempts.current === 1 ? 100 : 1000; - const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current - 1), 15000); - - if (reconnectAttempts.current === 1) { - console.log("SSE: Retrying connection shortly..."); - } else { - console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`); - } - - reconnectTimeoutRef.current = window.setTimeout(() => { - if (reconnectAttempts.current === 1) { - console.log("SSE: Attempting reconnection..."); - } else { - console.log("SSE: Attempting to reconnect..."); - } - connectSSE(); - }, delay); - } else { - console.error("SSE: Max reconnection attempts reached"); - toast.error("Connection lost. Please refresh the page."); - } - }; - - } catch (error) { - console.log("Initial SSE connection setup failed, will retry:", error); - // Don't show toast for initial connection failures since they often recover quickly - if (reconnectAttempts.current > 0) { - toast.error("Failed to establish connection"); + // Use appropriate logging level - first attempt failures are common and expected + if (reconnectAttempts.current === 0) { + console.log("SSE initial connection failed, will retry shortly..."); + } else { + console.warn("SSE connection error:", error); } + + // Only check for auth errors if auth is enabled + if (authEnabled) { + const token = authApiClient.getToken(); + if (!token) { + console.warn("SSE: Connection error and no auth token - stopping reconnection attempts"); + eventSource.close(); + sseConnection.current = null; + stopHealthCheck(); + return; + } + } + + eventSource.close(); + sseConnection.current = null; + + if (reconnectAttempts.current < maxReconnectAttempts) { + reconnectAttempts.current++; + // Use shorter delays for faster recovery, especially on first attempts + const baseDelay = reconnectAttempts.current === 1 ? 100 : 1000; + const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current - 1), 15000); + + if (reconnectAttempts.current === 1) { + console.log("SSE: Retrying connection shortly..."); + } else { + console.log(`SSE: Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current}/${maxReconnectAttempts})`); + } + + reconnectTimeoutRef.current = window.setTimeout(() => { + if (reconnectAttempts.current === 1) { + console.log("SSE: Attempting reconnection..."); + } else { + console.log("SSE: Attempting to reconnect..."); + } + connectSSE(); + }, delay); + } else { + console.error("SSE: Max reconnection attempts reached"); + toast.error("Connection lost. Please refresh the page."); + } + }; + + } catch (error) { + console.log("Initial SSE connection setup failed, will retry:", error); + // Don't show toast for initial connection failures since they often recover quickly + if (reconnectAttempts.current > 0) { + toast.error("Failed to establish connection"); } - }, [createQueueItemFromTask, scheduleRemoval, startHealthCheck, authEnabled]); + } + }, [createQueueItemFromTask, startHealthCheck, authEnabled, stopHealthCheck]); const disconnectSSE = useCallback(() => { if (sseConnection.current) { @@ -449,17 +490,19 @@ export function QueueProvider({ children }: { children: ReactNode }) { if (newTasks.length > 0) { setItems(prev => { - const uniqueNewTasks = newTasks - .filter((task: any) => !itemExists(task.task_id, prev)) - .filter((task: any) => { - const tempItem = createQueueItemFromTask(task); - const status = getStatus(tempItem); + const extended = newTasks + .map((task: any) => createQueueItemFromTask(task)) + .filter((qi: QueueItem) => { + const status = getStatus(qi); // Consistent filtering - exclude all terminal state tasks in pagination too - return !isTerminalStatus(status); - }) - .map((task: any) => createQueueItemFromTask(task)); - - return [...prev, ...uniqueNewTasks]; + if (isTerminalStatus(status)) return false; + // Dedupe by task id or identifiers + if (prev.some(p => p.id === qi.id || p.taskId === qi.id)) return false; + if (qi.spotifyId && prev.some(p => p.spotifyId === qi.spotifyId)) return false; + if (qi.ids?.deezer && prev.some(p => p.ids?.deezer === qi.ids?.deezer)) return false; + return true; + }); + return [...prev, ...extended]; }); setCurrentPage(nextPage); } @@ -471,7 +514,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { } finally { setIsLoadingMore(false); } - }, [hasMore, isLoadingMore, currentPage, createQueueItemFromTask, itemExists]); + }, [hasMore, isLoadingMore, currentPage, createQueueItemFromTask]); // Note: SSE connection state is managed through the initialize effect and restartSSE method // The auth context should call restartSSE() when login/logout occurs @@ -496,13 +539,11 @@ export function QueueProvider({ children }: { children: ReactNode }) { const { tasks, pagination, total_tasks, task_counts } = response.data; const queueItems = tasks - .filter((task: any) => { - const tempItem = createQueueItemFromTask(task); - const status = getStatus(tempItem); - // On refresh, exclude all terminal state tasks to start with a clean queue + .map((task: any) => createQueueItemFromTask(task)) + .filter((qi: QueueItem) => { + const status = getStatus(qi); return !isTerminalStatus(status); - }) - .map((task: any) => createQueueItemFromTask(task)); + }); console.log(`Queue initialized: ${queueItems.length} items (filtered out terminal state tasks)`); setItems(queueItems); @@ -542,8 +583,8 @@ export function QueueProvider({ children }: { children: ReactNode }) { return; } - // Check if item already exists in queue - if (itemExists(item.spotifyId, items)) { + // Check if item already exists in queue (by spotify id or identifiers on items) + if (items.some(i => i.spotifyId === item.spotifyId || i.ids?.spotify === item.spotifyId)) { toast.info("Item already in queue"); return; } @@ -551,22 +592,22 @@ export function QueueProvider({ children }: { children: ReactNode }) { const tempId = uuidv4(); pendingDownloads.current.add(item.spotifyId); - const newItem: QueueItem = { + const newItem: QueueItem = { id: tempId, downloadType: item.type, spotifyId: item.spotifyId, name: item.name, artist: item.artist || "", - }; + } as QueueItem; - setItems(prev => [newItem, ...prev]); + setItems(prev => [newItem, ...prev]); - try { + try { const response = await authApiClient.client.get(`/${item.type}/download/${item.spotifyId}`); - const { task_id: taskId } = response.data; + const { task_id: taskId } = response.data; - setItems(prev => - prev.map(i => + setItems(prev => + prev.map(i => i.id === tempId ? { ...i, id: taskId, taskId } : i ) ); @@ -575,15 +616,15 @@ export function QueueProvider({ children }: { children: ReactNode }) { pendingDownloads.current.delete(item.spotifyId); connectSSE(); // Ensure connection is active - } catch (error: any) { + } catch (error: any) { console.error(`Failed to start download:`, error); - toast.error(`Failed to start download for ${item.name}`); + toast.error(`Failed to start download for ${item.name}`); // Remove failed item and clear from pending setItems(prev => prev.filter(i => i.id !== tempId)); pendingDownloads.current.delete(item.spotifyId); } - }, [connectSSE, itemExists, items]); + }, [connectSSE, items]); const removeItem = useCallback((id: string) => { const item = items.find(i => i.id === id); @@ -604,32 +645,18 @@ export function QueueProvider({ children }: { children: ReactNode }) { }, [items]); const cancelItem = useCallback(async (id: string) => { - const item = items.find(i => i.id === id); + const item = items.find(i => i.id === id); if (!item?.taskId) return; - try { - await authApiClient.client.post(`/prgs/cancel/${item.taskId}`); - - setItems(prev => - prev.map(i => - i.id === id ? { - ...i, - error: "Cancelled by user", - lastCallback: { - status: "cancelled", - timestamp: Date.now() / 1000, - type: item.downloadType, - name: item.name, - artist: item.artist - } as unknown as CallbackObject - } : i - ) - ); + try { + await authApiClient.client.post(`/prgs/cancel/${item.taskId}`); - // Remove immediately after showing cancelled state briefly + // Mark as cancelled via error field to preserve type safety + setItems(prev => prev.map(i => i.id === id ? { ...i, error: "Cancelled by user" } : i)); + + // Remove shortly after showing cancelled state setTimeout(() => { setItems(prev => prev.filter(i => i.id !== id)); - // Clean up any existing removal timer if (removalTimers.current[id]) { clearTimeout(removalTimers.current[id]); delete removalTimers.current[id]; @@ -637,11 +664,11 @@ export function QueueProvider({ children }: { children: ReactNode }) { }, 500); toast.info(`Cancelled: ${item.name}`); - } catch (error) { + } catch (error) { console.error("Failed to cancel task:", error); toast.error(`Failed to cancel: ${item.name}`); } - }, [items, scheduleRemoval]); + }, [items]); const cancelAll = useCallback(async () => { const activeItems = items.filter(item => { @@ -657,26 +684,11 @@ export function QueueProvider({ children }: { children: ReactNode }) { try { await authApiClient.client.post("/prgs/cancel/all"); + // Mark each active item as cancelled via error field activeItems.forEach(item => { - setItems(prev => - prev.map(i => - i.id === item.id ? { - ...i, - error: "Cancelled by user", - lastCallback: { - status: "cancelled", - timestamp: Date.now() / 1000, - type: item.downloadType, - name: item.name, - artist: item.artist - } as unknown as CallbackObject - } : i - ) - ); - // Remove immediately after showing cancelled state briefly + setItems(prev => prev.map(i => i.id === item.id ? { ...i, error: "Cancelled by user" } : i)); setTimeout(() => { setItems(prev => prev.filter(i => i.id !== item.id)); - // Clean up any existing removal timer if (removalTimers.current[item.id]) { clearTimeout(removalTimers.current[item.id]); delete removalTimers.current[item.id]; @@ -689,7 +701,7 @@ export function QueueProvider({ children }: { children: ReactNode }) { console.error("Failed to cancel all:", error); toast.error("Failed to cancel downloads"); } - }, [items, scheduleRemoval]); + }, [items]); const clearCompleted = useCallback(() => { setItems(prev => prev.filter(item => { diff --git a/spotizerr-ui/src/contexts/queue-context.ts b/spotizerr-ui/src/contexts/queue-context.ts index 22da227..15f54da 100644 --- a/spotizerr-ui/src/contexts/queue-context.ts +++ b/spotizerr-ui/src/contexts/queue-context.ts @@ -1,23 +1,23 @@ import { createContext, useContext } from "react"; -import type { SummaryObject, CallbackObject, TrackCallbackObject, AlbumCallbackObject, PlaylistCallbackObject, ProcessingCallbackObject } from "@/types/callbacks"; +import type { SummaryObject, CallbackObject, TrackCallbackObject, AlbumCallbackObject, PlaylistCallbackObject, ProcessingCallbackObject, IDs } from "@/types/callbacks"; export type DownloadType = "track" | "album" | "playlist"; // Type guards for callback objects const isProcessingCallback = (obj: CallbackObject): obj is ProcessingCallbackObject => { - return "status" in obj && typeof obj.status === "string"; + return "status" in obj && typeof (obj as ProcessingCallbackObject).status === "string" && (obj as any).name !== undefined; }; const isTrackCallback = (obj: CallbackObject): obj is TrackCallbackObject => { - return "track" in obj && "status_info" in obj; + return (obj as any).track !== undefined && (obj as any).status_info !== undefined; }; const isAlbumCallback = (obj: CallbackObject): obj is AlbumCallbackObject => { - return "album" in obj && "status_info" in obj; + return (obj as any).album !== undefined && (obj as any).status_info !== undefined; }; const isPlaylistCallback = (obj: CallbackObject): obj is PlaylistCallbackObject => { - return "playlist" in obj && "status_info" in obj; + return (obj as any).playlist !== undefined && (obj as any).status_info !== undefined; }; // Simplified queue item that works directly with callback objects @@ -27,6 +27,9 @@ export interface QueueItem { downloadType: DownloadType; spotifyId: string; + // Primary identifiers from callback (spotify/deezer/isrc/upc) + ids?: IDs; + // Current callback data - this is the source of truth lastCallback?: CallbackObject; @@ -43,6 +46,11 @@ export interface QueueItem { // Status extraction utilities export const getStatus = (item: QueueItem): string => { + // If user locally cancelled the task, reflect it without fabricating a callback + if (item.error === "Cancelled by user") { + return "cancelled"; + } + if (!item.lastCallback) { // Only log if this seems problematic (task has been around for a while) return "initializing"; @@ -57,32 +65,30 @@ export const getStatus = (item: QueueItem): string => { if (item.downloadType === "album" || item.downloadType === "playlist") { const currentTrack = item.lastCallback.current_track || 1; const totalTracks = item.lastCallback.total_tracks || 1; - const trackStatus = item.lastCallback.status_info.status; + const trackStatus = item.lastCallback.status_info.status as string; // If this is the last track and it's in a terminal state, the parent is done if (currentTrack >= totalTracks && ["done", "skipped", "error"].includes(trackStatus)) { - console.log(`🎵 Playlist/Album completed: ${item.name} (track ${currentTrack}/${totalTracks}, status: ${trackStatus})`); return "completed"; } // If track is in terminal state but not the last track, parent is still downloading if (["done", "skipped", "error"].includes(trackStatus)) { - console.log(`🎵 Playlist/Album progress: ${item.name} (track ${currentTrack}/${totalTracks}, status: ${trackStatus}) - continuing...`); return "downloading"; } // Track is actively being processed return "downloading"; } - return item.lastCallback.status_info.status; + return item.lastCallback.status_info.status as string; } if (isAlbumCallback(item.lastCallback)) { - return item.lastCallback.status_info.status; + return item.lastCallback.status_info.status as string; } if (isPlaylistCallback(item.lastCallback)) { - return item.lastCallback.status_info.status; + return item.lastCallback.status_info.status as string; } console.warn(`getStatus: Unknown callback type for item ${item.id}:`, item.lastCallback); @@ -104,8 +110,8 @@ export const getProgress = (item: QueueItem): number | undefined => { // For individual tracks if (item.downloadType === "track" && isTrackCallback(item.lastCallback)) { - if (item.lastCallback.status_info.status === "real-time" && "progress" in item.lastCallback.status_info) { - return item.lastCallback.status_info.progress; + if ((item.lastCallback.status_info as any).status === "real-time" && "progress" in (item.lastCallback.status_info as any)) { + return (item.lastCallback.status_info as any).progress as number; } return undefined; } @@ -115,8 +121,9 @@ export const getProgress = (item: QueueItem): number | undefined => { const callback = item.lastCallback; const currentTrack = callback.current_track || 1; const totalTracks = callback.total_tracks || 1; - const trackProgress = (callback.status_info.status === "real-time" && "progress" in callback.status_info) - ? callback.status_info.progress : 0; + const statusInfo: any = callback.status_info; + const trackProgress = (statusInfo.status === "real-time" && "progress" in statusInfo) + ? statusInfo.progress : 0; // Formula: ((completed tracks) + (current track progress / 100)) / total tracks * 100 const completedTracks = currentTrack - 1; diff --git a/spotizerr-ui/src/routes/root.tsx b/spotizerr-ui/src/routes/root.tsx index f39cc2b..ca25246 100644 --- a/spotizerr-ui/src/routes/root.tsx +++ b/spotizerr-ui/src/routes/root.tsx @@ -7,6 +7,7 @@ import { ProtectedRoute } from "@/components/auth/ProtectedRoute"; import { UserMenu } from "@/components/auth/UserMenu"; import { useContext, useState, useEffect } from "react"; import { getTheme, toggleTheme } from "@/lib/theme"; +import { useSettings } from "@/contexts/settings-context"; function ThemeToggle() { const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | 'system'>('system'); @@ -80,6 +81,8 @@ function ThemeToggle() { function AppLayout() { const { toggleVisibility, totalTasks } = useContext(QueueContext) || {}; + const { settings } = useSettings(); + const watchEnabled = !!settings?.watch?.enabled; return (
@@ -92,9 +95,11 @@ function AppLayout() {
+ {watchEnabled && ( Watchlist + )} History @@ -144,9 +149,11 @@ function AppLayout() { Home + {watchEnabled && ( Watchlist + )} History diff --git a/spotizerr-ui/src/types/callbacks.ts b/spotizerr-ui/src/types/callbacks.ts index 5e88286..98cfc91 100644 --- a/spotizerr-ui/src/types/callbacks.ts +++ b/spotizerr-ui/src/types/callbacks.ts @@ -222,11 +222,22 @@ export interface SummaryObject { total_successful: number; total_skipped: number; total_failed: number; + // Optional metadata present in deezspot summaries (album/playlist and sometimes single-track) + service: "spotify" | "deezer"; + quality: string; // e.g., "ogg", "flac" + bitrate: string; // e.g., "320k" + m3u_path?: string; // playlist convenience output + // Convenience fields that may appear for single-track flows + final_path?: string; + download_quality?: string; // e.g., "OGG_320" } export interface DoneObject extends BaseStatusObject { status: "done"; summary?: SummaryObject; + // Convenience fields often present on done for tracks + final_path?: string; + download_quality?: string; } export type StatusInfo = diff --git a/tests/migration/__init__.py b/tests/migration/__init__.py deleted file mode 100644 index 0519ecb..0000000 --- a/tests/migration/__init__.py +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tests/migration/test_v3_0_6.py b/tests/migration/test_v3_0_6.py deleted file mode 100644 index 17cdfcb..0000000 --- a/tests/migration/test_v3_0_6.py +++ /dev/null @@ -1,633 +0,0 @@ -import sqlite3 -from pathlib import Path -import pytest -import json - -# Override the autouse credentials fixture from conftest for this module -@pytest.fixture(scope="session", autouse=True) -def setup_credentials_for_tests(): - # No-op to avoid external API calls; this shadows the session autouse fixture in conftest.py - yield - - -def _create_306_history_db(db_path: Path) -> None: - db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(str(db_path)) as conn: - conn.executescript( - """ - CREATE TABLE IF NOT EXISTS download_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - download_type TEXT NOT NULL, - title TEXT NOT NULL, - artists TEXT, - timestamp REAL NOT NULL, - status TEXT NOT NULL, - service TEXT, - quality_format TEXT, - quality_bitrate TEXT, - total_tracks INTEGER, - successful_tracks INTEGER, - failed_tracks INTEGER, - skipped_tracks INTEGER, - children_table TEXT, - task_id TEXT, - external_ids TEXT, - metadata TEXT, - release_date TEXT, - genres TEXT, - images TEXT, - owner TEXT, - album_type TEXT, - duration_total_ms INTEGER, - explicit BOOLEAN - ); - CREATE INDEX IF NOT EXISTS idx_download_history_timestamp ON download_history(timestamp); - CREATE INDEX IF NOT EXISTS idx_download_history_type_status ON download_history(download_type, status); - CREATE INDEX IF NOT EXISTS idx_download_history_task_id ON download_history(task_id); - CREATE UNIQUE INDEX IF NOT EXISTS uq_download_history_task_type_ids ON download_history(task_id, download_type, external_ids); - """ - ) - # Insert rows that reference non-existent children tables - conn.execute( - """ - INSERT INTO download_history ( - download_type, title, artists, timestamp, status, service, - quality_format, quality_bitrate, total_tracks, successful_tracks, - failed_tracks, skipped_tracks, children_table, task_id, - external_ids, metadata, release_date, genres, images, owner, - album_type, duration_total_ms, explicit - ) VALUES (?, ?, ?, strftime('%s','now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "album", - "Test Album", - "[]", - "completed", - "spotify", - "FLAC", - "1411kbps", - 10, - 8, - 1, - 1, - "album_test1", - "task-album-1", - "{}", - "{}", - "{}", - "[]", - "[]", - "{}", - "album", - 123456, - 0, - ), - ) - conn.execute( - """ - INSERT INTO download_history ( - download_type, title, artists, timestamp, status, service, - quality_format, quality_bitrate, total_tracks, successful_tracks, - failed_tracks, skipped_tracks, children_table, task_id, - external_ids, metadata, release_date, genres, images, owner, - album_type, duration_total_ms, explicit - ) VALUES (?, ?, ?, strftime('%s','now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "playlist", - "Test Playlist", - "[]", - "partial", - "spotify", - "MP3", - "320kbps", - 20, - 15, - 3, - 2, - "playlist_test2", - "task-playlist-1", - "{}", - "{}", - "{}", - "[]", - "[]", - "{}", - "", - 654321, - 0, - ), - ) - # Create a legacy children table with too-few columns to test schema upgrade - conn.execute( - "CREATE TABLE IF NOT EXISTS album_legacy (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL)" - ) - # Create a fully-specified children table from docs and add rows - conn.execute( - """ - CREATE TABLE IF NOT EXISTS album_f9e8d7c6b5 ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - artists TEXT, - album_title TEXT, - duration_ms INTEGER, - track_number INTEGER, - disc_number INTEGER, - explicit BOOLEAN, - status TEXT NOT NULL, - external_ids TEXT, - genres TEXT, - isrc TEXT, - timestamp REAL NOT NULL, - position INTEGER, - metadata TEXT - ) - """ - ) - conn.execute( - """ - INSERT INTO download_history ( - download_type, title, artists, timestamp, status, service, - quality_format, quality_bitrate, total_tracks, successful_tracks, - failed_tracks, skipped_tracks, children_table, task_id, - external_ids, metadata, release_date, genres, images, owner, - album_type, duration_total_ms, explicit - ) VALUES (?, ?, ?, strftime('%s','now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "album", - "Random Access Memories", - "[\"Daft Punk\"]", - "partial", - "spotify", - "FLAC", - "1411", - 13, - 12, - 1, - 0, - "album_f9e8d7c6b5", - "celery-task-id-789", - "{\"spotify\": \"4m2880jivSbbyEGAKfITCa\"}", - "{\"callback_type\": \"album\"}", - "{\"year\": 2013, \"month\": 5, \"day\": 17}", - "[\"disco\", \"funk\"]", - "[{\"url\": \"https://i.scdn.co/image/...\"}]", - None, - "album", - 4478293, - 0 - ), - ) - conn.executemany( - """ - INSERT INTO album_f9e8d7c6b5 ( - title, artists, album_title, duration_ms, track_number, disc_number, explicit, status, - external_ids, genres, isrc, timestamp, position, metadata - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'), ?, ?) - """, - [ - ( - "Get Lucky (feat. Pharrell Williams & Nile Rodgers)", - "[\"Daft Punk\", \"Pharrell Williams\", \"Nile Rodgers\"]", - "Random Access Memories", - 369626, - 8, - 1, - 0, - "completed", - "{\"spotify\": \"69kOkLUCdZlE8ApD28j1JG\", \"isrc\": \"GBUJH1300019\"}", - "[]", - "GBUJH1300019", - 0, - "{\"album\": {...}, \"type\": \"track\"}", - ), - ( - "Lose Yourself to Dance (feat. Pharrell Williams)", - "[\"Daft Punk\", \"Pharrell Williams\"]", - "Random Access Memories", - 353893, - 6, - 1, - 0, - "failed", - "{\"spotify\": \"5L95vS64r8PAj5M8H1oYkm\", \"isrc\": \"GBUJH1300017\"}", - "[]", - "GBUJH1300017", - 0, - "{\"album\": {...}, \"failure_reason\": \"Could not find matching track on Deezer.\"}", - ), - ] - ) - - -def _create_306_watch_dbs(playlists_db: Path, artists_db: Path) -> None: - playlists_db.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(str(playlists_db)) as pconn: - pconn.executescript( - """ - CREATE TABLE IF NOT EXISTS watched_playlists ( - spotify_id TEXT PRIMARY KEY, - name TEXT, - owner_id TEXT, - owner_name TEXT, - total_tracks INTEGER, - link TEXT, - snapshot_id TEXT, - last_checked INTEGER, - added_at INTEGER, - is_active INTEGER DEFAULT 1 - ); - """ - ) - # Insert a sample watched playlist row (docs example) - pconn.execute( - """ - INSERT OR REPLACE INTO watched_playlists ( - spotify_id, name, owner_id, owner_name, total_tracks, link, snapshot_id, last_checked, added_at, is_active - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "37i9dQZF1DXcBWIGoYBM5M", - "Today's Top Hits", - "spotify", - "Spotify", - 50, - "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", - "MTY3NzE4NjgwMCwwMDAwMDAwMDk1ODVmYjI5ZDY5MGUzN2Q4Y2U4OWY2YmY1ZDE4ZTAy", - 1677187000, - 1677186950, - 1, - ), - ) - # Create a legacy/minimal playlist dynamic table to test schema upgrade - pconn.execute( - "CREATE TABLE IF NOT EXISTS playlist_legacy (spotify_track_id TEXT PRIMARY KEY, title TEXT)" - ) - # Create a fully-specified playlist dynamic table (docs example) and add rows - pconn.execute( - """ - CREATE TABLE IF NOT EXISTS playlist_37i9dQZF1DXcBWIGoYBM5M ( - spotify_track_id TEXT PRIMARY KEY, - title TEXT, - artist_names TEXT, - album_name TEXT, - album_artist_names TEXT, - track_number INTEGER, - album_spotify_id TEXT, - duration_ms INTEGER, - added_at_playlist TEXT, - added_to_db INTEGER, - is_present_in_spotify INTEGER, - last_seen_in_spotify INTEGER, - snapshot_id TEXT, - final_path TEXT - ) - """ - ) - pconn.executemany( - """ - INSERT OR REPLACE INTO playlist_37i9dQZF1DXcBWIGoYBM5M ( - spotify_track_id, title, artist_names, album_name, album_artist_names, track_number, album_spotify_id, - duration_ms, added_at_playlist, added_to_db, is_present_in_spotify, last_seen_in_spotify, snapshot_id, final_path - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - "4k6Uh1HXdhtusDW5y80vNN", - "As It Was", - "Harry Styles", - "Harry's House", - "Harry Styles", - 4, - "5r36AJ6VOJtp00oxSkNaAO", - 167303, - "2023-02-20T10:00:00Z", - 1677186980, - 1, - 1677187000, - "MTY3NzE4NjgwMCwwMDAwMDAwMDk1ODVmYjI5ZDY5MGUzN2Q4Y2U4OWY2YmY1ZDE4ZTAy", - "/downloads/music/Harry Styles/Harry's House/04 - As It Was.flac", - ), - ( - "5ww2BF9slyYgAno5EAsoOJ", - "Flowers", - "Miley Cyrus", - "Endless Summer Vacation", - "Miley Cyrus", - 1, - "1lw0K2sIKi84gav3e4pG3c", - 194952, - "2023-02-23T12:00:00Z", - 1677186995, - 1, - 1677187000, - "MTY3NzE4NjgwMCwwMDAwMDAwMDk1ODVmYjI5ZDY5MGUzN2Q4Y2U4OWY2YmY1ZDE4ZTAy", - None, - ), - ] - ) - with sqlite3.connect(str(artists_db)) as aconn: - aconn.executescript( - """ - CREATE TABLE IF NOT EXISTS watched_artists ( - spotify_id TEXT PRIMARY KEY, - name TEXT, - link TEXT, - total_albums_on_spotify INTEGER, - last_checked INTEGER, - added_at INTEGER, - is_active INTEGER DEFAULT 1, - genres TEXT, - popularity INTEGER, - image_url TEXT - ); - """ - ) - # Insert a sample watched artist row (docs example) - aconn.execute( - """ - INSERT OR REPLACE INTO watched_artists ( - spotify_id, name, link, total_albums_on_spotify, last_checked, added_at, is_active, genres, popularity, image_url - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "4oLeXFyACqeem2VImYeBFe", - "Madeon", - "https://open.spotify.com/artist/4oLeXFyACqeem2VImYeBFe", - 45, - 1677188000, - 1677187900, - 1, - "electro house, filter house, french house", - 65, - "https://i.scdn.co/image/ab6761610000e5eb...", - ), - ) - # Create a legacy/minimal artist dynamic table to test schema upgrade - aconn.execute( - "CREATE TABLE IF NOT EXISTS artist_legacy (album_spotify_id TEXT PRIMARY KEY, name TEXT)" - ) - # Create a fully-specified artist dynamic table (docs example) and add rows - aconn.execute( - """ - CREATE TABLE IF NOT EXISTS artist_4oLeXFyACqeem2VImYeBFe ( - album_spotify_id TEXT PRIMARY KEY, - artist_spotify_id TEXT, - name TEXT, - album_group TEXT, - album_type TEXT, - release_date TEXT, - release_date_precision TEXT, - total_tracks INTEGER, - link TEXT, - image_url TEXT, - added_to_db INTEGER, - last_seen_on_spotify INTEGER, - download_task_id TEXT, - download_status INTEGER, - is_fully_downloaded_managed_by_app INTEGER - ) - """ - ) - aconn.executemany( - """ - INSERT OR REPLACE INTO artist_4oLeXFyACqeem2VImYeBFe ( - album_spotify_id, artist_spotify_id, name, album_group, album_type, release_date, release_date_precision, - total_tracks, link, image_url, added_to_db, last_seen_on_spotify, download_task_id, download_status, is_fully_downloaded_managed_by_app - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - [ - ( - "2GWMnf2ltOQd2v2T62a2m8", - "4oLeXFyACqeem2VImYeBFe", - "Good Faith", - "album", - "album", - "2019-11-15", - "day", - 10, - "https://open.spotify.com/album/2GWMnf2ltOQd2v2T62a2m8", - "https://i.scdn.co/image/ab67616d0000b273...", - 1677187950, - 1677188000, - "celery-task-id-123", - 2, - 1, - ), - ( - "2smfe2S0AVaxH2I1a5p55n", - "4oLeXFyACqeem2VImYeBFe", - "Gonna Be Good", - "single", - "single", - "2023-01-19", - "day", - 1, - "https://open.spotify.com/album/2smfe2S0AVaxH2I1a5p55n", - "https://i.scdn.co/image/ab67616d0000b273...", - 1677187960, - 1677188000, - "celery-task-id-456", - 1, - 0, - ), - ] - ) - - -def _create_306_accounts(creds_dir: Path, accounts_db: Path) -> None: - creds_dir.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(str(accounts_db)) as conn: - conn.executescript( - """ - CREATE TABLE IF NOT EXISTS spotify ( - name TEXT PRIMARY KEY, - region TEXT, - created_at REAL, - updated_at REAL - ); - CREATE TABLE IF NOT EXISTS deezer ( - name TEXT PRIMARY KEY, - arl TEXT, - region TEXT, - created_at REAL, - updated_at REAL - ); - """ - ) - conn.execute( - "INSERT OR REPLACE INTO spotify (name, region, created_at, updated_at) VALUES (?, ?, ?, ?)", - ("my_main_spotify", "US", 1677190000.0, 1677190000.0), - ) - conn.execute( - "INSERT OR REPLACE INTO deezer (name, arl, region, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", - ("my_hifi_deezer", "a1b2c3d4e5f6a1b2c3d4e5f6...", "FR", 1677190100.0, 1677190100.0), - ) - # Pre-create creds filesystem - search_json = creds_dir / "search.json" - if not search_json.exists(): - search_json.write_text('{"client_id":"your_global_spotify_client_id","client_secret":"your_global_spotify_client_secret"}\n', encoding="utf-8") - blobs_dir = creds_dir / "blobs" / "my_main_spotify" - blobs_dir.mkdir(parents=True, exist_ok=True) - creds_blob = blobs_dir / "credentials.json" - if not creds_blob.exists(): - creds_blob.write_text( - '{"version":"v1","access_token":"...","expires_at":1677193600,"refresh_token":"...","scope":"user-read-private user-read-email playlist-read-private"}\n', - encoding="utf-8", - ) - - -def _get_columns(db_path: Path, table: str) -> set[str]: - with sqlite3.connect(str(db_path)) as conn: - cur = conn.execute(f"PRAGMA table_info({table})") - return {row[1] for row in cur.fetchall()} - - -def _get_count(db_path: Path, table: str) -> int: - with sqlite3.connect(str(db_path)) as conn: - cur = conn.execute(f"SELECT COUNT(*) FROM {table}") - return cur.fetchone()[0] - - -def test_migration_children_tables_created_and_upgraded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - # Arrange temp paths - data_dir = tmp_path / "data" - history_db = data_dir / "history" / "download_history.db" - playlists_db = data_dir / "watch" / "playlists.db" - artists_db = data_dir / "watch" / "artists.db" - creds_dir = data_dir / "creds" - accounts_db = creds_dir / "accounts.db" - blobs_dir = creds_dir / "blobs" - search_json = creds_dir / "search.json" - - # Create 3.0.6 base schemas and sample data (full simulation) - _create_306_history_db(history_db) - _create_306_watch_dbs(playlists_db, artists_db) - _create_306_accounts(creds_dir, accounts_db) - - # Point the migration runner to our temp DBs - from routes.migrations import runner - monkeypatch.setattr(runner, "DATA_DIR", data_dir) - monkeypatch.setattr(runner, "HISTORY_DB", history_db) - monkeypatch.setattr(runner, "WATCH_DIR", data_dir / "watch") - monkeypatch.setattr(runner, "PLAYLISTS_DB", playlists_db) - monkeypatch.setattr(runner, "ARTISTS_DB", artists_db) - monkeypatch.setattr(runner, "CREDS_DIR", creds_dir) - monkeypatch.setattr(runner, "ACCOUNTS_DB", accounts_db) - monkeypatch.setattr(runner, "BLOBS_DIR", blobs_dir) - monkeypatch.setattr(runner, "SEARCH_JSON", search_json) - - # Act: run migrations - runner.run_migrations_if_needed() - # Run twice to ensure idempotency - runner.run_migrations_if_needed() - - # Assert: referenced children tables exist with expected columns - expected_children_cols = { - "id", - "title", - "artists", - "album_title", - "duration_ms", - "track_number", - "disc_number", - "explicit", - "status", - "external_ids", - "genres", - "isrc", - "timestamp", - "position", - "metadata", - } - assert _get_columns(history_db, "album_test1").issuperset(expected_children_cols) - assert _get_columns(history_db, "playlist_test2").issuperset(expected_children_cols) - # Legacy table upgraded - assert _get_columns(history_db, "album_legacy").issuperset(expected_children_cols) - # Pre-existing children table preserved and correct - assert _get_columns(history_db, "album_f9e8d7c6b5").issuperset(expected_children_cols) - assert _get_count(history_db, "album_f9e8d7c6b5") == 2 - - # Assert: accounts DB created/preserved with expected tables and columns - assert accounts_db.exists() - spotify_cols = _get_columns(accounts_db, "spotify") - deezer_cols = _get_columns(accounts_db, "deezer") - assert {"name", "region", "created_at", "updated_at"}.issubset(spotify_cols) - assert {"name", "arl", "region", "created_at", "updated_at"}.issubset(deezer_cols) - - # Assert: creds filesystem and pre-existing blob preserved - assert blobs_dir.exists() and blobs_dir.is_dir() - assert search_json.exists() - data = json.loads(search_json.read_text()) - assert set(data.keys()) == {"client_id", "client_secret"} - assert (blobs_dir / "my_main_spotify" / "credentials.json").exists() - - # Assert: watch playlists core and dynamic tables upgraded to/at 3.1.2 schema - watched_playlists_cols = _get_columns(playlists_db, "watched_playlists") - assert { - "spotify_id", - "name", - "owner_id", - "owner_name", - "total_tracks", - "link", - "snapshot_id", - "last_checked", - "added_at", - "is_active", - }.issubset(watched_playlists_cols) - playlist_dynamic_expected = { - "spotify_track_id", - "title", - "artist_names", - "album_name", - "album_artist_names", - "track_number", - "album_spotify_id", - "duration_ms", - "added_at_playlist", - "added_to_db", - "is_present_in_spotify", - "last_seen_in_spotify", - "snapshot_id", - "final_path", - } - assert _get_columns(playlists_db, "playlist_legacy").issuperset(playlist_dynamic_expected) - assert _get_columns(playlists_db, "playlist_37i9dQZF1DXcBWIGoYBM5M").issuperset(playlist_dynamic_expected) - assert _get_count(playlists_db, "playlist_37i9dQZF1DXcBWIGoYBM5M") == 2 - - # Assert: watch artists core and dynamic tables upgraded to/at 3.1.2 schema - watched_artists_cols = _get_columns(artists_db, "watched_artists") - assert { - "spotify_id", - "name", - "link", - "total_albums_on_spotify", - "last_checked", - "added_at", - "is_active", - "genres", - "popularity", - "image_url", - }.issubset(watched_artists_cols) - artist_dynamic_expected = { - "album_spotify_id", - "artist_spotify_id", - "name", - "album_group", - "album_type", - "release_date", - "release_date_precision", - "total_tracks", - "link", - "image_url", - "added_to_db", - "last_seen_on_spotify", - "download_task_id", - "download_status", - "is_fully_downloaded_managed_by_app", - } - assert _get_columns(artists_db, "artist_legacy").issuperset(artist_dynamic_expected) - assert _get_columns(artists_db, "artist_4oLeXFyACqeem2VImYeBFe").issuperset(artist_dynamic_expected) - assert _get_count(artists_db, "artist_4oLeXFyACqeem2VImYeBFe") == 2 \ No newline at end of file diff --git a/tests/migration/test_v3_1_0.py b/tests/migration/test_v3_1_0.py deleted file mode 100644 index 3447fe1..0000000 --- a/tests/migration/test_v3_1_0.py +++ /dev/null @@ -1,65 +0,0 @@ -import sqlite3 -from pathlib import Path -import pytest - -import sqlite3 -from pathlib import Path -import pytest - -from routes.migrations.v3_1_0 import MigrationV3_1_0 - -# Override the autouse credentials fixture from conftest for this module -@pytest.fixture(scope="session", autouse=True) -def setup_credentials_for_tests(): - # No-op to avoid external API calls - yield - - -def _create_310_watch_artists_db(db_path: Path) -> None: - db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(str(db_path)) as conn: - conn.executescript( - """ - CREATE TABLE watched_artists ( - spotify_id TEXT PRIMARY KEY, - name TEXT - ); - CREATE TABLE "artist_a1b2c3" ( - album_spotify_id TEXT PRIMARY KEY, - artist_spotify_id TEXT, - name TEXT, - album_type TEXT, - release_date TEXT, - total_tracks INTEGER, - link TEXT, - image_url TEXT, - added_to_db INTEGER, - last_seen_on_spotify INTEGER - ); - """ - ) - conn.execute("INSERT INTO watched_artists (spotify_id) VALUES (?)", ('a1b2c3',)) - - -def test_watch_artists_migration(tmp_path): - # 1. Setup mock v3.1.0 database - db_path = tmp_path / "artists.db" - _create_310_watch_artists_db(db_path) - - # 2. Run the migration - migration = MigrationV3_1_0() - with sqlite3.connect(db_path) as conn: - # Sanity check before migration - cur = conn.execute('PRAGMA table_info("artist_a1b2c3")') - columns_before = {row[1] for row in cur.fetchall()} - assert 'download_status' not in columns_before - - # Apply migration - migration.update_watch_artists(conn) - - # 3. Assert migration was successful - cur = conn.execute('PRAGMA table_info("artist_a1b2c3")') - columns_after = {row[1] for row in cur.fetchall()} - - expected_columns = migration.ARTIST_ALBUMS_EXPECTED_COLUMNS.keys() - assert set(expected_columns).issubset(columns_after) diff --git a/tests/migration/test_v3_1_1.py b/tests/migration/test_v3_1_1.py deleted file mode 100644 index ac90fde..0000000 --- a/tests/migration/test_v3_1_1.py +++ /dev/null @@ -1,135 +0,0 @@ -import sqlite3 -import unittest -from pathlib import Path -from tempfile import mkdtemp -from shutil import rmtree -import pytest - -from routes.migrations.v3_1_1 import MigrationV3_1_1 - -# Override the autouse credentials fixture from conftest for this module -@pytest.fixture(scope="session", autouse=True) -def setup_credentials_for_tests(): - # No-op to avoid external API calls; this shadows the session autouse fixture in conftest.py - yield - - -class TestMigrationV3_1_1(unittest.TestCase): - """ - Tests the dummy migration from 3.1.1 to 3.1.2, ensuring no changes are made. - """ - - def setUp(self): - self.temp_dir = Path(mkdtemp()) - self.history_db_path = self.temp_dir / "history" / "download_history.db" - self.artists_db_path = self.temp_dir / "watch" / "artists.db" - self.playlists_db_path = self.temp_dir / "watch" / "playlists.db" - self.accounts_db_path = self.temp_dir / "creds" / "accounts.db" - self._create_mock_databases() - - def tearDown(self): - rmtree(self.temp_dir) - - def _get_db_schema(self, db_path: Path) -> dict: - """Helper to get the schema of a database.""" - schema = {} - with sqlite3.connect(db_path) as conn: - cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table';") - tables = [row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")] - for table_name in tables: - info_cursor = conn.execute(f'PRAGMA table_info("{table_name}")') - schema[table_name] = {row[1] for row in info_cursor.fetchall()} - return schema - - def _create_mock_databases(self): - """Creates a set of mock databases with the 3.1.1 schema.""" - # History DB - self.history_db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(self.history_db_path) as conn: - conn.executescript( - """ - CREATE TABLE download_history ( - id INTEGER PRIMARY KEY, download_type TEXT, title TEXT, artists TEXT, - timestamp REAL, status TEXT, service TEXT, quality_format TEXT, - quality_bitrate TEXT, total_tracks INTEGER, successful_tracks INTEGER, - failed_tracks INTEGER, skipped_tracks INTEGER, children_table TEXT, - task_id TEXT, external_ids TEXT, metadata TEXT, release_date TEXT, - genres TEXT, images TEXT, owner TEXT, album_type TEXT, - duration_total_ms INTEGER, explicit BOOLEAN - ); - CREATE TABLE playlist_p1l2a3 ( - id INTEGER PRIMARY KEY, title TEXT, artists TEXT, album_title TEXT, - duration_ms INTEGER, track_number INTEGER, disc_number INTEGER, - explicit BOOLEAN, status TEXT, external_ids TEXT, genres TEXT, - isrc TEXT, timestamp REAL, position INTEGER, metadata TEXT - ); - """ - ) - - # Watch Artists DB - self.artists_db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(self.artists_db_path) as conn: - conn.executescript( - """ - CREATE TABLE watched_artists (id TEXT PRIMARY KEY, children_table TEXT); - INSERT INTO watched_artists (id, children_table) VALUES ('a1b2c3d4', 'artist_a1b2c3d4'); - CREATE TABLE artist_a1b2c3d4 ( - id TEXT PRIMARY KEY, title TEXT, artists TEXT, album_type TEXT, - release_date TEXT, total_tracks INTEGER, external_ids TEXT, - images TEXT, album_group TEXT, release_date_precision TEXT, - download_task_id TEXT, download_status TEXT, - is_fully_downloaded_managed_by_app BOOLEAN - ); - """ - ) - - # Watch Playlists DB - self.playlists_db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(self.playlists_db_path) as conn: - conn.executescript( - """ - CREATE TABLE watched_playlists (id TEXT PRIMARY KEY, children_table TEXT); - CREATE TABLE playlist_p1l2a3 (id TEXT PRIMARY KEY, title TEXT); - """ - ) - - # Accounts DB - self.accounts_db_path.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(self.accounts_db_path) as conn: - conn.execute("CREATE TABLE accounts (id TEXT PRIMARY KEY, service TEXT, details TEXT);") - - def test_migration_leaves_schema_unchanged(self): - """Asserts that the dummy migration makes no changes to any database.""" - # Get initial schemas - initial_schemas = { - "history": self._get_db_schema(self.history_db_path), - "artists": self._get_db_schema(self.artists_db_path), - "playlists": self._get_db_schema(self.playlists_db_path), - "accounts": self._get_db_schema(self.accounts_db_path), - } - - # Run the dummy migration - migration = MigrationV3_1_1() - with sqlite3.connect(self.history_db_path) as conn: - migration.update_history(conn) - with sqlite3.connect(self.artists_db_path) as conn: - migration.update_watch_artists(conn) - with sqlite3.connect(self.playlists_db_path) as conn: - migration.update_watch_playlists(conn) - with sqlite3.connect(self.accounts_db_path) as conn: - migration.update_accounts(conn) - - # Get final schemas - final_schemas = { - "history": self._get_db_schema(self.history_db_path), - "artists": self._get_db_schema(self.artists_db_path), - "playlists": self._get_db_schema(self.playlists_db_path), - "accounts": self._get_db_schema(self.accounts_db_path), - } - - # Assert schemas are identical - self.assertEqual(initial_schemas, final_schemas) - - -if __name__ == '__main__': - unittest.main()