diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a47f964..0239bb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,12 @@ # Contributing guidelines +## Commit format + - All pull requests must be made to `dev` branch +- Use [conventional commit messages](https://www.conventionalcommits.org/en/v1.0.0/). E.g. `feat: add feature` or `fix: resolve issue #69420` + + +## Feature philosophy - When implementing a feature related to downloading, follow the rule of choice: Every download must come from an active decision made by the user (e.g. clicking a download button, deciding the user wants a whole artist's discography, etc.). This takes out of the picture features like recommendation algorithms, auto-genererated playlists, etc. + diff --git a/Dockerfile b/Dockerfile index f6e38b3..739f4a8 100755 --- a/Dockerfile +++ b/Dockerfile @@ -19,16 +19,15 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - gosu \ - git \ - ffmpeg \ + ffmpeg gosu\ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Install Python dependencies COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/ +RUN uv pip install --system -r requirements.txt # Copy application code (excluding UI source and TS source) COPY . . diff --git a/app.py b/app.py index b520e4a..c24c822 100755 --- a/app.py +++ b/app.py @@ -25,6 +25,7 @@ except Exception as e: logging.getLogger(__name__).error( f"Database migration step failed early in startup: {e}", exc_info=True ) + sys.exit(1) # Import route routers (to be created) from routes.auth.credentials import router as credentials_router @@ -47,6 +48,9 @@ from routes.utils.celery_config import REDIS_URL from routes.auth import AUTH_ENABLED from routes.auth.middleware import AuthMiddleware +# Import watch manager controls (start/stop) without triggering side effects +from routes.utils.watch.manager import start_watch_manager, stop_watch_manager + # Import and initialize routes (this will start the watch manager) @@ -166,9 +170,25 @@ async def lifespan(app: FastAPI): except Exception as e: logging.error(f"Failed to start Celery workers: {e}") + # Start Watch Manager after Celery is up + try: + start_watch_manager() + logging.info("Watch Manager initialized and registered for shutdown.") + except Exception as e: + logging.error( + f"Could not start Watch Manager: {e}. Watch functionality will be disabled.", + exc_info=True, + ) + yield # Shutdown + try: + stop_watch_manager() + logging.info("Watch Manager stopped") + except Exception as e: + logging.error(f"Error stopping Watch Manager: {e}") + try: celery_manager.stop() logging.info("Celery workers stopped") diff --git a/routes/__init__.py b/routes/__init__.py index 85dd144..2fa27c9 100755 --- a/routes/__init__.py +++ b/routes/__init__.py @@ -1,36 +1,7 @@ import logging -import atexit # Configure basic logging for the application if not already configured -# This is a good place for it if routes are a central part of your app structure. -logging.basicConfig( - level=logging.INFO, format="%(message)s" -) +# This remains safe to execute on import +logging.basicConfig(level=logging.INFO, format="%(message)s") logger = logging.getLogger(__name__) - -# Run DB migrations early so other modules see expected schemas -try: - from routes.migrations import run_migrations_if_needed - run_migrations_if_needed() - logger.info("Database migrations executed (if needed).") -except Exception as e: - logger.error(f"Database migration step failed: {e}", exc_info=True) - -try: - from routes.utils.watch.manager import start_watch_manager, stop_watch_manager - - # Start the playlist watch manager when the application/blueprint is initialized - start_watch_manager() - # Register the stop function to be called on application exit - atexit.register(stop_watch_manager) - logger.info("Playlist Watch Manager initialized and registered for shutdown.") -except ImportError as e: - logger.error( - f"Could not import or start Playlist Watch Manager: {e}. Playlist watching will be disabled." - ) -except Exception as e: - logger.error( - f"An unexpected error occurred during Playlist Watch Manager setup: {e}", - exc_info=True, - ) diff --git a/routes/auth/credentials.py b/routes/auth/credentials.py index e0e7c28..8b8496f 100755 --- a/routes/auth/credentials.py +++ b/routes/auth/credentials.py @@ -129,6 +129,20 @@ async def handle_create_credential(service: str, name: str, request: Request, cu # For Deezer, it expects 'arl' and 'region' # Validation is handled within create_credential utility function result = create_credential(service, name, data) + + # set as active Spotify account if none is set + if service == "spotify": + try: + from routes.utils.celery_config import get_config_params as get_main_config_params + from routes.system.config import save_config + config = get_main_config_params() + # The field is likely "spotify" (as used in frontend) + if not config.get("spotify"): + config["spotify"] = name + save_config(config) + except Exception as e: + logger.warning(f"Could not set new Spotify account '{name}' as active: {e}") + return { "message": f"Credential for '{name}' ({service}) created successfully.", "details": result, diff --git a/routes/migrations/runner.py b/routes/migrations/runner.py index f9117af..820ac28 100644 --- a/routes/migrations/runner.py +++ b/routes/migrations/runner.py @@ -3,10 +3,7 @@ import sqlite3 from pathlib import Path from typing import Optional -from .v3_0_6 import MigrationV3_0_6 -from .v3_1_0 import MigrationV3_1_0 -from .v3_1_1 import MigrationV3_1_1 -from .v3_1_2 import MigrationV3_1_2 +from .v3_2_0 import MigrationV3_2_0 logger = logging.getLogger(__name__) @@ -41,7 +38,7 @@ CHILDREN_EXPECTED_COLUMNS: dict[str, str] = { "metadata": "TEXT", } -# 3.1.2 expected schemas for Watch DBs (kept here to avoid importing modules with side-effects) +# 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", @@ -103,10 +100,7 @@ EXPECTED_ARTIST_ALBUMS_COLUMNS: dict[str, str] = { "is_fully_downloaded_managed_by_app": "INTEGER DEFAULT 0", } -m306 = MigrationV3_0_6() -m310 = MigrationV3_1_0() -m311 = MigrationV3_1_1() -m312 = MigrationV3_1_2() +m320 = MigrationV3_2_0() def _safe_connect(path: Path) -> Optional[sqlite3.Connection]: @@ -184,60 +178,53 @@ def _create_or_update_children_table(conn: sqlite3.Connection, table_name: str) ) -def _update_children_tables_for_history(conn: sqlite3.Connection) -> None: +# --- 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: - 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(): - table_name = row[0] - if not table_name: - continue - _create_or_update_children_table(conn, table_name) - except sqlite3.Error as e: - logger.warning( - f"Failed to scan referenced children tables from main history: {e}" - ) - - 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(): - table_name = row[0] - _create_or_update_children_table(conn, table_name) - except sqlite3.Error as e: - logger.warning(f"Failed to scan legacy children tables in history DB: {e}") - logger.info("Children history tables migration ensured") - except Exception: - logger.error("Failed migrating children history tables", exc_info=True) - - -def _ensure_creds_filesystem() -> None: - try: - BLOBS_DIR.mkdir(parents=True, exist_ok=True) - if not SEARCH_JSON.exists(): - SEARCH_JSON.write_text( - '{ "client_id": "", "client_secret": "" }\n', encoding="utf-8" - ) - logger.info(f"Created default global Spotify creds file at {SEARCH_JSON}") - except Exception: - logger.error( - "Failed to ensure credentials filesystem (blobs/search.json)", exc_info=True + 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 _apply_versioned_updates( - conn: sqlite3.Connection, c_base, u_base, post_update=None -) -> None: - if not c_base(conn): - u_base(conn) - if post_update: - post_update(conn) +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.1.2 upgrade helpers for Watch DBs --- +# --- 3.2.0 verification helpers for Watch DBs --- def _update_watch_playlists_db(conn: sqlite3.Connection) -> None: @@ -298,10 +285,10 @@ def _update_watch_playlists_db(conn: sqlite3.Connection) -> None: EXPECTED_PLAYLIST_TRACKS_COLUMNS, f"playlist tracks ({table_name})", ) - logger.info("Upgraded watch playlists DB to 3.1.2 schema") + logger.info("Upgraded watch playlists DB to 3.2.0 base schema") except Exception: logger.error( - "Failed to upgrade watch playlists DB to 3.1.2 schema", exc_info=True + "Failed to upgrade watch playlists DB to 3.2.0 base schema", exc_info=True ) @@ -361,10 +348,24 @@ def _update_watch_artists_db(conn: sqlite3.Connection) -> None: EXPECTED_ARTIST_ALBUMS_COLUMNS, f"artist albums ({table_name})", ) - logger.info("Upgraded watch artists DB to 3.1.2 schema") + logger.info("Upgraded watch artists DB to 3.2.0 base schema") except Exception: logger.error( - "Failed to upgrade watch artists DB to 3.1.2 schema", exc_info=True + "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) + if not SEARCH_JSON.exists(): + SEARCH_JSON.write_text( + '{ "client_id": "", "client_secret": "" }\n', encoding="utf-8" + ) + logger.info(f"Created default global Spotify creds file at {SEARCH_JSON}") + except Exception: + logger.error( + "Failed to ensure credentials filesystem (blobs/search.json)", exc_info=True ) @@ -374,75 +375,42 @@ def run_migrations_if_needed(): return try: - # History DB - with _safe_connect(HISTORY_DB) as conn: - if conn: - _apply_versioned_updates( - conn, - m306.check_history, - m306.update_history, - post_update=_update_children_tables_for_history, + # 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.2.1." + ) + raise RuntimeError( + "Instance is not at schema version 3.2.0. Please upgrade to 3.2.0 before applying 3.2.1." ) - _apply_versioned_updates(conn, m311.check_history, m311.update_history) - _apply_versioned_updates(conn, m312.check_history, m312.update_history) - conn.commit() # Watch playlists DB with _safe_connect(PLAYLISTS_DB) as conn: if conn: - _apply_versioned_updates( - conn, - m306.check_watch_playlists, - m306.update_watch_playlists, - ) - _apply_versioned_updates( - conn, - m311.check_watch_playlists, - m311.update_watch_playlists, - ) - _apply_versioned_updates( - conn, - m312.check_watch_playlists, - m312.update_watch_playlists, - ) _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 + # Watch artists DB (if exists) if ARTISTS_DB.exists(): with _safe_connect(ARTISTS_DB) as conn: if conn: - _apply_versioned_updates( - conn, m306.check_watch_artists, m306.update_watch_artists - ) - _apply_versioned_updates( - conn, m310.check_watch_artists, m310.update_watch_artists - ) - _apply_versioned_updates( - conn, m311.check_watch_artists, m311.update_watch_artists - ) - _apply_versioned_updates( - conn, m312.check_watch_artists, m312.update_watch_artists - ) _update_watch_artists_db(conn) + if not m320.check_watch_artists(conn): + m320.update_watch_artists(conn) conn.commit() - # Accounts DB + # Accounts DB (no changes for this migration path) with _safe_connect(ACCOUNTS_DB) as conn: if conn: - _apply_versioned_updates( - conn, m306.check_accounts, m306.update_accounts - ) - _apply_versioned_updates( - conn, m311.check_accounts, m311.update_accounts - ) - _apply_versioned_updates( - conn, m312.check_accounts, m312.update_accounts - ) conn.commit() except Exception as e: logger.error("Error during migration: %s", e, exc_info=True) + raise else: _ensure_creds_filesystem() - logger.info("Database migrations check completed") + logger.info("Database migrations check completed (3.2.0 -> 3.2.1 path)") diff --git a/routes/migrations/v3_0_6.py b/routes/migrations/v3_0_6.py deleted file mode 100644 index 35e1a45..0000000 --- a/routes/migrations/v3_0_6.py +++ /dev/null @@ -1,201 +0,0 @@ -import sqlite3 - - -class MigrationV3_0_6: - HISTORY_SQL = """ - 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); - """ - - WATCH_PLAYLISTS_SQL = """ - 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 - ); - """ - - WATCH_ARTISTS_SQL = """ - 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 - ); - """ - - ACCOUNTS_SPOTIFY_SQL = """ - CREATE TABLE IF NOT EXISTS spotify ( - name TEXT PRIMARY KEY, - region TEXT, - created_at REAL, - updated_at REAL - ); - """ - - ACCOUNTS_DEEZER_SQL = """ - CREATE TABLE IF NOT EXISTS deezer ( - name TEXT PRIMARY KEY, - arl TEXT, - region TEXT, - created_at REAL, - updated_at REAL - ); - """ - - @staticmethod - def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]: - try: - cur = conn.execute(f"PRAGMA table_info({table})") - return {row[1] for row in cur.fetchall()} - except Exception: - return set() - - # --- Checks --- - def check_history(self, conn: sqlite3.Connection) -> bool: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'" - ) - if not cur.fetchone(): - return False - required = { - "id", - "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", - } - return required.issubset(self._table_columns(conn, "download_history")) - - def check_watch_playlists(self, conn: sqlite3.Connection) -> bool: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='watched_playlists'" - ) - if not cur.fetchone(): - return False - required = { - "spotify_id", - "name", - "owner_id", - "owner_name", - "total_tracks", - "link", - "snapshot_id", - "last_checked", - "added_at", - "is_active", - } - return required.issubset(self._table_columns(conn, "watched_playlists")) - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='watched_artists'" - ) - if not cur.fetchone(): - return False - required = { - "spotify_id", - "name", - "link", - "total_albums_on_spotify", - "last_checked", - "added_at", - "is_active", - "genres", - "popularity", - "image_url", - } - return required.issubset(self._table_columns(conn, "watched_artists")) - - def check_accounts(self, conn: sqlite3.Connection) -> bool: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='spotify'" - ) - if not cur.fetchone(): - return False - if not {"name", "region", "created_at", "updated_at"}.issubset( - self._table_columns(conn, "spotify") - ): - return False - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='deezer'" - ) - if not cur.fetchone(): - return False - return {"name", "arl", "region", "created_at", "updated_at"}.issubset( - self._table_columns(conn, "deezer") - ) - - # --- Updates --- - def update_history(self, conn: sqlite3.Connection) -> None: - conn.executescript(self.HISTORY_SQL) - - def update_watch_playlists(self, conn: sqlite3.Connection) -> None: - conn.executescript(self.WATCH_PLAYLISTS_SQL) - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - conn.executescript(self.WATCH_ARTISTS_SQL) - - def update_accounts(self, conn: sqlite3.Connection) -> None: - conn.executescript(self.ACCOUNTS_SPOTIFY_SQL) - conn.executescript(self.ACCOUNTS_DEEZER_SQL) \ No newline at end of file diff --git a/routes/migrations/v3_1_0.py b/routes/migrations/v3_1_0.py deleted file mode 100644 index 62a4734..0000000 --- a/routes/migrations/v3_1_0.py +++ /dev/null @@ -1,88 +0,0 @@ -import sqlite3 -import logging - -logger = logging.getLogger(__name__) - - -class MigrationV3_1_0: - ARTIST_ALBUMS_EXPECTED_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", - } - - def _table_columns(self, conn: sqlite3.Connection, table: str) -> set[str]: - try: - cur = conn.execute(f"PRAGMA table_info({table})") - return {row[1] for row in cur.fetchall()} - except sqlite3.OperationalError: - return set() - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - """Checks if the artist-specific tables have the new columns.""" - try: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'artist_%' LIMIT 1" - ) - first_artist_table = cur.fetchone() - - if not first_artist_table: - return True # No artist tables, so no migration needed - - table_name = first_artist_table[0] - existing_columns = self._table_columns(conn, table_name) - required_columns = self.ARTIST_ALBUMS_EXPECTED_COLUMNS.keys() - - return set(required_columns).issubset(existing_columns) - except Exception as e: - logger.error(f"Error checking artist watch DB schema: {e}") - return False - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - """Updates all artist-specific tables with new columns.""" - try: - cur = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'artist_%'" - ) - artist_tables = cur.fetchall() - - for row in artist_tables: - table_name = row[0] - existing_columns = self._table_columns(conn, table_name) - - for col_name, col_type in self.ARTIST_ALBUMS_EXPECTED_COLUMNS.items(): - if col_name in existing_columns: - continue - - try: - # Remove constraints for ADD COLUMN - col_type_for_add = ( - col_type.replace("PRIMARY KEY", "") - .replace("AUTOINCREMENT", "") - .replace("NOT NULL", "") - .strip() - ) - conn.execute( - f'ALTER TABLE "{table_name}" ADD COLUMN {col_name} {col_type_for_add}' - ) - logger.info( - f"Added column '{col_name}' to table '{table_name}' in artists.db." - ) - except sqlite3.OperationalError as e: - logger.warning( - f"Could not add column '{col_name}' to table '{table_name}': {e}" - ) - except Exception as e: - logger.error(f"Failed to update artist watch DB: {e}", exc_info=True) diff --git a/routes/migrations/v3_1_1.py b/routes/migrations/v3_1_1.py deleted file mode 100644 index 584744e..0000000 --- a/routes/migrations/v3_1_1.py +++ /dev/null @@ -1,42 +0,0 @@ -import sqlite3 - - -class MigrationV3_1_1: - """ - Dummy migration for version 3.1.1 to 3.1.2. - No database schema changes were made between these versions. - This class serves as a placeholder to ensure the migration runner - is aware of this version and can proceed without errors. - """ - - def check_history(self, conn: sqlite3.Connection) -> bool: - # No changes, so migration is not needed. - return True - - def update_history(self, conn: sqlite3.Connection) -> None: - # No-op - pass - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - # No changes, so migration is not needed. - return True - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - # No-op - pass - - def check_watch_playlists(self, conn: sqlite3.Connection) -> bool: - # No changes, so migration is not needed. - return True - - def update_watch_playlists(self, conn: sqlite3.Connection) -> None: - # No-op - pass - - def check_accounts(self, conn: sqlite3.Connection) -> bool: - # No changes, so migration is not needed. - return True - - def update_accounts(self, conn: sqlite3.Connection) -> None: - # No-op - pass diff --git a/routes/migrations/v3_1_2.py b/routes/migrations/v3_1_2.py deleted file mode 100644 index 26b00d0..0000000 --- a/routes/migrations/v3_1_2.py +++ /dev/null @@ -1,103 +0,0 @@ -import sqlite3 -import logging - - -logger = logging.getLogger(__name__) - - -class MigrationV3_1_2: - """ - Migration for version 3.1.2. - Ensure history children tables (album_*/playlist_*) include service and quality columns. - """ - - CHILDREN_EXTRA_COLUMNS: dict[str, str] = { - "service": "TEXT", - "quality_format": "TEXT", - "quality_bitrate": "TEXT", - } - - def _table_columns(self, conn: sqlite3.Connection, table: str) -> set[str]: - try: - cur = conn.execute(f"PRAGMA table_info({table})") - return {row[1] for row in cur.fetchall()} - except sqlite3.OperationalError: - return set() - - def _list_children_tables(self, 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 check_history(self, conn: sqlite3.Connection) -> bool: - tables = self._list_children_tables(conn) - if not tables: - # Nothing to migrate - return True - # If any table is missing any of the extra columns, migration is needed - for t in tables: - cols = self._table_columns(conn, t) - if not set(self.CHILDREN_EXTRA_COLUMNS.keys()).issubset(cols): - return False - return True - - def update_history(self, conn: sqlite3.Connection) -> None: - tables = self._list_children_tables(conn) - for t in tables: - existing = self._table_columns(conn, t) - for col_name, col_type in self.CHILDREN_EXTRA_COLUMNS.items(): - if col_name in existing: - continue - try: - conn.execute(f"ALTER TABLE {t} ADD COLUMN {col_name} {col_type}") - logger.info( - f"Added column '{col_name} {col_type}' to history children table '{t}'." - ) - except sqlite3.OperationalError as e: - logger.warning( - f"Could not add column '{col_name}' to history children table '{t}': {e}" - ) - - def check_watch_artists(self, conn: sqlite3.Connection) -> bool: - # No changes for watch artists in 3.1.2 - return True - - def update_watch_artists(self, conn: sqlite3.Connection) -> None: - # No-op - pass - - def check_watch_playlists(self, conn: sqlite3.Connection) -> bool: - # No changes for watch playlists in 3.1.2 - return True - - def update_watch_playlists(self, conn: sqlite3.Connection) -> None: - # No-op - pass - - def check_accounts(self, conn: sqlite3.Connection) -> bool: - # No changes for accounts in 3.1.2 - return True - - def update_accounts(self, conn: sqlite3.Connection) -> None: - # No-op - pass diff --git a/routes/migrations/v3_2_0.py b/routes/migrations/v3_2_0.py new file mode 100644 index 0000000..4307bec --- /dev/null +++ b/routes/migrations/v3_2_0.py @@ -0,0 +1,100 @@ +import sqlite3 +import logging + +logger = logging.getLogger(__name__) + + +class MigrationV3_2_0: + """ + Migration for version 3.2.0 (upgrade path 3.2.0 -> 3.2.1). + - 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.2.1 --- + + 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.2.1 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.2.1", 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.2.1 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.2.1", exc_info=True) diff --git a/routes/utils/artist.py b/routes/utils/artist.py index b9d8126..e08474e 100644 --- a/routes/utils/artist.py +++ b/routes/utils/artist.py @@ -1,4 +1,5 @@ import json +from routes.utils.watch.manager import get_watch_config import logging from routes.utils.celery_queue_manager import download_queue_manager from routes.utils.get_info import get_spotify_info @@ -86,16 +87,16 @@ def get_artist_discography( raise -def download_artist_albums( - url, album_type="album,single,compilation", request_args=None, username=None -): +def download_artist_albums(url, album_type=None, request_args=None, username=None): """ Download albums by an artist, filtered by album types. + If album_type is not provided, uses the watchedArtistAlbumGroup setting from watch config. Args: url (str): Spotify artist URL album_type (str): Comma-separated list of album types to download (album, single, compilation, appears_on) + If None, uses watchedArtistAlbumGroup setting request_args (dict): Original request arguments for tracking username (str | None): Username initiating the request, used for per-user separation @@ -118,39 +119,82 @@ def download_artist_albums( logger.error(error_msg) raise ValueError(error_msg) - artist_data = get_spotify_info(artist_id, "artist_discography") + # Get watch config to determine which album groups to download + watch_config = get_watch_config() + allowed_groups = [ + g.lower() + for g in watch_config.get("watchedArtistAlbumGroup", ["album", "single"]) + ] + logger.info( + f"Filtering albums by watchedArtistAlbumGroup setting (exact album_group match): {allowed_groups}" + ) - if not artist_data or "items" not in artist_data: + # Fetch all artist albums with pagination + all_artist_albums = [] + offset = 0 + limit = 50 # Spotify API limit for artist albums + + logger.info(f"Fetching all albums for artist ID: {artist_id} with pagination") + + while True: + logger.debug( + f"Fetching albums for {artist_id}. Limit: {limit}, Offset: {offset}" + ) + artist_data_page = get_spotify_info( + artist_id, "artist_discography", limit=limit, offset=offset + ) + + if not artist_data_page or not isinstance(artist_data_page.get("items"), list): + logger.warning( + f"No album items found or invalid format for artist {artist_id} at offset {offset}. Response: {artist_data_page}" + ) + break + + current_page_albums = artist_data_page.get("items", []) + if not current_page_albums: + logger.info( + f"No more albums on page for artist {artist_id} at offset {offset}. Total fetched so far: {len(all_artist_albums)}." + ) + break + + logger.debug( + f"Fetched {len(current_page_albums)} albums on current page for artist {artist_id}." + ) + all_artist_albums.extend(current_page_albums) + + # Check if Spotify indicates a next page URL + if artist_data_page.get("next"): + offset += limit # Increment offset by the limit used for the request + else: + logger.info( + f"No next page URL for artist {artist_id}. Pagination complete. Total albums fetched: {len(all_artist_albums)}." + ) + break + + if not all_artist_albums: raise ValueError( f"Failed to retrieve artist data or no albums found for artist ID {artist_id}" ) - allowed_types = [t.strip().lower() for t in album_type.split(",")] - logger.info(f"Filtering albums by types: {allowed_types}") - + # Filter albums based on the allowed types using album_group field (like in manager.py) filtered_albums = [] - for album in artist_data.get("items", []): - album_type_value = album.get("album_type", "").lower() + for album in all_artist_albums: album_group_value = album.get("album_group", "").lower() + album_name = album.get("name", "Unknown Album") + album_id = album.get("id", "Unknown ID") - if ( - ( - "album" in allowed_types - and album_type_value == "album" - and album_group_value == "album" - ) - or ( - "single" in allowed_types - and album_type_value == "single" - and album_group_value == "single" - ) - or ("compilation" in allowed_types and album_type_value == "compilation") - or ("appears_on" in allowed_types and album_group_value == "appears_on") - ): + # Exact album_group match only (align with watch manager) + is_matching_group = album_group_value in allowed_groups + + logger.debug( + f"Album {album_name} ({album_id}): album_group={album_group_value}. Allowed groups: {allowed_groups}. Match: {is_matching_group}." + ) + + if is_matching_group: filtered_albums.append(album) if not filtered_albums: - logger.warning(f"No albums match the specified types: {album_type}") + logger.warning(f"No albums match the specified groups: {allowed_groups}") return [], [] successfully_queued_albums = [] @@ -168,7 +212,7 @@ def download_artist_albums( if not album_url: logger.warning( - f"Skipping album '{album_name}' because it has no Spotify URL." + f"Skipping album {album_name} because it has no Spotify URL." ) continue @@ -211,6 +255,6 @@ def download_artist_albums( ) logger.info( - f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found." + f"Artist album processing: {len(successfully_queued_albums)} queued, {len(duplicate_albums)} duplicates found from {len(filtered_albums)} matching albums out of {len(all_artist_albums)} total albums." ) return successfully_queued_albums, duplicate_albums diff --git a/routes/utils/watch/db.py b/routes/utils/watch/db.py index 38f3119..b2428e9 100644 --- a/routes/utils/watch/db.py +++ b/routes/utils/watch/db.py @@ -25,6 +25,9 @@ EXPECTED_WATCHED_PLAYLISTS_COLUMNS = { "last_checked": "INTEGER", "added_at": "INTEGER", "is_active": "INTEGER DEFAULT 1", + # New: batch progress for per-interval page fetching + "batch_next_offset": "INTEGER DEFAULT 0", + "batch_processing_snapshot_id": "TEXT", } EXPECTED_PLAYLIST_TRACKS_COLUMNS = { @@ -55,6 +58,8 @@ EXPECTED_WATCHED_ARTISTS_COLUMNS = { "genres": "TEXT", # Comma-separated "popularity": "INTEGER", "image_url": "TEXT", + # New: batch progress for per-interval page fetching + "batch_next_offset": "INTEGER DEFAULT 0", } EXPECTED_ARTIST_ALBUMS_COLUMNS = { @@ -439,6 +444,61 @@ def update_playlist_snapshot( ) +# --- New: per-playlist batch progress helpers --- + + +def get_playlist_batch_progress(playlist_spotify_id: str) -> tuple[int, str | None]: + """Returns (batch_next_offset, batch_processing_snapshot_id) for a watched playlist.""" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT batch_next_offset, batch_processing_snapshot_id FROM watched_playlists WHERE spotify_id = ?", + (playlist_spotify_id,), + ) + row = cursor.fetchone() + if not row: + return 0, None + next_offset = ( + row["batch_next_offset"] if "batch_next_offset" in row.keys() else 0 + ) + processing_snapshot = ( + row["batch_processing_snapshot_id"] + if "batch_processing_snapshot_id" in row.keys() + else None + ) + return int(next_offset or 0), processing_snapshot + except sqlite3.Error as e: + logger.error( + f"Error retrieving batch progress for playlist {playlist_spotify_id}: {e}", + exc_info=True, + ) + return 0, None + + +def set_playlist_batch_progress( + playlist_spotify_id: str, next_offset: int, processing_snapshot_id: str | None +) -> None: + """Updates batch_next_offset and batch_processing_snapshot_id for a watched playlist.""" + try: + with _get_playlists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE watched_playlists + SET batch_next_offset = ?, batch_processing_snapshot_id = ? + WHERE spotify_id = ? + """, + (int(next_offset or 0), processing_snapshot_id, playlist_spotify_id), + ) + conn.commit() + except sqlite3.Error as e: + logger.error( + f"Error updating batch progress for playlist {playlist_spotify_id}: {e}", + exc_info=True, + ) + + def get_playlist_track_ids_from_db(playlist_spotify_id: str): """Retrieves all track Spotify IDs from a specific playlist's tracks table in playlists.db.""" table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" @@ -773,7 +833,7 @@ def add_specific_tracks_to_playlist_table( def remove_specific_tracks_from_playlist_table( playlist_spotify_id: str, track_spotify_ids: list ): - """Removes specific tracks from the playlist's local track table.""" + """Removes specific tracks from the playlist's local DB table.""" table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" if not track_spotify_ids: return 0 @@ -799,7 +859,7 @@ def remove_specific_tracks_from_playlist_table( conn.commit() deleted_count = cursor.rowcount logger.info( - f"Manually removed {deleted_count} tracks from DB for playlist {playlist_spotify_id}." + f"Successfully removed {deleted_count} tracks locally for playlist {playlist_spotify_id}." ) return deleted_count except sqlite3.Error as e: @@ -1164,6 +1224,53 @@ def update_artist_metadata_after_check( ) +# --- New: per-artist batch progress helpers --- + + +def get_artist_batch_next_offset(artist_spotify_id: str) -> int: + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT batch_next_offset FROM watched_artists WHERE spotify_id = ?", + (artist_spotify_id,), + ) + row = cursor.fetchone() + if not row: + return 0 + return ( + int(row["batch_next_offset"]) + if "batch_next_offset" in row.keys() + else 0 + ) + except sqlite3.Error as e: + logger.error( + f"Error retrieving batch_next_offset for artist {artist_spotify_id}: {e}", + exc_info=True, + ) + return 0 + + +def set_artist_batch_next_offset(artist_spotify_id: str, next_offset: int) -> None: + try: + with _get_artists_db_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE watched_artists + SET batch_next_offset = ? + WHERE spotify_id = ? + """, + (int(next_offset or 0), artist_spotify_id), + ) + conn.commit() + except sqlite3.Error as e: + logger.error( + f"Error updating batch_next_offset for artist {artist_spotify_id}: {e}", + exc_info=True, + ) + + def get_artist_album_ids_from_db(artist_spotify_id: str): """Retrieves all album Spotify IDs from a specific artist's albums table in artists.db.""" table_name = f"artist_{artist_spotify_id.replace('-', '_')}" @@ -1289,11 +1396,11 @@ def add_or_update_album_for_artist( total_tracks, link, image_url, - current_time, # added_to_db - current_time, # last_seen_on_spotify - task_id, # download_task_id - download_status, # download_status - 0, # is_fully_downloaded_managed_by_app + current_time, # added_to_db + current_time, # last_seen_on_spotify + task_id, # download_task_id + download_status, # download_status + 0, # is_fully_downloaded_managed_by_app ) cursor.execute( f""" diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 97510b8..4a10e78 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -14,7 +14,6 @@ from routes.utils.watch.db import ( get_playlist_total_tracks_from_db, add_tracks_to_playlist_db, update_playlist_snapshot, - mark_tracks_as_not_present_in_spotify, update_all_existing_tables_schema, ensure_playlist_table_schema, # Artist watch DB functions @@ -22,6 +21,11 @@ from routes.utils.watch.db import ( get_watched_artist, get_artist_album_ids_from_db, update_artist_metadata_after_check, # Renamed from update_artist_metadata + # New batch progress helpers + get_playlist_batch_progress, + set_playlist_batch_progress, + get_artist_batch_next_offset, + set_artist_batch_next_offset, ) from routes.utils.get_info import ( get_spotify_info, @@ -47,8 +51,35 @@ DEFAULT_WATCH_CONFIG = { "delayBetweenPlaylistsSeconds": 2, "delayBetweenArtistsSeconds": 5, "useSnapshotIdChecking": True, + "maxItemsPerRun": 50, } +# Round-robin index for one-item-per-interval scheduling +_round_robin_index = 0 + +# Per-item locks to ensure only one run processes a given item at a time +_playlist_locks: Dict[str, threading.RLock] = {} +_artist_locks: Dict[str, threading.RLock] = {} +_locks_guard = threading.RLock() + + +def _get_playlist_lock(playlist_spotify_id: str) -> threading.RLock: + with _locks_guard: + lock = _playlist_locks.get(playlist_spotify_id) + if lock is None: + lock = threading.RLock() + _playlist_locks[playlist_spotify_id] = lock + return lock + + +def _get_artist_lock(artist_spotify_id: str) -> threading.RLock: + with _locks_guard: + lock = _artist_locks.get(artist_spotify_id) + if lock is None: + lock = threading.RLock() + _artist_locks[artist_spotify_id] = lock + return lock + def get_watch_config(): """Loads the watch configuration from main.json's 'watch' key (camelCase). @@ -84,7 +115,8 @@ def get_watch_config(): "delay_between_playlists_seconds": "delayBetweenPlaylistsSeconds", "delay_between_artists_seconds": "delayBetweenArtistsSeconds", "use_snapshot_id_checking": "useSnapshotIdChecking", - "max_tracks_per_run": "maxTracksPerRun", + "max_tracks_per_run": "maxItemsPerRun", + "max_items_per_run": "maxItemsPerRun", } migrated_watch = {} for k, v in legacy_watch.items(): @@ -108,7 +140,8 @@ def get_watch_config(): "delay_between_playlists_seconds": "delayBetweenPlaylistsSeconds", "delay_between_artists_seconds": "delayBetweenArtistsSeconds", "use_snapshot_id_checking": "useSnapshotIdChecking", - "max_tracks_per_run": "maxTracksPerRun", + "max_tracks_per_run": "maxItemsPerRun", + "max_items_per_run": "maxItemsPerRun", } migrated = False for legacy_key, camel_key in legacy_to_camel.items(): @@ -116,11 +149,30 @@ def get_watch_config(): watch_cfg[camel_key] = watch_cfg.pop(legacy_key) migrated = True + # Additional migration: if maxTracksPerRun exists but maxItemsPerRun does not, promote it + if "maxTracksPerRun" in watch_cfg and "maxItemsPerRun" not in watch_cfg: + watch_cfg["maxItemsPerRun"] = watch_cfg.get("maxTracksPerRun") + migrated = True + # Ensure defaults for k, v in DEFAULT_WATCH_CONFIG.items(): if k not in watch_cfg: watch_cfg[k] = v + # Enforce range for maxItemsPerRun (1..50) + try: + current_value = int( + watch_cfg.get("maxItemsPerRun", DEFAULT_WATCH_CONFIG["maxItemsPerRun"]) + ) + except Exception: + current_value = DEFAULT_WATCH_CONFIG["maxItemsPerRun"] + clamped_value = ( + 1 if current_value < 1 else (50 if current_value > 50 else current_value) + ) + if clamped_value != watch_cfg.get("maxItemsPerRun"): + watch_cfg["maxItemsPerRun"] = clamped_value + migrated = True + if migrated or legacy_file_found: # Persist migration back to main.json main_cfg["watch"] = watch_cfg @@ -194,11 +246,11 @@ def has_playlist_changed(playlist_spotify_id: str, current_snapshot_id: str) -> This is much more efficient than fetching all tracks. Args: - playlist_spotify_id: The Spotify playlist ID - current_snapshot_id: The current snapshot_id from API + playlist_spotify_id: The Spotify playlist ID + current_snapshot_id: The current snapshot_id from API Returns: - True if playlist has changed, False otherwise + True if playlist has changed, False otherwise """ try: db_playlist = get_watched_playlist(playlist_spotify_id) @@ -228,14 +280,14 @@ def needs_track_sync( Check if tracks need to be synchronized by comparing snapshot_ids and total counts. Args: - playlist_spotify_id: The Spotify playlist ID - current_snapshot_id: The current snapshot_id from API - api_total_tracks: The total number of tracks reported by API + playlist_spotify_id: The Spotify playlist ID + current_snapshot_id: The current snapshot_id from API + api_total_tracks: The total number of tracks reported by API Returns: - Tuple of (needs_sync, tracks_to_find) where: - - needs_sync: True if tracks need to be synchronized - - tracks_to_find: List of track IDs that need to be found in API response + Tuple of (needs_sync, tracks_to_find) where: + - needs_sync: True if tracks need to be synchronized + - tracks_to_find: List of track IDs that need to be found in API response """ try: # Get tracks from database with their snapshot_ids @@ -281,22 +333,27 @@ def find_tracks_in_playlist( Progressively fetch playlist tracks until all specified tracks are found or playlist is exhausted. Args: - playlist_spotify_id: The Spotify playlist ID - tracks_to_find: List of track IDs to find - current_snapshot_id: The current snapshot_id + playlist_spotify_id: The Spotify playlist ID + tracks_to_find: List of track IDs to find + current_snapshot_id: The current snapshot_id Returns: - Tuple of (found_tracks, not_found_tracks) where: - - found_tracks: List of track items that were found - - not_found_tracks: List of track IDs that were not found + Tuple of (found_tracks, not_found_tracks) where: + - found_tracks: List of track items that were found + - not_found_tracks: List of track IDs that were not found """ found_tracks = [] not_found_tracks = tracks_to_find.copy() offset = 0 - limit = 100 + # Use configured max items per run for pagination (Spotify max 50) + try: + cfg = get_watch_config() + limit = max(1, min(int(cfg.get("maxItemsPerRun", 50)), 50)) + except Exception: + limit = 50 logger.info( - f"Searching for {len(tracks_to_find)} tracks in playlist {playlist_spotify_id} starting from offset {offset}" + f"Searching for {len(tracks_to_find)} tracks in playlist {playlist_spotify_id} starting from offset {offset} with limit {limit}" ) while not_found_tracks and offset < 10000: # Safety limit @@ -349,6 +406,7 @@ def find_tracks_in_playlist( def check_watched_playlists(specific_playlist_id: str = None): """Checks watched playlists for new tracks and queues downloads. If specific_playlist_id is provided, only that playlist is checked. + Processes at most one batch per run (offset advanced between runs) to avoid rate limits. """ logger.info( f"Playlist Watch Manager: Starting check. Specific playlist: {specific_playlist_id or 'All'}" @@ -360,6 +418,11 @@ def check_watched_playlists(specific_playlist_id: str = None): base_dir_fmt = formatting_cfg.get("customDirFormat", "%ar_album%/%album%") base_track_fmt = formatting_cfg.get("customTrackFormat", "%tracknum%. %music%") pad_tracks = formatting_cfg.get("tracknumPadding", True) + # Determine pagination limit for this run + try: + batch_limit = max(1, min(int(config.get("maxItemsPerRun", 50)), 50)) + except Exception: + batch_limit = 50 if specific_playlist_id: playlist_obj = get_watched_playlist(specific_playlist_id) @@ -379,292 +442,245 @@ def check_watched_playlists(specific_playlist_id: str = None): for playlist_in_db in watched_playlists_to_check: playlist_spotify_id = playlist_in_db["spotify_id"] playlist_name = playlist_in_db["name"] - logger.info( - f"Playlist Watch Manager: Checking playlist '{playlist_name}' ({playlist_spotify_id})..." + playlist_lock = _get_playlist_lock(playlist_spotify_id) + logger.debug( + f"Playlist Watch Manager: Waiting for lock on playlist {playlist_spotify_id}..." ) - - try: - # Ensure the playlist's track table has the latest schema before processing - ensure_playlist_table_schema(playlist_spotify_id) - - # First, get playlist metadata to check if it has changed - current_playlist_metadata = get_playlist_metadata(playlist_spotify_id) - if not current_playlist_metadata: - logger.error( - f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}." - ) - continue - - api_snapshot_id = current_playlist_metadata.get("snapshot_id") - api_total_tracks = current_playlist_metadata.get("tracks", {}).get( - "total", 0 + with playlist_lock: + logger.debug( + f"Playlist Watch Manager: Acquired lock for playlist {playlist_spotify_id}." + ) + logger.info( + f"Playlist Watch Manager: Checking playlist '{playlist_name}' ({playlist_spotify_id})..." ) - # Enhanced snapshot_id checking with track-level tracking - if use_snapshot_checking: - # First check if playlist snapshot_id has changed - playlist_changed = has_playlist_changed( - playlist_spotify_id, api_snapshot_id + try: + # Ensure the playlist's track table has the latest schema before processing + ensure_playlist_table_schema(playlist_spotify_id) + + # First, get playlist metadata to check if it has changed + current_playlist_metadata = get_playlist_metadata(playlist_spotify_id) + if not current_playlist_metadata: + logger.error( + f"Playlist Watch Manager: Failed to fetch metadata from Spotify for playlist {playlist_spotify_id}." + ) + continue + + api_snapshot_id = current_playlist_metadata.get("snapshot_id") + api_total_tracks = current_playlist_metadata.get("tracks", {}).get( + "total", 0 ) - if not playlist_changed: + # Enhanced snapshot_id checking with track-level tracking + if use_snapshot_checking: + # First check if playlist snapshot_id has changed + playlist_changed = has_playlist_changed( + playlist_spotify_id, api_snapshot_id + ) + else: + playlist_changed = True # Force full check + + # Determine if we need a full multi-run sync + needs_full_sync = False + if playlist_changed: + needs_full_sync = True + else: # Even if playlist snapshot_id hasn't changed, check if individual tracks need sync needs_sync, tracks_to_find = needs_track_sync( playlist_spotify_id, api_snapshot_id, api_total_tracks ) - if not needs_sync: logger.info( - f"Playlist Watch Manager: Playlist '{playlist_name}' ({playlist_spotify_id}) has not changed since last check (snapshot_id: {api_snapshot_id}). Skipping detailed check." + f"Playlist Watch Manager: Playlist '{playlist_name}' ({playlist_spotify_id}) unchanged (snapshot {api_snapshot_id}). Skipping." ) continue else: if not tracks_to_find: - # Empty tracks_to_find means full sync is needed (track count mismatch detected) - logger.info( - f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but full sync needed due to track count mismatch. Proceeding with full check." - ) - # Continue to full sync below + # Track count mismatch → treat as full sync + needs_full_sync = True else: + # Targeted sync required. To avoid rate limits, process only one page this run. logger.info( - f"Playlist Watch Manager: Playlist '{playlist_name}' snapshot_id unchanged, but {len(tracks_to_find)} tracks need sync. Proceeding with targeted check." + f"Playlist Watch Manager: Targeted sync for '{playlist_name}' with {len(tracks_to_find)} tracks needing update. Processing one page (limit={batch_limit})." ) - # Use targeted track search instead of full fetch - found_tracks, not_found_tracks = find_tracks_in_playlist( - playlist_spotify_id, tracks_to_find, api_snapshot_id + # Use one-page scan to try find some of the tracks + progress_offset, _ = get_playlist_batch_progress( + playlist_spotify_id ) - - # Update found tracks with new snapshot_id + tracks_batch = get_playlist_tracks( + playlist_spotify_id, + limit=batch_limit, + offset=progress_offset, + ) + batch_items = ( + tracks_batch.get("items", []) if tracks_batch else [] + ) + found_tracks = [] + remaining_to_find = set(tracks_to_find) + for item in batch_items: + track = item.get("track") + if ( + track + and track.get("id") + and track["id"] in remaining_to_find + and not track.get("is_local") + ): + found_tracks.append(item) + remaining_to_find.remove(track["id"]) if found_tracks: add_tracks_to_playlist_db( playlist_spotify_id, found_tracks, api_snapshot_id ) - - # Mark not found tracks as removed - if not_found_tracks: + # Advance offset for next run + next_offset = progress_offset + len(batch_items) + if batch_items and next_offset < api_total_tracks: + set_playlist_batch_progress( + playlist_spotify_id, next_offset, None + ) logger.info( - f"Playlist Watch Manager: {len(not_found_tracks)} tracks not found in playlist '{playlist_name}'. Marking as removed." + f"Playlist Watch Manager: Targeted sync processed page (offset {progress_offset}, size {len(batch_items)}). Next offset set to {next_offset}." ) - mark_tracks_as_not_present_in_spotify( - playlist_spotify_id, not_found_tracks + else: + # End of scan cycle for targeted mode; reset progress cursor + set_playlist_batch_progress( + playlist_spotify_id, 0, None ) - - # Update the playlist's m3u file after tracks are removed - try: - logger.info( - f"Updating m3u file for playlist '{playlist_name}' after removing {len(not_found_tracks)} tracks." - ) - update_playlist_m3u_file(playlist_spotify_id) - except Exception as m3u_update_err: - logger.error( - f"Failed to update m3u file for playlist '{playlist_name}' after marking tracks as removed: {m3u_update_err}", - exc_info=True, - ) - - # Update playlist snapshot and continue to next playlist - update_playlist_snapshot( - playlist_spotify_id, api_snapshot_id, api_total_tracks - ) - logger.info( - f"Playlist Watch Manager: Finished targeted sync for playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}." - ) + logger.info( + "Playlist Watch Manager: Targeted sync reached end of playlist. Resetting scan offset to 0." + ) + # Do not update playlist snapshot here; only when full sync finishes continue - else: - logger.info( - f"Playlist Watch Manager: Playlist '{playlist_name}' has changed. New snapshot_id: {api_snapshot_id}. Proceeding with full check." + + if needs_full_sync: + # Multi-run full sync: process only one batch per run + progress_offset, processing_snapshot = get_playlist_batch_progress( + playlist_spotify_id ) - else: - logger.info( - f"Playlist Watch Manager: Snapshot checking disabled. Proceeding with full check for playlist '{playlist_name}'." - ) + # If processing a new snapshot or no processing snapshot recorded, start from offset 0 + if ( + not processing_snapshot + or processing_snapshot != api_snapshot_id + or progress_offset >= api_total_tracks + ): + progress_offset = 0 + set_playlist_batch_progress( + playlist_spotify_id, 0, api_snapshot_id + ) + logger.info( + f"Playlist Watch Manager: Starting/Resetting full sync for '{playlist_name}' snapshot {api_snapshot_id}." + ) - # Fetch all tracks using the optimized function - # This happens when: - # 1. Playlist snapshot_id has changed (full sync needed) - # 2. Snapshot checking is disabled (full sync always) - # 3. Database is empty but API has tracks (full sync needed) - logger.info( - f"Playlist Watch Manager: Fetching all tracks for playlist '{playlist_name}' ({playlist_spotify_id}) with {api_total_tracks} total tracks." - ) - - all_api_track_items = [] - offset = 0 - limit = 100 # Use maximum batch size for efficiency - - while offset < api_total_tracks: - try: - # Use the optimized get_playlist_tracks function + logger.info( + f"Playlist Watch Manager: Fetching one batch (limit={batch_limit}, offset={progress_offset}) for playlist '{playlist_name}'." + ) tracks_batch = get_playlist_tracks( - playlist_spotify_id, limit=limit, offset=offset + playlist_spotify_id, limit=batch_limit, offset=progress_offset ) + batch_items = tracks_batch.get("items", []) if tracks_batch else [] - if not tracks_batch or "items" not in tracks_batch: - logger.warning( - f"Playlist Watch Manager: No tracks returned for playlist {playlist_spotify_id} at offset {offset}" - ) - break - - batch_items = tracks_batch.get("items", []) - if not batch_items: - break - - all_api_track_items.extend(batch_items) - offset += len(batch_items) - - # Add small delay between batches to be respectful to API - if offset < api_total_tracks: - time.sleep(0.1) - - except Exception as e: - logger.error( - f"Playlist Watch Manager: Error fetching tracks batch for playlist {playlist_spotify_id} at offset {offset}: {e}" - ) - break - - current_api_track_ids = set() - api_track_id_to_item_map = {} - api_track_position_map: dict[str, int] = {} - # Build maps for quick lookup and position within the playlist (1-based) - for idx, item in enumerate( - all_api_track_items, start=1 - ): # Use overall playlist index for numbering - track = item.get("track") - if track and track.get("id") and not track.get("is_local"): - track_id = track["id"] - current_api_track_ids.add(track_id) - api_track_id_to_item_map[track_id] = item - api_track_position_map[track_id] = idx - - db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id) - - new_track_ids_for_download = current_api_track_ids - db_track_ids - queued_for_download_count = 0 - if new_track_ids_for_download: - logger.info( - f"Playlist Watch Manager: Found {len(new_track_ids_for_download)} new tracks for playlist '{playlist_name}' to download." - ) - for track_id in new_track_ids_for_download: - api_item = api_track_id_to_item_map.get(track_id) - if not api_item or not api_item.get("track"): - logger.warning( - f"Playlist Watch Manager: Missing track details in API map for new track_id {track_id} in playlist {playlist_spotify_id}. Cannot queue." - ) - continue - - track_to_queue = api_item["track"] - # Compute per-track formatting overrides for playlist placeholders - position_in_playlist = api_track_position_map.get(track_id) - custom_dir_format, custom_track_format = ( - _apply_playlist_placeholders( - base_dir_fmt, - base_track_fmt, - playlist_name, - position_in_playlist if position_in_playlist else 0, - api_total_tracks, - pad_tracks, - ) - ) - - task_payload = { - "download_type": "track", - "url": construct_spotify_url(track_id, "track"), - "name": track_to_queue.get("name", "Unknown Track"), - "artist": ", ".join( - [ - a["name"] - for a in track_to_queue.get("artists", []) - if a.get("name") - ] - ), - "orig_request": { - "source": "playlist_watch", - "playlist_id": playlist_spotify_id, - "playlist_name": playlist_name, - "track_spotify_id": track_id, - "track_item_for_db": api_item, # Pass full API item for DB update on completion - }, - # Override formats so %playlist% and %playlistnum% resolve now per track - "custom_dir_format": custom_dir_format, - "custom_track_format": custom_track_format, - } - try: - task_id_or_none = download_queue_manager.add_task( - task_payload, from_watch_job=True - ) - if task_id_or_none: # Task was newly queued - logger.info( - f"Playlist Watch Manager: Queued download task {task_id_or_none} for new track {track_id} ('{track_to_queue.get('name')}') from playlist '{playlist_name}'." + # Build quick lookup for new tracks vs DB + db_track_ids = get_playlist_track_ids_from_db(playlist_spotify_id) + queued_for_download_count = 0 + for item in batch_items: + track = item.get("track") + if not track or not track.get("id") or track.get("is_local"): + continue + track_id = track["id"] + if track_id not in db_track_ids: + # Compute per-track formatting overrides + position_in_playlist = None # Unknown without full context; use None so %playlistnum% resolves to '' or basic padding + custom_dir_format, custom_track_format = ( + _apply_playlist_placeholders( + base_dir_fmt, + base_track_fmt, + playlist_name, + position_in_playlist if position_in_playlist else 0, + api_total_tracks, + pad_tracks, + ) ) - queued_for_download_count += 1 - # If task_id_or_none is None, it was a duplicate and not re-queued, Celery manager handles logging. - except Exception as e: - logger.error( - f"Playlist Watch Manager: Failed to queue download for new track {track_id} from playlist '{playlist_name}': {e}", - exc_info=True, + task_payload = { + "download_type": "track", + "url": construct_spotify_url(track_id, "track"), + "name": track.get("name", "Unknown Track"), + "artist": ", ".join( + [ + a["name"] + for a in track.get("artists", []) + if a.get("name") + ] + ), + "orig_request": { + "source": "playlist_watch", + "playlist_id": playlist_spotify_id, + "playlist_name": playlist_name, + "track_spotify_id": track_id, + "track_item_for_db": item, + }, + "custom_dir_format": custom_dir_format, + "custom_track_format": custom_track_format, + } + try: + task_id_or_none = download_queue_manager.add_task( + task_payload, from_watch_job=True + ) + if task_id_or_none: + queued_for_download_count += 1 + except Exception as e: + logger.error( + f"Playlist Watch Manager: Failed to queue download for track {track_id} from playlist '{playlist_name}': {e}", + exc_info=True, + ) + + # Refresh/mark present for items in this batch + if batch_items: + add_tracks_to_playlist_db( + playlist_spotify_id, batch_items, api_snapshot_id ) - logger.info( - f"Playlist Watch Manager: Attempted to queue {queued_for_download_count} new tracks for playlist '{playlist_name}'." - ) - else: - logger.info( - f"Playlist Watch Manager: No new tracks to download for playlist '{playlist_name}'." + + # Advance or finalize progress + next_offset = progress_offset + len(batch_items) + if batch_items and next_offset < api_total_tracks: + set_playlist_batch_progress( + playlist_spotify_id, next_offset, api_snapshot_id + ) + logger.info( + f"Playlist Watch Manager: Processed batch size {len(batch_items)} at offset {progress_offset}. Next offset {next_offset}." + ) + # Do not update snapshot yet; continue next run + else: + # Finished this snapshot's full sync + set_playlist_batch_progress(playlist_spotify_id, 0, None) + update_playlist_snapshot( + playlist_spotify_id, api_snapshot_id, api_total_tracks + ) + logger.info( + f"Playlist Watch Manager: Full sync completed for '{playlist_name}'. Snapshot updated to {api_snapshot_id}." + ) + # Optionally update m3u at the end + try: + update_playlist_m3u_file(playlist_spotify_id) + except Exception as m3u_update_err: + logger.error( + f"Failed to update m3u file for playlist '{playlist_name}' after full sync: {m3u_update_err}", + exc_info=True, + ) + + except Exception as e: + logger.error( + f"Playlist Watch Manager: Error processing playlist {playlist_spotify_id}: {e}", + exc_info=True, ) - # Update DB for tracks that are still present in API (e.g. update 'last_seen_in_spotify') - # add_tracks_to_playlist_db handles INSERT OR REPLACE, updating existing entries. - # We should pass all current API tracks to ensure their `last_seen_in_spotify`, `is_present_in_spotify`, and `snapshot_id` are updated. - if ( - all_api_track_items - ): # If there are any tracks in the API for this playlist - logger.info( - f"Playlist Watch Manager: Refreshing {len(all_api_track_items)} tracks from API in local DB for playlist '{playlist_name}'." - ) - add_tracks_to_playlist_db( - playlist_spotify_id, all_api_track_items, api_snapshot_id - ) - - removed_db_ids = db_track_ids - current_api_track_ids - if removed_db_ids: - logger.info( - f"Playlist Watch Manager: {len(removed_db_ids)} tracks removed from Spotify playlist '{playlist_name}'. Marking in DB." - ) - mark_tracks_as_not_present_in_spotify( - playlist_spotify_id, list(removed_db_ids) - ) - - # Update the playlist's m3u file after any changes (new tracks queued or tracks removed) - if new_track_ids_for_download or removed_db_ids: - try: - logger.info( - f"Updating m3u file for playlist '{playlist_name}' after playlist changes." - ) - update_playlist_m3u_file(playlist_spotify_id) - except Exception as m3u_update_err: - logger.error( - f"Failed to update m3u file for playlist '{playlist_name}' after playlist changes: {m3u_update_err}", - exc_info=True, - ) - - update_playlist_snapshot( - playlist_spotify_id, api_snapshot_id, api_total_tracks - ) # api_total_tracks from initial fetch - logger.info( - f"Playlist Watch Manager: Finished checking playlist '{playlist_name}'. Snapshot ID updated to {api_snapshot_id}. API Total Tracks: {api_total_tracks}. Queued {queued_for_download_count} new tracks." - ) - - except Exception as e: - logger.error( - f"Playlist Watch Manager: Error processing playlist {playlist_spotify_id}: {e}", - exc_info=True, - ) - - time.sleep(max(1, config.get("delayBetweenPlaylistsSeconds", 2))) + # Only sleep between items when running a batch (no specific ID) + if not specific_playlist_id: + time.sleep(max(1, config.get("delayBetweenPlaylistsSeconds", 2))) logger.info("Playlist Watch Manager: Finished checking all watched playlists.") def check_watched_artists(specific_artist_id: str = None): - """Checks watched artists for new albums and queues downloads.""" + """Checks watched artists for new albums and queues downloads. Processes one page per run to avoid rate limits.""" logger.info( f"Artist Watch Manager: Starting check. Specific artist: {specific_artist_id or 'All'}" ) @@ -675,6 +691,11 @@ def check_watched_artists(specific_artist_id: str = None): logger.info( f"Artist Watch Manager: Watching for album groups: {watched_album_groups}" ) + # Determine pagination limit for artist albums (Spotify max 50) + try: + artist_batch_limit = max(1, min(int(config.get("maxItemsPerRun", 50)), 50)) + except Exception: + artist_batch_limit = 50 if specific_artist_id: artist_obj_in_db = get_watched_artist(specific_artist_id) @@ -694,21 +715,22 @@ def check_watched_artists(specific_artist_id: str = None): for artist_in_db in artists_to_check: artist_spotify_id = artist_in_db["spotify_id"] artist_name = artist_in_db["name"] - logger.info( - f"Artist Watch Manager: Checking artist '{artist_name}' ({artist_spotify_id})..." + artist_lock = _get_artist_lock(artist_spotify_id) + logger.debug( + f"Artist Watch Manager: Waiting for lock on artist {artist_spotify_id}..." ) - - try: - # Use the optimized artist discography function with pagination - all_artist_albums_from_api: List[Dict[str, Any]] = [] - offset = 0 - limit = 50 # Spotify API limit for artist albums - + with artist_lock: + logger.debug( + f"Artist Watch Manager: Acquired lock for artist {artist_spotify_id}." + ) logger.info( - f"Artist Watch Manager: Fetching albums for artist '{artist_name}' ({artist_spotify_id})" + f"Artist Watch Manager: Checking artist '{artist_name}' ({artist_spotify_id})..." ) - while True: + try: + # One page per run + offset = get_artist_batch_next_offset(artist_spotify_id) + limit = artist_batch_limit logger.debug( f"Artist Watch Manager: Fetching albums for {artist_spotify_id}. Limit: {limit}, Offset: {offset}" ) @@ -716,176 +738,98 @@ def check_watched_artists(specific_artist_id: str = None): artist_spotify_id, "artist_discography", limit=limit, offset=offset ) - if not artist_albums_page or not isinstance( - artist_albums_page.get("items"), list - ): - logger.warning( - f"Artist Watch Manager: No album items found or invalid format for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Response: {artist_albums_page}" - ) - break - - current_page_albums = artist_albums_page.get("items", []) - if not current_page_albums: - logger.info( - f"Artist Watch Manager: No more albums on page for artist {artist_spotify_id} (name: '{artist_name}') at offset {offset}. Total fetched so far: {len(all_artist_albums_from_api)}." - ) - break - - logger.debug( - f"Artist Watch Manager: Fetched {len(current_page_albums)} albums on current page for artist '{artist_name}'." + current_page_albums = ( + artist_albums_page.get("items", []) + if artist_albums_page + and isinstance(artist_albums_page.get("items"), list) + else [] ) - all_artist_albums_from_api.extend(current_page_albums) - - # Correct pagination: Check if Spotify indicates a next page URL - # The `next` field in Spotify API responses is a URL to the next page or null. - if artist_albums_page.get("next"): - offset += limit # CORRECT: Increment offset by the limit used for the request - else: - logger.info( - f"Artist Watch Manager: No 'next' page URL for artist '{artist_name}'. Pagination complete. Total albums fetched: {len(all_artist_albums_from_api)}." - ) - break - - # total_albums_from_api = len(all_artist_albums_from_api) - # Use the 'total' field from the API response for a more accurate count of all available albums (matching current API filter if any) - api_reported_total_albums = ( - artist_albums_page.get("total", 0) - if "artist_albums_page" in locals() and artist_albums_page - else len(all_artist_albums_from_api) - ) - logger.info( - f"Artist Watch Manager: Fetched {len(all_artist_albums_from_api)} albums in total from API for artist '{artist_name}'. API reports total: {api_reported_total_albums}." - ) - - db_album_ids = get_artist_album_ids_from_db(artist_spotify_id) - logger.info( - f"Artist Watch Manager: Found {len(db_album_ids)} albums in DB for artist '{artist_name}'. These will be skipped if re-encountered unless logic changes." - ) - - queued_for_download_count = 0 - processed_album_ids_in_run = set() # To avoid processing duplicate album_ids if API returns them across pages (should not happen with correct pagination) - - for album_data in all_artist_albums_from_api: - album_id = album_data.get("id") - album_name = album_data.get("name", "Unknown Album") - album_group = album_data.get("album_group", "N/A").lower() - album_type = album_data.get("album_type", "N/A").lower() - - if not album_id: - logger.warning( - f"Artist Watch Manager: Skipping album without ID for artist '{artist_name}'. Album data: {album_data}" - ) - continue - - if album_id in processed_album_ids_in_run: - logger.debug( - f"Artist Watch Manager: Album '{album_name}' ({album_id}) already processed in this run. Skipping." - ) - continue - processed_album_ids_in_run.add(album_id) - - # Filter based on watchedArtistAlbumGroup - # The album_group field is generally preferred for this type of categorization as per Spotify docs. - is_matching_group = album_group in watched_album_groups - - logger.debug( - f"Artist '{artist_name}', Album '{album_name}' ({album_id}): album_group='{album_group}', album_type='{album_type}'. Watched groups: {watched_album_groups}. Match: {is_matching_group}." + api_reported_total_albums = ( + artist_albums_page.get("total", 0) if artist_albums_page else 0 ) - if not is_matching_group: - logger.debug( - f"Artist Watch Manager: Skipping album '{album_name}' ({album_id}) by '{artist_name}' - group '{album_group}' not in watched list: {watched_album_groups}." - ) - continue + db_album_ids = get_artist_album_ids_from_db(artist_spotify_id) + queued_for_download_count = 0 + processed_album_ids_in_run = set() - logger.info( - f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' (group: {album_group}) IS a matching group." - ) + for album_data in current_page_albums: + album_id = album_data.get("id") + if not album_id: + continue + if album_id in processed_album_ids_in_run: + continue + processed_album_ids_in_run.add(album_id) - if album_id not in db_album_ids: - logger.info( - f"Artist Watch Manager: Found NEW matching album '{album_name}' ({album_id}) by '{artist_name}'. Queuing for download." - ) + album_group = album_data.get("album_group", "N/A").lower() + if album_group not in watched_album_groups: + continue - album_artists_list = album_data.get("artists", []) - album_main_artist_name = ( - album_artists_list[0].get("name", "Unknown Artist") - if album_artists_list - else "Unknown Artist" - ) - - task_payload = { - "download_type": "album", # Or "track" if downloading individual tracks of album later - "url": construct_spotify_url(album_id, "album"), - "name": album_name, - "artist": album_main_artist_name, # Primary artist of the album - "orig_request": { - "source": "artist_watch", - "artist_spotify_id": artist_spotify_id, # Watched artist - "artist_name": artist_name, - "album_spotify_id": album_id, - "album_data_for_db": album_data, # Pass full API album object for DB update on completion/queuing - }, - } - try: - # Add to DB first with task_id, then queue. Or queue and add task_id to DB. - # Let's use add_or_update_album_for_artist to record it with a task_id before queuing. - # The celery_queue_manager.add_task might return None if it's a duplicate. - - # Record the album in DB as being processed for download - # Task_id will be added if successfully queued - - # We should call add_task first, and if it returns a task_id (not a duplicate), then update our DB. - task_id_or_none = download_queue_manager.add_task( - task_payload, from_watch_job=True + if album_id not in db_album_ids: + album_name = album_data.get("name", "Unknown Album") + album_artists_list = album_data.get("artists", []) + album_main_artist_name = ( + album_artists_list[0].get("name", "Unknown Artist") + if album_artists_list + else "Unknown Artist" ) - - if task_id_or_none: # Task was newly queued - # REMOVED: add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=task_id_or_none, is_download_complete=False) - # The album will be added/updated in the DB by celery_tasks.py upon successful download completion. - logger.info( - f"Artist Watch Manager: Queued download task {task_id_or_none} for new album '{album_name}' from artist '{artist_name}'. DB entry will be created/updated on success." + task_payload = { + "download_type": "album", + "url": construct_spotify_url(album_id, "album"), + "name": album_name, + "artist": album_main_artist_name, + "orig_request": { + "source": "artist_watch", + "artist_spotify_id": artist_spotify_id, + "artist_name": artist_name, + "album_spotify_id": album_id, + "album_data_for_db": album_data, + }, + } + try: + task_id_or_none = download_queue_manager.add_task( + task_payload, from_watch_job=True + ) + if task_id_or_none: + queued_for_download_count += 1 + except Exception as e: + logger.error( + f"Artist Watch Manager: Failed to queue download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", + exc_info=True, ) - queued_for_download_count += 1 - # If task_id_or_none is None, it was a duplicate. Celery manager handles logging. - except Exception as e: - logger.error( - f"Artist Watch Manager: Failed to queue download for new album {album_id} ('{album_name}') from artist '{artist_name}': {e}", - exc_info=True, - ) - else: + # Advance offset or finalize + if artist_albums_page and artist_albums_page.get("next"): + next_offset = offset + len(current_page_albums) + set_artist_batch_next_offset(artist_spotify_id, next_offset) logger.info( - f"Artist Watch Manager: Album '{album_name}' ({album_id}) by '{artist_name}' already known in DB (ID found in db_album_ids). Skipping queue." + f"Artist Watch Manager: Processed page size {len(current_page_albums)} at offset {offset}. Next offset {next_offset}." + ) + else: + set_artist_batch_next_offset(artist_spotify_id, 0) + update_artist_metadata_after_check( + artist_spotify_id, api_reported_total_albums + ) + logger.info( + f"Artist Watch Manager: Completed discography scan for '{artist_name}'. Metadata updated." ) - # Optionally, update its entry (e.g. last_seen, or if details changed), but for now, we only queue new ones. - # add_or_update_album_for_artist(artist_spotify_id, album_data, task_id=None, is_download_complete=False) # would update added_to_db_at - logger.info( - f"Artist Watch Manager: For artist '{artist_name}', processed {len(all_artist_albums_from_api)} API albums, attempted to queue {queued_for_download_count} new albums." - ) + except Exception as e: + logger.error( + f"Artist Watch Manager: Error processing artist {artist_spotify_id} ('{artist_name}'): {e}", + exc_info=True, + ) - update_artist_metadata_after_check( - artist_spotify_id, api_reported_total_albums - ) - logger.info( - f"Artist Watch Manager: Finished checking artist '{artist_name}'. DB metadata updated. API reported total albums (for API filter): {api_reported_total_albums}." - ) - - except Exception as e: - logger.error( - f"Artist Watch Manager: Error processing artist {artist_spotify_id} ('{artist_name}'): {e}", - exc_info=True, - ) - - time.sleep(max(1, config.get("delayBetweenArtistsSeconds", 5))) + # Only sleep between items when running a batch (no specific ID) + if not specific_artist_id: + time.sleep(max(1, config.get("delayBetweenArtistsSeconds", 5))) logger.info("Artist Watch Manager: Finished checking all watched artists.") def playlist_watch_scheduler(): - """Periodically calls check_watched_playlists and check_watched_artists.""" + """Periodically checks one watched item (playlist or artist) per interval in round-robin order.""" logger.info("Watch Scheduler: Thread started.") + global _round_robin_index while not STOP_EVENT.is_set(): current_config = get_watch_config() # Get latest config for this run @@ -901,38 +845,105 @@ def playlist_watch_scheduler(): ) # Still respect poll interval for checking config again continue # Skip to next iteration + # Build the current list of items to watch (playlists and artists) try: - logger.info("Watch Scheduler: Starting playlist check run.") - check_watched_playlists() - logger.info("Watch Scheduler: Playlist check run completed.") + playlists_list = get_watched_playlists() or [] + recorded_playlists = [("playlist", p["spotify_id"]) for p in playlists_list] + artists_list = get_watched_artists() or [] + recorded_artists = [("artist", a["spotify_id"]) for a in artists_list] + all_items = recorded_playlists + recorded_artists except Exception as e: logger.error( - f"Watch Scheduler: Unhandled exception during check_watched_playlists: {e}", - exc_info=True, + f"Watch Scheduler: Failed to build items list: {e}", exc_info=True ) + all_items = [] - # Add a small delay between playlist and artist checks if desired - # time.sleep(current_config.get("delay_between_check_types_seconds", 10)) - if STOP_EVENT.is_set(): - break # Check stop event again before starting artist check + if not all_items: + logger.info( + "Watch Scheduler: No watched playlists or artists. Waiting for next interval." + ) + STOP_EVENT.wait(interval) + continue + + # Pick the next item in round-robin order + index = _round_robin_index % len(all_items) + item_type, item_id = all_items[index] + _round_robin_index += 1 try: - logger.info("Watch Scheduler: Starting artist check run.") - check_watched_artists() - logger.info("Watch Scheduler: Artist check run completed.") + if item_type == "playlist": + logger.info( + f"Watch Scheduler: Checking next playlist {item_id} (index {index})." + ) + check_watched_playlists(specific_playlist_id=item_id) + elif item_type == "artist": + logger.info( + f"Watch Scheduler: Checking next artist {item_id} (index {index})." + ) + check_watched_artists(specific_artist_id=item_id) + else: + logger.warning( + f"Watch Scheduler: Unknown item type '{item_type}' for id '{item_id}'. Skipping." + ) except Exception as e: logger.error( - f"Watch Scheduler: Unhandled exception during check_watched_artists: {e}", + f"Watch Scheduler: Unhandled exception during item check ({item_type}:{item_id}): {e}", exc_info=True, ) logger.info( - f"Watch Scheduler: All checks complete. Next run in {interval} seconds." + f"Watch Scheduler: One-item check complete. Next run in {interval} seconds." ) STOP_EVENT.wait(interval) logger.info("Watch Scheduler: Thread stopped.") +def run_playlist_check_over_intervals(playlist_spotify_id: str) -> None: + """Run checks for a specific playlist over repeated intervals until sync completes. + Spreads batches across watchPollInterval to avoid rate limits. + """ + logger.info( + f"Manual Playlist Runner: Starting interval-based sync for playlist {playlist_spotify_id}." + ) + while not STOP_EVENT.is_set(): + try: + check_watched_playlists(specific_playlist_id=playlist_spotify_id) + # Determine if we are done: no active processing snapshot and no pending sync + cfg = get_watch_config() + interval = cfg.get("watchPollIntervalSeconds", 3600) + metadata = get_playlist_metadata(playlist_spotify_id) + if not metadata: + logger.warning( + f"Manual Playlist Runner: Could not load metadata for {playlist_spotify_id}. Stopping." + ) + break + api_snapshot_id = metadata.get("snapshot_id") + total = metadata.get("tracks", {}).get("total", 0) + progress_offset, processing_snapshot = get_playlist_batch_progress( + playlist_spotify_id + ) + needs_sync, _ = needs_track_sync( + playlist_spotify_id, api_snapshot_id, total + ) + if processing_snapshot is None and not needs_sync: + logger.info( + f"Manual Playlist Runner: Sync complete for playlist {playlist_spotify_id}." + ) + break + logger.info( + f"Manual Playlist Runner: Waiting {interval}s before next batch for playlist {playlist_spotify_id}." + ) + if STOP_EVENT.wait(interval): + break + except Exception as e: + logger.error( + f"Manual Playlist Runner: Error during interval sync for {playlist_spotify_id}: {e}", + exc_info=True, + ) + break + logger.info(f"Manual Playlist Runner: Finished for playlist {playlist_spotify_id}.") + + # --- Global thread for the scheduler --- _watch_scheduler_thread = None # Renamed from _playlist_watch_thread @@ -993,10 +1004,10 @@ def get_playlist_tracks_for_m3u(playlist_spotify_id: str) -> List[Dict[str, Any] Get all tracks for a playlist from the database with complete metadata needed for m3u generation. Args: - playlist_spotify_id: The Spotify playlist ID + playlist_spotify_id: The Spotify playlist ID Returns: - List of track dictionaries with metadata + List of track dictionaries with metadata """ table_name = f"playlist_{playlist_spotify_id.replace('-', '_')}" tracks: List[Dict[str, Any]] = [] @@ -1031,12 +1042,12 @@ def get_playlist_tracks_for_m3u(playlist_spotify_id: str) -> List[Dict[str, Any] # Get all tracks that are present in Spotify cursor.execute(f""" - SELECT spotify_track_id, title, artist_names, album_name, - album_artist_names, track_number, duration_ms, final_path - FROM {table_name} - WHERE is_present_in_spotify = 1 - ORDER BY track_number, title - """) + SELECT spotify_track_id, title, artist_names, album_name, + album_artist_names, track_number, duration_ms, final_path + FROM {table_name} + WHERE is_present_in_spotify = 1 + ORDER BY track_number, title + """) rows = cursor.fetchall() for row in rows: @@ -1071,7 +1082,7 @@ def update_playlist_m3u_file(playlist_spotify_id: str): Generate/update the m3u file for a watched playlist based on tracks in the database. Args: - playlist_spotify_id: The Spotify playlist ID + playlist_spotify_id: The Spotify playlist ID """ try: # Get playlist metadata diff --git a/spotizerr-ui/package.json b/spotizerr-ui/package.json index 54ed741..1473397 100644 --- a/spotizerr-ui/package.json +++ b/spotizerr-ui/package.json @@ -1,7 +1,7 @@ { "name": "spotizerr-ui", "private": true, - "version": "3.2.0", + "version": "3.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/spotizerr-ui/src/components/config/AccountsTab.tsx b/spotizerr-ui/src/components/config/AccountsTab.tsx index eeb6e91..f2d52c8 100644 --- a/spotizerr-ui/src/components/config/AccountsTab.tsx +++ b/spotizerr-ui/src/components/config/AccountsTab.tsx @@ -85,6 +85,7 @@ export function AccountsTab() { onSuccess: () => { toast.success("Account added successfully!"); queryClient.invalidateQueries({ queryKey: ["credentials", activeService] }); + queryClient.invalidateQueries({ queryKey: ["config"] }); // Invalidate config to update active Spotify account in UI setIsAdding(false); setSubmitError(null); reset(); diff --git a/spotizerr-ui/src/components/config/WatchTab.tsx b/spotizerr-ui/src/components/config/WatchTab.tsx index 3171093..9a69c7e 100644 --- a/spotizerr-ui/src/components/config/WatchTab.tsx +++ b/spotizerr-ui/src/components/config/WatchTab.tsx @@ -13,6 +13,7 @@ interface WatchSettings { enabled: boolean; watchPollIntervalSeconds: number; watchedArtistAlbumGroup: AlbumGroup[]; + maxItemsPerRun: number; } interface DownloadSettings { @@ -92,8 +93,9 @@ export function WatchTab() { setTimeout(() => setSaveStatus("idle"), 3000); queryClient.invalidateQueries({ queryKey: ["watchConfig"] }); }, - onError: (error) => { - toast.error(`Failed to save settings: ${error.message}`); + onError: (error: any) => { + const message = error?.response?.data?.error || error?.message || "Unknown error"; + toast.error(`Failed to save settings: ${message}`); setSaveStatus("error"); setTimeout(() => setSaveStatus("idle"), 3000); }, @@ -108,6 +110,7 @@ export function WatchTab() { }, [config, reset]); const watchEnabled = watch("enabled"); + const maxItemsPerRunValue = watch("maxItemsPerRun"); // Validation effect for watch + download method requirement useEffect(() => { @@ -125,9 +128,15 @@ export function WatchTab() { if (!deezerCredentials?.length) missingServices.push("Deezer"); error = `Watch with Fallback requires accounts for both services. Missing: ${missingServices.join(", ")}. Configure accounts in the Accounts tab.`; } + + // Validate maxItemsPerRun range (1..50) + const mir = Number(maxItemsPerRunValue); + if (!error && (Number.isNaN(mir) || mir < 1 || mir > 50)) { + error = "Max items per run must be between 1 and 50."; + } setValidationError(error); - }, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length]); + }, [watchEnabled, downloadConfig?.realTime, downloadConfig?.fallback, spotifyCredentials?.length, deezerCredentials?.length, maxItemsPerRunValue]); const onSubmit: SubmitHandler = (data) => { // Check validation before submitting @@ -148,9 +157,18 @@ export function WatchTab() { return; } + // Validate maxItemsPerRun in handler too, to be safe + const mir = Number(data.maxItemsPerRun); + if (Number.isNaN(mir) || mir < 1 || mir > 50) { + setValidationError("Max items per run must be between 1 and 50."); + toast.error("Validation failed: Max items per run must be between 1 and 50."); + return; + } + mutation.mutate({ ...data, watchPollIntervalSeconds: Number(data.watchPollIntervalSeconds), + maxItemsPerRun: Number(data.maxItemsPerRun), }); }; @@ -225,7 +243,20 @@ export function WatchTab() { {...register("watchPollIntervalSeconds")} className="block w-full p-2 border bg-input-background dark:bg-input-background-dark border-input-border dark:border-input-border-dark rounded-md focus:outline-none focus:ring-2 focus:ring-input-focus" /> -

