Kompletter Rewrite
This commit is contained in:
477
serien_checker/database/db_manager.py
Normal file
477
serien_checker/database/db_manager.py
Normal file
@@ -0,0 +1,477 @@
|
||||
"""
|
||||
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))
|
||||
Reference in New Issue
Block a user