478 lines
21 KiB
Python
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))
|