diff --git a/.gitignore b/.gitignore index 27c1325..80ae492 100755 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ data.*/ 3.0.6.md 3.1.2.md sqltree.sh +3.1.1.md diff --git a/docker-compose.yaml b/docker-compose.yaml index 1dc6124..ecc18e9 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,7 +1,7 @@ name: spotizerr services: spotizerr: - image: cooldockerizer93/spotizerr:3.1.0 + image: cooldockerizer93/spotizerr:3.1.1 volumes: - ./data:/app/data - ./downloads:/app/downloads diff --git a/routes/migrations/runner.py b/routes/migrations/runner.py index e17c549..887ddd1 100644 --- a/routes/migrations/runner.py +++ b/routes/migrations/runner.py @@ -5,6 +5,7 @@ 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 logger = logging.getLogger(__name__) @@ -103,6 +104,7 @@ EXPECTED_ARTIST_ALBUMS_COLUMNS: dict[str, str] = { m306 = MigrationV3_0_6() m310 = MigrationV3_1_0() +m311 = MigrationV3_1_1() def _safe_connect(path: Path) -> Optional[sqlite3.Connection]: @@ -325,63 +327,65 @@ def _update_watch_artists_db(conn: sqlite3.Connection) -> None: logger.error("Failed to upgrade watch artists DB to 3.1.2 schema", exc_info=True) -def run_migrations_if_needed() -> None: - try: - # History DB - h_conn = _safe_connect(HISTORY_DB) - if h_conn: - try: - _apply_versioned_updates( - h_conn, - m306.check_history, - m306.update_history, - post_update=_update_children_tables_for_history, - ) - h_conn.commit() - finally: - h_conn.close() +def run_migrations_if_needed(): + # Check if data directory exists + if not DATA_DIR.exists(): + return - # Watch playlists DB - p_conn = _safe_connect(PLAYLISTS_DB) - if p_conn: - try: - _apply_versioned_updates( - p_conn, - m306.check_watch_playlists, - m306.update_watch_playlists, - ) - _update_watch_playlists_db(p_conn) - p_conn.commit() - finally: - p_conn.close() + 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, + ) + _apply_versioned_updates(conn, m311.check_history, m311.update_history) + conn.commit() - # Watch artists DB - 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 - ) - _update_watch_artists_db(conn) - 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, + ) + _update_watch_playlists_db(conn) + conn.commit() - # Accounts DB - c_conn = _safe_connect(ACCOUNTS_DB) - if c_conn: - try: - _apply_versioned_updates( - c_conn, - m306.check_accounts, - m306.update_accounts, - ) - c_conn.commit() - finally: - c_conn.close() - _ensure_creds_filesystem() + # Watch artists DB + 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 + ) + _update_watch_artists_db(conn) + conn.commit() - logger.info("Database migrations check completed") - except Exception: - logger.error("Database migration failed", exc_info=True) \ No newline at end of file + # Accounts DB + 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) + conn.commit() + + except Exception as e: + logger.error("Error during migration: %s", e, exc_info=True) + else: + _ensure_creds_filesystem() + logger.info("Database migrations check completed") \ No newline at end of file diff --git a/routes/migrations/v3_1_1.py b/routes/migrations/v3_1_1.py new file mode 100644 index 0000000..584744e --- /dev/null +++ b/routes/migrations/v3_1_1.py @@ -0,0 +1,42 @@ +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/tests/migration/test_v3_1_1.py b/tests/migration/test_v3_1_1.py new file mode 100644 index 0000000..ac90fde --- /dev/null +++ b/tests/migration/test_v3_1_1.py @@ -0,0 +1,135 @@ +import sqlite3 +import unittest +from pathlib import Path +from tempfile import mkdtemp +from shutil import rmtree +import pytest + +from routes.migrations.v3_1_1 import MigrationV3_1_1 + +# Override the autouse credentials fixture from conftest for this module +@pytest.fixture(scope="session", autouse=True) +def setup_credentials_for_tests(): + # No-op to avoid external API calls; this shadows the session autouse fixture in conftest.py + yield + + +class TestMigrationV3_1_1(unittest.TestCase): + """ + Tests the dummy migration from 3.1.1 to 3.1.2, ensuring no changes are made. + """ + + def setUp(self): + self.temp_dir = Path(mkdtemp()) + self.history_db_path = self.temp_dir / "history" / "download_history.db" + self.artists_db_path = self.temp_dir / "watch" / "artists.db" + self.playlists_db_path = self.temp_dir / "watch" / "playlists.db" + self.accounts_db_path = self.temp_dir / "creds" / "accounts.db" + self._create_mock_databases() + + def tearDown(self): + rmtree(self.temp_dir) + + def _get_db_schema(self, db_path: Path) -> dict: + """Helper to get the schema of a database.""" + schema = {} + with sqlite3.connect(db_path) as conn: + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table';") + tables = [row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")] + for table_name in tables: + info_cursor = conn.execute(f'PRAGMA table_info("{table_name}")') + schema[table_name] = {row[1] for row in info_cursor.fetchall()} + return schema + + def _create_mock_databases(self): + """Creates a set of mock databases with the 3.1.1 schema.""" + # History DB + self.history_db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.history_db_path) as conn: + conn.executescript( + """ + CREATE TABLE download_history ( + id INTEGER PRIMARY KEY, download_type TEXT, title TEXT, artists TEXT, + timestamp REAL, status TEXT, service TEXT, quality_format TEXT, + quality_bitrate TEXT, total_tracks INTEGER, successful_tracks INTEGER, + failed_tracks INTEGER, skipped_tracks INTEGER, children_table TEXT, + task_id TEXT, external_ids TEXT, metadata TEXT, release_date TEXT, + genres TEXT, images TEXT, owner TEXT, album_type TEXT, + duration_total_ms INTEGER, explicit BOOLEAN + ); + CREATE TABLE playlist_p1l2a3 ( + id INTEGER PRIMARY KEY, title TEXT, artists TEXT, album_title TEXT, + duration_ms INTEGER, track_number INTEGER, disc_number INTEGER, + explicit BOOLEAN, status TEXT, external_ids TEXT, genres TEXT, + isrc TEXT, timestamp REAL, position INTEGER, metadata TEXT + ); + """ + ) + + # Watch Artists DB + self.artists_db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.artists_db_path) as conn: + conn.executescript( + """ + CREATE TABLE watched_artists (id TEXT PRIMARY KEY, children_table TEXT); + INSERT INTO watched_artists (id, children_table) VALUES ('a1b2c3d4', 'artist_a1b2c3d4'); + CREATE TABLE artist_a1b2c3d4 ( + id TEXT PRIMARY KEY, title TEXT, artists TEXT, album_type TEXT, + release_date TEXT, total_tracks INTEGER, external_ids TEXT, + images TEXT, album_group TEXT, release_date_precision TEXT, + download_task_id TEXT, download_status TEXT, + is_fully_downloaded_managed_by_app BOOLEAN + ); + """ + ) + + # Watch Playlists DB + self.playlists_db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.playlists_db_path) as conn: + conn.executescript( + """ + CREATE TABLE watched_playlists (id TEXT PRIMARY KEY, children_table TEXT); + CREATE TABLE playlist_p1l2a3 (id TEXT PRIMARY KEY, title TEXT); + """ + ) + + # Accounts DB + self.accounts_db_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self.accounts_db_path) as conn: + conn.execute("CREATE TABLE accounts (id TEXT PRIMARY KEY, service TEXT, details TEXT);") + + def test_migration_leaves_schema_unchanged(self): + """Asserts that the dummy migration makes no changes to any database.""" + # Get initial schemas + initial_schemas = { + "history": self._get_db_schema(self.history_db_path), + "artists": self._get_db_schema(self.artists_db_path), + "playlists": self._get_db_schema(self.playlists_db_path), + "accounts": self._get_db_schema(self.accounts_db_path), + } + + # Run the dummy migration + migration = MigrationV3_1_1() + with sqlite3.connect(self.history_db_path) as conn: + migration.update_history(conn) + with sqlite3.connect(self.artists_db_path) as conn: + migration.update_watch_artists(conn) + with sqlite3.connect(self.playlists_db_path) as conn: + migration.update_watch_playlists(conn) + with sqlite3.connect(self.accounts_db_path) as conn: + migration.update_accounts(conn) + + # Get final schemas + final_schemas = { + "history": self._get_db_schema(self.history_db_path), + "artists": self._get_db_schema(self.artists_db_path), + "playlists": self._get_db_schema(self.playlists_db_path), + "accounts": self._get_db_schema(self.accounts_db_path), + } + + # Assert schemas are identical + self.assertEqual(initial_schemas, final_schemas) + + +if __name__ == '__main__': + unittest.main()