Implemented 3.1.1 -> 3.1.2 migration
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ data.*/
|
|||||||
3.0.6.md
|
3.0.6.md
|
||||||
3.1.2.md
|
3.1.2.md
|
||||||
sqltree.sh
|
sqltree.sh
|
||||||
|
3.1.1.md
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: spotizerr
|
name: spotizerr
|
||||||
services:
|
services:
|
||||||
spotizerr:
|
spotizerr:
|
||||||
image: cooldockerizer93/spotizerr:3.1.0
|
image: cooldockerizer93/spotizerr:3.1.1
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./downloads:/app/downloads
|
- ./downloads:/app/downloads
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from .v3_0_6 import MigrationV3_0_6
|
from .v3_0_6 import MigrationV3_0_6
|
||||||
from .v3_1_0 import MigrationV3_1_0
|
from .v3_1_0 import MigrationV3_1_0
|
||||||
|
from .v3_1_1 import MigrationV3_1_1
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ EXPECTED_ARTIST_ALBUMS_COLUMNS: dict[str, str] = {
|
|||||||
|
|
||||||
m306 = MigrationV3_0_6()
|
m306 = MigrationV3_0_6()
|
||||||
m310 = MigrationV3_1_0()
|
m310 = MigrationV3_1_0()
|
||||||
|
m311 = MigrationV3_1_1()
|
||||||
|
|
||||||
|
|
||||||
def _safe_connect(path: Path) -> Optional[sqlite3.Connection]:
|
def _safe_connect(path: Path) -> Optional[sqlite3.Connection]:
|
||||||
@@ -325,35 +327,39 @@ 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)
|
logger.error("Failed to upgrade watch artists DB to 3.1.2 schema", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_if_needed() -> None:
|
def run_migrations_if_needed():
|
||||||
|
# Check if data directory exists
|
||||||
|
if not DATA_DIR.exists():
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# History DB
|
# History DB
|
||||||
h_conn = _safe_connect(HISTORY_DB)
|
with _safe_connect(HISTORY_DB) as conn:
|
||||||
if h_conn:
|
if conn:
|
||||||
try:
|
|
||||||
_apply_versioned_updates(
|
_apply_versioned_updates(
|
||||||
h_conn,
|
conn,
|
||||||
m306.check_history,
|
m306.check_history,
|
||||||
m306.update_history,
|
m306.update_history,
|
||||||
post_update=_update_children_tables_for_history,
|
post_update=_update_children_tables_for_history,
|
||||||
)
|
)
|
||||||
h_conn.commit()
|
_apply_versioned_updates(conn, m311.check_history, m311.update_history)
|
||||||
finally:
|
conn.commit()
|
||||||
h_conn.close()
|
|
||||||
|
|
||||||
# Watch playlists DB
|
# Watch playlists DB
|
||||||
p_conn = _safe_connect(PLAYLISTS_DB)
|
with _safe_connect(PLAYLISTS_DB) as conn:
|
||||||
if p_conn:
|
if conn:
|
||||||
try:
|
|
||||||
_apply_versioned_updates(
|
_apply_versioned_updates(
|
||||||
p_conn,
|
conn,
|
||||||
m306.check_watch_playlists,
|
m306.check_watch_playlists,
|
||||||
m306.update_watch_playlists,
|
m306.update_watch_playlists,
|
||||||
)
|
)
|
||||||
_update_watch_playlists_db(p_conn)
|
_apply_versioned_updates(
|
||||||
p_conn.commit()
|
conn,
|
||||||
finally:
|
m311.check_watch_playlists,
|
||||||
p_conn.close()
|
m311.update_watch_playlists,
|
||||||
|
)
|
||||||
|
_update_watch_playlists_db(conn)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# Watch artists DB
|
# Watch artists DB
|
||||||
if ARTISTS_DB.exists():
|
if ARTISTS_DB.exists():
|
||||||
@@ -365,23 +371,21 @@ def run_migrations_if_needed() -> None:
|
|||||||
_apply_versioned_updates(
|
_apply_versioned_updates(
|
||||||
conn, m310.check_watch_artists, m310.update_watch_artists
|
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)
|
_update_watch_artists_db(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Accounts DB
|
# Accounts DB
|
||||||
c_conn = _safe_connect(ACCOUNTS_DB)
|
with _safe_connect(ACCOUNTS_DB) as conn:
|
||||||
if c_conn:
|
if conn:
|
||||||
try:
|
_apply_versioned_updates(conn, m306.check_accounts, m306.update_accounts)
|
||||||
_apply_versioned_updates(
|
_apply_versioned_updates(conn, m311.check_accounts, m311.update_accounts)
|
||||||
c_conn,
|
conn.commit()
|
||||||
m306.check_accounts,
|
|
||||||
m306.update_accounts,
|
|
||||||
)
|
|
||||||
c_conn.commit()
|
|
||||||
finally:
|
|
||||||
c_conn.close()
|
|
||||||
_ensure_creds_filesystem()
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error during migration: %s", e, exc_info=True)
|
||||||
|
else:
|
||||||
|
_ensure_creds_filesystem()
|
||||||
logger.info("Database migrations check completed")
|
logger.info("Database migrations check completed")
|
||||||
except Exception:
|
|
||||||
logger.error("Database migration failed", exc_info=True)
|
|
||||||
42
routes/migrations/v3_1_1.py
Normal file
42
routes/migrations/v3_1_1.py
Normal file
@@ -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
|
||||||
135
tests/migration/test_v3_1_1.py
Normal file
135
tests/migration/test_v3_1_1.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user