How often to check watched items for updates.

+

How often to check for new items in watchlist.

+ + +
+ + +

Batch size per watch cycle (1–50).

diff --git a/spotizerr-ui/src/contexts/SettingsProvider.tsx b/spotizerr-ui/src/contexts/SettingsProvider.tsx index 10b328e..cc76843 100644 --- a/spotizerr-ui/src/contexts/SettingsProvider.tsx +++ b/spotizerr-ui/src/contexts/SettingsProvider.tsx @@ -7,165 +7,176 @@ import { useAuth } from "./auth-context"; // --- Case Conversion Utility --- // This is added here to simplify the fix and avoid module resolution issues. function snakeToCamel(str: string): string { - return str.replace(/(_\w)/g, (m) => m[1].toUpperCase()); + return str.replace(/(_\w)/g, (m) => m[1].toUpperCase()); } function convertKeysToCamelCase(obj: unknown): unknown { - if (Array.isArray(obj)) { - return obj.map((v) => convertKeysToCamelCase(v)); - } - if (typeof obj === "object" && obj !== null) { - return Object.keys(obj).reduce((acc: Record, key: string) => { - const camelKey = snakeToCamel(key); - acc[camelKey] = convertKeysToCamelCase((obj as Record)[key]); - return acc; - }, {}); - } - return obj; + if (Array.isArray(obj)) { + return obj.map((v) => convertKeysToCamelCase(v)); + } + if (typeof obj === "object" && obj !== null) { + return Object.keys(obj).reduce((acc: Record, key: string) => { + const camelKey = snakeToCamel(key); + acc[camelKey] = convertKeysToCamelCase((obj as Record)[key]); + return acc; + }, {}); + } + return obj; } // Redefine AppSettings to match the flat structure of the API response export type FlatAppSettings = { - service: "spotify" | "deezer"; - spotify: string; - spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH"; - deezer: string; - deezerQuality: "MP3_128" | "MP3_320" | "FLAC"; - maxConcurrentDownloads: number; - realTime: boolean; - fallback: boolean; - convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | ""; - bitrate: string; - maxRetries: number; - retryDelaySeconds: number; - retryDelayIncrease: number; - customDirFormat: string; - customTrackFormat: string; - tracknumPadding: boolean; - saveCover: boolean; - explicitFilter: boolean; - // Add other fields from the old AppSettings as needed by other parts of the app - watch: AppSettings["watch"]; - // Add defaults for the new download properties - threads: number; - path: string; - skipExisting: boolean; - m3u: boolean; - hlsThreads: number; - // Frontend-only flag used in DownloadsTab - recursiveQuality: boolean; - separateTracksByUser: boolean; - // Add defaults for the new formatting properties - track: string; - album: string; - playlist: string; - compilation: string; - artistSeparator: string; - spotifyMetadata: boolean; - realTimeMultiplier: number; + service: "spotify" | "deezer"; + spotify: string; + spotifyQuality: "NORMAL" | "HIGH" | "VERY_HIGH"; + deezer: string; + deezerQuality: "MP3_128" | "MP3_320" | "FLAC"; + maxConcurrentDownloads: number; + realTime: boolean; + fallback: boolean; + convertTo: "MP3" | "AAC" | "OGG" | "OPUS" | "FLAC" | "WAV" | "ALAC" | ""; + bitrate: string; + maxRetries: number; + retryDelaySeconds: number; + retryDelayIncrease: number; + customDirFormat: string; + customTrackFormat: string; + tracknumPadding: boolean; + saveCover: boolean; + explicitFilter: boolean; + // Add other fields from the old AppSettings as needed by other parts of the app + watch: AppSettings["watch"]; + // Add defaults for the new download properties + threads: number; + path: string; + skipExisting: boolean; + m3u: boolean; + hlsThreads: number; + // Frontend-only flag used in DownloadsTab + recursiveQuality: boolean; + separateTracksByUser: boolean; + // Add defaults for the new formatting properties + track: string; + album: string; + playlist: string; + compilation: string; + artistSeparator: string; + spotifyMetadata: boolean; + realTimeMultiplier: number; }; const defaultSettings: FlatAppSettings = { - service: "spotify", - spotify: "", - spotifyQuality: "NORMAL", - deezer: "", - deezerQuality: "MP3_128", - maxConcurrentDownloads: 3, - realTime: false, - fallback: false, - convertTo: "", - bitrate: "", - maxRetries: 3, - retryDelaySeconds: 5, - retryDelayIncrease: 5, - customDirFormat: "%ar_album%/%album%", - customTrackFormat: "%tracknum%. %music%", - tracknumPadding: true, - saveCover: true, - explicitFilter: false, - // Add defaults for the new download properties - threads: 4, - path: "/downloads", - skipExisting: true, - m3u: false, - hlsThreads: 8, - // Frontend-only default - recursiveQuality: false, - separateTracksByUser: false, - // Add defaults for the new formatting properties - track: "{artist_name}/{album_name}/{track_number} - {track_name}", - album: "{artist_name}/{album_name}", - playlist: "Playlists/{playlist_name}", - compilation: "Compilations/{album_name}", - artistSeparator: "; ", - spotifyMetadata: true, - watch: { - enabled: false, - }, - realTimeMultiplier: 0, + service: "spotify", + spotify: "", + spotifyQuality: "NORMAL", + deezer: "", + deezerQuality: "MP3_128", + maxConcurrentDownloads: 3, + realTime: false, + fallback: false, + convertTo: "", + bitrate: "", + maxRetries: 3, + retryDelaySeconds: 5, + retryDelayIncrease: 5, + customDirFormat: "%ar_album%/%album%", + customTrackFormat: "%tracknum%. %music%", + tracknumPadding: true, + saveCover: true, + explicitFilter: false, + // Add defaults for the new download properties + threads: 4, + path: "/downloads", + skipExisting: true, + m3u: false, + hlsThreads: 8, + // Frontend-only default + recursiveQuality: false, + separateTracksByUser: false, + // Add defaults for the new formatting properties + track: "{artist_name}/{album_name}/{track_number} - {track_name}", + album: "{artist_name}/{album_name}", + playlist: "Playlists/{playlist_name}", + compilation: "Compilations/{album_name}", + artistSeparator: "; ", + spotifyMetadata: true, + watch: { + enabled: false, + maxItemsPerRun: 50, + watchPollIntervalSeconds: 3600, + watchedArtistAlbumGroup: ["album", "single"], + }, + realTimeMultiplier: 0, }; interface FetchedCamelCaseSettings { - watchEnabled?: boolean; - watch?: { enabled: boolean }; - [key: string]: unknown; + watchEnabled?: boolean; + watch?: { enabled: boolean; maxItemsPerRun?: number; watchPollIntervalSeconds?: number; watchedArtistAlbumGroup?: string[] }; + [key: string]: unknown; } const fetchSettings = async (): Promise => { - try { - const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([ - authApiClient.client.get("/config"), - authApiClient.client.get("/config/watch"), - ]); + try { + const [{ data: generalConfig }, { data: watchConfig }] = await Promise.all([ + authApiClient.client.get("/config"), + authApiClient.client.get("/config/watch"), + ]); - const combinedConfig = { - ...generalConfig, - watch: watchConfig, - }; + const combinedConfig = { + ...generalConfig, + watch: watchConfig, + }; - // Transform the keys before returning the data - const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings; + // Transform the keys before returning the data + const camelData = convertKeysToCamelCase(combinedConfig) as FetchedCamelCaseSettings; - const withDefaults: FlatAppSettings = { - ...(camelData as unknown as FlatAppSettings), - // Ensure required frontend-only fields exist - recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false), - realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0), - }; + const withDefaults: FlatAppSettings = { + ...(camelData as unknown as FlatAppSettings), + // Ensure required frontend-only fields exist + recursiveQuality: Boolean((camelData as any).recursiveQuality ?? false), + realTimeMultiplier: Number((camelData as any).realTimeMultiplier ?? 0), + // Ensure watch subkeys default if missing + watch: { + ...(camelData.watch as any), + enabled: Boolean((camelData.watch as any)?.enabled ?? false), + maxItemsPerRun: Number((camelData.watch as any)?.maxItemsPerRun ?? 50), + watchPollIntervalSeconds: Number((camelData.watch as any)?.watchPollIntervalSeconds ?? 3600), + watchedArtistAlbumGroup: (camelData.watch as any)?.watchedArtistAlbumGroup ?? ["album", "single"], + }, + }; - return withDefaults; - } catch (error: any) { - // If we get authentication errors, return default settings - if (error.response?.status === 401 || error.response?.status === 403) { - console.log("Authentication required for config access, using default settings"); - return defaultSettings; - } - // Re-throw other errors for React Query to handle - throw error; - } + return withDefaults; + } catch (error: any) { + // If we get authentication errors, return default settings + if (error.response?.status === 401 || error.response?.status === 403) { + console.log("Authentication required for config access, using default settings"); + return defaultSettings; + } + // Re-throw other errors for React Query to handle + throw error; + } }; export function SettingsProvider({ children }: { children: ReactNode }) { - const { isLoading, authEnabled, isAuthenticated, user } = useAuth(); - - // Only fetch settings when auth is ready and user is admin (or auth is disabled) - const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin")); - - const { - data: settings, - isLoading: isSettingsLoading, - isError, - } = useQuery({ - queryKey: ["config"], - queryFn: fetchSettings, - staleTime: 1000 * 60 * 5, // 5 minutes - refetchOnWindowFocus: false, - enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin - }); + const { isLoading, authEnabled, isAuthenticated, user } = useAuth(); + + // Only fetch settings when auth is ready and user is admin (or auth is disabled) + const shouldFetchSettings = !isLoading && (!authEnabled || (isAuthenticated && user?.role === "admin")); + + const { + data: settings, + isLoading: isSettingsLoading, + isError, + } = useQuery({ + queryKey: ["config"], + queryFn: fetchSettings, + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + enabled: shouldFetchSettings, // Only run query when auth is ready and user is admin + }); - // Use default settings on error to prevent app crash - const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading }; + // Use default settings on error to prevent app crash + const value = { settings: isError ? defaultSettings : settings || null, isLoading: isSettingsLoading }; - return {children}; + return {children}; } diff --git a/spotizerr-ui/src/contexts/settings-context.ts b/spotizerr-ui/src/contexts/settings-context.ts index 8e9d534..4b1c9df 100644 --- a/spotizerr-ui/src/contexts/settings-context.ts +++ b/spotizerr-ui/src/contexts/settings-context.ts @@ -37,7 +37,9 @@ export interface AppSettings { spotifyMetadata: boolean; watch: { enabled: boolean; - // Add other watch properties from the old type if they still exist in the API response + maxItemsPerRun: number; + watchPollIntervalSeconds: number; + watchedArtistAlbumGroup: string[]; }; // Add other root-level properties from the API if they exist realTimeMultiplier: number; diff --git a/spotizerr-ui/src/routes/artist.tsx b/spotizerr-ui/src/routes/artist.tsx index 7a59725..ce33e0b 100644 --- a/spotizerr-ui/src/routes/artist.tsx +++ b/spotizerr-ui/src/routes/artist.tsx @@ -246,9 +246,10 @@ export const Artist = () => { return
Artist data could not be fully loaded. Please try again later.
; } - const artistAlbums = applyFilters(albums.filter((album) => album.album_type === "album")); - const artistSingles = applyFilters(albums.filter((album) => album.album_type === "single")); - const artistCompilations = applyFilters(albums.filter((album) => album.album_type === "compilation")); + const artistAlbums = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "album")); + const artistSingles = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "single")); + const artistCompilations = applyFilters(albums.filter((album) => (album.album_group ?? album.album_type) === "compilation")); + const artistAppearsOn = applyFilters(albums.filter((album) => (album.album_group ?? "") === "appears_on")); return (
@@ -364,6 +365,18 @@ export const Artist = () => {
)} + {/* Appears On */} + {artistAppearsOn.length > 0 && ( +
+

Appears On

+
+ {artistAppearsOn.map((album) => ( + handleDownloadAlbum(album)} /> + ))} +
+
+ )} + {/* sentinel + loading */}
{loadingMore &&
Loading more...
} diff --git a/spotizerr-ui/src/types/spotify.ts b/spotizerr-ui/src/types/spotify.ts index b066a62..65525fb 100644 --- a/spotizerr-ui/src/types/spotify.ts +++ b/spotizerr-ui/src/types/spotify.ts @@ -44,6 +44,7 @@ export interface AlbumType { id: string; name: string; album_type: "album" | "single" | "compilation"; + album_group?: "album" | "single" | "compilation" | "appears_on"; artists: ArtistType[]; images: ImageType[]; release_date: string;