Files
Serien-Checker/serien_checker/database/db_manager.py
2025-12-21 14:35:08 +01:00

478 lines
21 KiB
Python

"""
Database manager for Serien-Checker using SQLite
"""
import sqlite3
import os
import sys
from pathlib import Path
from typing import List, Optional, Tuple
from datetime import datetime
from contextlib import contextmanager
from .models import Series, Season, Episode, Settings, SeasonType, DatePreference
class DatabaseManager:
"""Manages SQLite database operations"""
SCHEMA_VERSION = 3
@staticmethod
def get_executable_dir() -> Path:
"""
Get the directory where the executable/script is located
Works for both .py and .exe
"""
if getattr(sys, 'frozen', False):
# Running as compiled executable (PyInstaller)
return Path(sys.executable).parent
else:
# Running as script
return Path(__file__).parent.parent.parent
def __init__(self, db_path: Optional[str] = None, portable: bool = False):
"""
Initialize database manager
Args:
db_path: Custom database path (optional)
portable: If True, use portable mode (DB in program directory)
"""
if db_path:
self.db_path = db_path
else:
# Check if running as EXE or if portable mode is enabled
if getattr(sys, 'frozen', False) or portable:
# Running as EXE or portable mode: DB in program directory (next to EXE)
self.db_path = str(self.get_executable_dir() / "serien_checker.db")
else:
# Running as script in development: DB in user's AppData
app_data = Path.home() / ".serien_checker"
app_data.mkdir(exist_ok=True)
self.db_path = str(app_data / "serien_checker.db")
self._init_database()
@contextmanager
def _get_connection(self):
"""Context manager for database connections"""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _init_database(self):
"""Initialize database schema"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Create series table
cursor.execute("""
CREATE TABLE IF NOT EXISTS series (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
date_preference TEXT NOT NULL,
last_updated TEXT
)
""")
# Create seasons table
cursor.execute("""
CREATE TABLE IF NOT EXISTS seasons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
series_id INTEGER NOT NULL,
name TEXT NOT NULL,
season_type TEXT NOT NULL,
sort_order INTEGER NOT NULL,
FOREIGN KEY (series_id) REFERENCES series(id) ON DELETE CASCADE,
UNIQUE(series_id, name)
)
""")
# Create episodes table
cursor.execute("""
CREATE TABLE IF NOT EXISTS episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
season_id INTEGER NOT NULL,
episode_number INTEGER,
episode_code TEXT NOT NULL,
title TEXT NOT NULL,
episode_id TEXT,
date_de_tv TEXT,
date_de_streaming TEXT,
date_de_home_media TEXT,
date_de_sync TEXT,
date_original TEXT,
comparison_date TEXT,
FOREIGN KEY (season_id) REFERENCES seasons(id) ON DELETE CASCADE,
UNIQUE(season_id, episode_code)
)
""")
# Create settings table
cursor.execute("""
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL
)
""")
# Create indices for better performance
cursor.execute("CREATE INDEX IF NOT EXISTS idx_seasons_series ON seasons(series_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_season ON episodes(season_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_comparison_date ON episodes(comparison_date)")
# Check if episode_number column exists (migration from schema v0 to v1)
cursor.execute("PRAGMA table_info(episodes)")
columns = [row[1] for row in cursor.fetchall()]
if 'episode_number' not in columns:
from ..utils.logger import setup_logger
logger = setup_logger()
logger.info("Migrating database: Adding episode_number column")
cursor.execute("ALTER TABLE episodes ADD COLUMN episode_number INTEGER")
# Check if date_de_home_media column exists (migration from schema v1 to v2)
cursor.execute("PRAGMA table_info(episodes)")
columns = [row[1] for row in cursor.fetchall()]
if 'date_de_home_media' not in columns:
from ..utils.logger import setup_logger
logger = setup_logger()
logger.info("Migrating database: Adding date_de_home_media column")
cursor.execute("ALTER TABLE episodes ADD COLUMN date_de_home_media TEXT")
# Check if episode_id column exists (migration from schema v2 to v3)
cursor.execute("PRAGMA table_info(episodes)")
columns = [row[1] for row in cursor.fetchall()]
if 'episode_id' not in columns:
from ..utils.logger import setup_logger
logger = setup_logger()
logger.info("Migrating database: Adding episode_id column")
cursor.execute("ALTER TABLE episodes ADD COLUMN episode_id TEXT")
# Note: Cannot add UNIQUE constraint via ALTER TABLE in SQLite
# The constraint will be enforced at application level for existing data
# New databases will have the UNIQUE constraint from the start
# Drop old unique index on episode_id (if exists from previous version)
try:
cursor.execute("DROP INDEX IF EXISTS idx_episodes_episode_id_unique")
except:
pass
# Try to create unique index on (season_id, episode_id) combination
# This allows same episode_id in different seasons (e.g., "Staffel 6" vs "Staffel 6: Video-Podcast")
try:
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_episodes_season_episode_id ON episodes(season_id, episode_id) WHERE episode_id IS NOT NULL")
except sqlite3.IntegrityError:
# Duplicates exist, skip unique index
from ..utils.logger import setup_logger
logger = setup_logger()
logger.warning("Cannot create UNIQUE index on (season_id, episode_id) - duplicates exist. Will be enforced for new episodes only.")
# Update schema version
cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
("schema_version", str(self.SCHEMA_VERSION)))
# ==================== SERIES OPERATIONS ====================
def add_series(self, series: Series) -> int:
"""Add a new series"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO series (title, url, date_preference, last_updated)
VALUES (?, ?, ?, ?)
""", (series.title, series.url, series.date_preference.value,
series.last_updated.isoformat() if series.last_updated else None))
return cursor.lastrowid
def get_series(self, series_id: int) -> Optional[Series]:
"""Get series by ID"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM series WHERE id = ?", (series_id,))
row = cursor.fetchone()
return Series.from_row(row) if row else None
def get_all_series(self) -> List[Series]:
"""Get all series"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM series ORDER BY title")
return [Series.from_row(row) for row in cursor.fetchall()]
def update_series(self, series: Series):
"""Update an existing series"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE series
SET title = ?, url = ?, date_preference = ?, last_updated = ?
WHERE id = ?
""", (series.title, series.url, series.date_preference.value,
series.last_updated.isoformat() if series.last_updated else None,
series.id))
def delete_series(self, series_id: int):
"""Delete a series and all related data"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM series WHERE id = ?", (series_id,))
def clear_series_data(self, series_id: int):
"""
Clear all seasons and episodes for a series, but keep the series itself.
Useful for refreshing series data from scratch.
"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Delete all episodes and seasons (CASCADE should handle this, but be explicit)
cursor.execute("""
DELETE FROM episodes WHERE season_id IN (
SELECT id FROM seasons WHERE series_id = ?
)
""", (series_id,))
cursor.execute("DELETE FROM seasons WHERE series_id = ?", (series_id,))
# ==================== SEASON OPERATIONS ====================
def add_season(self, season: Season) -> int:
"""Add a new season"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO seasons (series_id, name, season_type, sort_order)
VALUES (?, ?, ?, ?)
""", (season.series_id, season.name, season.season_type.value, season.sort_order))
return cursor.lastrowid
def get_seasons_by_series(self, series_id: int) -> List[Season]:
"""Get all seasons for a series"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM seasons
WHERE series_id = ?
ORDER BY sort_order
""", (series_id,))
return [Season.from_row(row) for row in cursor.fetchall()]
def get_season(self, season_id: int) -> Optional[Season]:
"""Get season by ID"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM seasons WHERE id = ?", (season_id,))
row = cursor.fetchone()
return Season.from_row(row) if row else None
def season_exists(self, series_id: int, season_name: str) -> Optional[int]:
"""Check if season exists, return ID if it does"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id FROM seasons
WHERE series_id = ? AND name = ?
""", (series_id, season_name))
row = cursor.fetchone()
return row[0] if row else None
# ==================== EPISODE OPERATIONS ====================
def add_episode(self, episode: Episode) -> int:
"""Add a new episode (with automatic deduplication)"""
with self._get_connection() as conn:
cursor = conn.cursor()
# Check if episode already exists by episode_id AND season_id
# Note: Same episode_id can exist in different seasons (e.g., "Staffel 6" vs "Staffel 6: Video-Podcast")
if episode.episode_id:
cursor.execute("SELECT id FROM episodes WHERE episode_id = ? AND season_id = ?",
(episode.episode_id, episode.season_id))
row = cursor.fetchone()
if row:
existing_id = row[0]
from ..utils.logger import setup_logger
logger = setup_logger()
logger.debug(f"Episode with episode_id={episode.episode_id} already exists in season {episode.season_id} (ID={existing_id}), updating instead of inserting")
episode.id = existing_id
self.update_episode(episode)
return existing_id
# Check if episode already exists (by season_id and episode_code) - for backwards compatibility
existing_id = self.episode_exists(episode.season_id, episode.episode_code)
if existing_id:
# Episode exists, update it instead of inserting
from ..utils.logger import setup_logger
logger = setup_logger()
logger.debug(f"Episode {episode.episode_code} already exists in season {episode.season_id}, updating instead of inserting")
episode.id = existing_id
self.update_episode(episode)
return existing_id
# Insert new episode
cursor.execute("""
INSERT INTO episodes (
season_id, episode_number, episode_code, title, episode_id,
date_de_tv, date_de_streaming, date_de_home_media, date_de_sync, date_original,
comparison_date
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
episode.season_id, episode.episode_number, episode.episode_code, episode.title, episode.episode_id,
episode.date_de_tv.isoformat() if episode.date_de_tv else None,
episode.date_de_streaming.isoformat() if episode.date_de_streaming else None,
episode.date_de_home_media.isoformat() if episode.date_de_home_media else None,
episode.date_de_sync.isoformat() if episode.date_de_sync else None,
episode.date_original.isoformat() if episode.date_original else None,
episode.comparison_date.isoformat() if episode.comparison_date else None
))
return cursor.lastrowid
def get_episodes_by_season(self, season_id: int) -> List[Episode]:
"""Get all episodes for a season"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id, season_id, episode_number, episode_code, title, episode_id,
date_de_tv, date_de_streaming, date_de_home_media, date_de_sync, date_original, comparison_date
FROM episodes
WHERE season_id = ?
ORDER BY episode_code
""", (season_id,))
return [Episode.from_row(row) for row in cursor.fetchall()]
def episode_exists(self, season_id: int, episode_code: str) -> Optional[int]:
"""Check if episode exists, return ID if it does"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id FROM episodes
WHERE season_id = ? AND episode_code = ?
""", (season_id, episode_code))
row = cursor.fetchone()
return row[0] if row else None
def update_episode(self, episode: Episode):
"""Update an existing episode"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE episodes
SET episode_number = ?, title = ?,
date_de_tv = ?, date_de_streaming = ?, date_de_home_media = ?, date_de_sync = ?, date_original = ?,
comparison_date = ?
WHERE id = ?
""", (
episode.episode_number,
episode.title,
episode.date_de_tv.isoformat() if episode.date_de_tv else None,
episode.date_de_streaming.isoformat() if episode.date_de_streaming else None,
episode.date_de_home_media.isoformat() if episode.date_de_home_media else None,
episode.date_de_sync.isoformat() if episode.date_de_sync else None,
episode.date_original.isoformat() if episode.date_original else None,
episode.comparison_date.isoformat() if episode.comparison_date else None,
episode.id
))
def get_recent_episodes(self, series_id: int, limit: int = 20) -> List[Tuple[Season, Episode]]:
"""Get the most recent episodes for a series (by comparison_date)"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT s.id, s.series_id, s.name, s.season_type, s.sort_order,
e.id, e.season_id, e.episode_number, e.episode_code, e.title, e.episode_id,
e.date_de_tv, e.date_de_streaming, e.date_de_home_media, e.date_de_sync, e.date_original, e.comparison_date
FROM episodes e
JOIN seasons s ON e.season_id = s.id
WHERE s.series_id = ? AND e.comparison_date IS NOT NULL
ORDER BY e.comparison_date DESC
LIMIT ?
""", (series_id, limit))
results = []
for row in cursor.fetchall():
# Season: id, series_id, name, season_type, sort_order (5 fields)
season = Season.from_row(row[:5])
# Episode: id, season_id, episode_number, episode_code, title, episode_id, date_de_tv, date_de_streaming, date_de_home_media, date_de_sync, date_original, comparison_date (12 fields)
episode = Episode.from_row(row[5:17])
results.append((season, episode))
return results
def get_future_episodes(self, series_id: int) -> List[Tuple[Season, Episode]]:
"""Get all future episodes for a series"""
with self._get_connection() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("""
SELECT s.id, s.series_id, s.name, s.season_type, s.sort_order,
e.id, e.season_id, e.episode_number, e.episode_code, e.title, e.episode_id,
e.date_de_tv, e.date_de_streaming, e.date_de_home_media, e.date_de_sync, e.date_original, e.comparison_date
FROM episodes e
JOIN seasons s ON e.season_id = s.id
WHERE s.series_id = ? AND e.comparison_date > ?
ORDER BY e.comparison_date
""", (series_id, now))
results = []
for row in cursor.fetchall():
# Season: 5 fields, Episode: 12 fields
season = Season.from_row(row[:5])
episode = Episode.from_row(row[5:17])
results.append((season, episode))
return results
def get_episode_count(self, series_id: int) -> int:
"""Get total episode count for a series"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*)
FROM episodes e
JOIN seasons s ON e.season_id = s.id
WHERE s.series_id = ?
""", (series_id,))
return cursor.fetchone()[0]
def has_future_episodes(self, series_id: int) -> bool:
"""Check if a series has any future episodes (comparison_date > now)"""
with self._get_connection() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
cursor.execute("""
SELECT COUNT(*)
FROM episodes e
JOIN seasons s ON e.season_id = s.id
WHERE s.series_id = ?
AND e.comparison_date IS NOT NULL
AND e.comparison_date > ?
""", (series_id, now))
count = cursor.fetchone()[0]
return count > 0
# ==================== SETTINGS OPERATIONS ====================
def get_setting(self, key: str, default: str = None) -> Optional[str]:
"""Get a setting value"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT value FROM settings WHERE key = ?", (key,))
row = cursor.fetchone()
return row[0] if row else default
def set_setting(self, key: str, value: str):
"""Set a setting value"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO settings (key, value)
VALUES (?, ?)
""", (key, value))