Kompletter Rewrite
This commit is contained in:
1
serien_checker/database/__init__.py
Normal file
1
serien_checker/database/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Database module for Serien-Checker"""
|
||||
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))
|
||||
232
serien_checker/database/models.py
Normal file
232
serien_checker/database/models.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Data models and enums for Serien-Checker database
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional, Union
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def _safe_parse_datetime(value: Union[str, datetime, None]) -> Optional[datetime]:
|
||||
"""
|
||||
Safely parse a datetime value that might be a string, datetime object, or None
|
||||
|
||||
Args:
|
||||
value: String ISO format, datetime object, or None
|
||||
|
||||
Returns:
|
||||
datetime object or None
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return datetime.fromisoformat(value)
|
||||
return None
|
||||
|
||||
|
||||
class SeasonType(Enum):
|
||||
"""Types of seasons"""
|
||||
NORMAL = "normal"
|
||||
SPECIALS = "specials"
|
||||
EXTRAS = "extras"
|
||||
BEST_OF = "best_of"
|
||||
YEAR_BASED = "year_based"
|
||||
|
||||
|
||||
class DatePreference(Enum):
|
||||
"""Preferred date types for episode releases"""
|
||||
DE_FIRST = "de_first" # Deutsche Erstausstrahlung (frühestes deutsches Datum)
|
||||
DE_TV = "de_tv" # Deutsche TV-Premiere
|
||||
DE_STREAMING = "de_streaming" # Deutsche Streaming-Premiere
|
||||
DE_HOME_MEDIA = "de_home_media" # Deutsche Home-Media-Premiere
|
||||
DE_SYNC = "de_sync" # Deutsche Synchronfassung
|
||||
ORIGINAL = "original" # Erstausstrahlung (Original)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Series:
|
||||
"""Represents a TV series"""
|
||||
id: Optional[int]
|
||||
title: str
|
||||
url: str
|
||||
date_preference: DatePreference
|
||||
last_updated: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: tuple) -> 'Series':
|
||||
"""Create Series from database row"""
|
||||
return cls(
|
||||
id=row[0],
|
||||
title=row[1],
|
||||
url=row[2],
|
||||
date_preference=DatePreference(row[3]),
|
||||
last_updated=datetime.fromisoformat(row[4]) if row[4] else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Season:
|
||||
"""Represents a season of a series"""
|
||||
id: Optional[int]
|
||||
series_id: int
|
||||
name: str
|
||||
season_type: SeasonType
|
||||
sort_order: int
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: tuple) -> 'Season':
|
||||
"""Create Season from database row"""
|
||||
return cls(
|
||||
id=row[0],
|
||||
series_id=row[1],
|
||||
name=row[2],
|
||||
season_type=SeasonType(row[3]),
|
||||
sort_order=row[4]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
"""Represents an episode"""
|
||||
id: Optional[int]
|
||||
season_id: int
|
||||
episode_code: str # e.g., "01", "01a", "01b"
|
||||
title: str
|
||||
|
||||
# Episode number (overall series number from fernsehserien.de)
|
||||
episode_number: Optional[int] = None
|
||||
|
||||
# Episode ID from fernsehserien.de (e.g., "1828679")
|
||||
episode_id: Optional[str] = None
|
||||
|
||||
# All available air dates
|
||||
date_de_tv: Optional[datetime] = None
|
||||
date_de_streaming: Optional[datetime] = None
|
||||
date_de_home_media: Optional[datetime] = None
|
||||
date_de_sync: Optional[datetime] = None
|
||||
date_original: Optional[datetime] = None
|
||||
|
||||
# Comparison date (based on series preference)
|
||||
comparison_date: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: tuple) -> 'Episode':
|
||||
"""Create Episode from database row"""
|
||||
# Handle old (9/10/11 fields) and new (12 fields) schema
|
||||
if len(row) >= 12:
|
||||
# New schema with episode_number, episode_id, and date_de_home_media
|
||||
return cls(
|
||||
id=row[0],
|
||||
season_id=row[1],
|
||||
episode_number=row[2],
|
||||
episode_code=row[3],
|
||||
title=row[4],
|
||||
episode_id=row[5],
|
||||
date_de_tv=_safe_parse_datetime(row[6]),
|
||||
date_de_streaming=_safe_parse_datetime(row[7]),
|
||||
date_de_home_media=_safe_parse_datetime(row[8]),
|
||||
date_de_sync=_safe_parse_datetime(row[9]),
|
||||
date_original=_safe_parse_datetime(row[10]),
|
||||
comparison_date=_safe_parse_datetime(row[11])
|
||||
)
|
||||
elif len(row) >= 11:
|
||||
# Schema with episode_number and date_de_home_media but no episode_id
|
||||
return cls(
|
||||
id=row[0],
|
||||
season_id=row[1],
|
||||
episode_number=row[2],
|
||||
episode_code=row[3],
|
||||
title=row[4],
|
||||
episode_id=None,
|
||||
date_de_tv=_safe_parse_datetime(row[5]),
|
||||
date_de_streaming=_safe_parse_datetime(row[6]),
|
||||
date_de_home_media=_safe_parse_datetime(row[7]),
|
||||
date_de_sync=_safe_parse_datetime(row[8]),
|
||||
date_original=_safe_parse_datetime(row[9]),
|
||||
comparison_date=_safe_parse_datetime(row[10])
|
||||
)
|
||||
elif len(row) >= 10:
|
||||
# Schema with episode_number but without date_de_home_media and episode_id
|
||||
return cls(
|
||||
id=row[0],
|
||||
season_id=row[1],
|
||||
episode_number=row[2],
|
||||
episode_code=row[3],
|
||||
title=row[4],
|
||||
episode_id=None,
|
||||
date_de_tv=_safe_parse_datetime(row[5]),
|
||||
date_de_streaming=_safe_parse_datetime(row[6]),
|
||||
date_de_home_media=None,
|
||||
date_de_sync=_safe_parse_datetime(row[7]),
|
||||
date_original=_safe_parse_datetime(row[8]),
|
||||
comparison_date=_safe_parse_datetime(row[9])
|
||||
)
|
||||
else:
|
||||
# Old schema without episode_number, episode_id, and date_de_home_media
|
||||
return cls(
|
||||
id=row[0],
|
||||
season_id=row[1],
|
||||
episode_number=None,
|
||||
episode_code=row[2],
|
||||
title=row[3],
|
||||
episode_id=None,
|
||||
date_de_tv=_safe_parse_datetime(row[4]),
|
||||
date_de_streaming=_safe_parse_datetime(row[5]),
|
||||
date_de_home_media=None,
|
||||
date_de_sync=_safe_parse_datetime(row[6]),
|
||||
date_original=_safe_parse_datetime(row[7]),
|
||||
comparison_date=_safe_parse_datetime(row[8])
|
||||
)
|
||||
|
||||
def calculate_comparison_date(self, preference: DatePreference) -> Optional[datetime]:
|
||||
"""
|
||||
Calculate the comparison date based on preference with fallback logic
|
||||
"""
|
||||
# Special case: DE_FIRST = earliest German date
|
||||
if preference == DatePreference.DE_FIRST:
|
||||
german_dates = [
|
||||
d for d in [self.date_de_tv, self.date_de_streaming, self.date_de_home_media, self.date_de_sync]
|
||||
if d is not None
|
||||
]
|
||||
if german_dates:
|
||||
return min(german_dates)
|
||||
# No fallback - return None if no German date available
|
||||
return None
|
||||
|
||||
# Priority order based on preference
|
||||
# For German preferences: only use German dates, no fallback to original
|
||||
# For ORIGINAL preference: only use original date
|
||||
# Priority for German dates: TV → Streaming → Home-Media → Sync
|
||||
priority_map = {
|
||||
DatePreference.DE_TV: [self.date_de_tv, self.date_de_streaming, self.date_de_home_media, self.date_de_sync],
|
||||
DatePreference.DE_STREAMING: [self.date_de_streaming, self.date_de_tv, self.date_de_home_media, self.date_de_sync],
|
||||
DatePreference.DE_HOME_MEDIA: [self.date_de_home_media, self.date_de_streaming, self.date_de_tv, self.date_de_sync],
|
||||
DatePreference.DE_SYNC: [self.date_de_sync, self.date_de_home_media, self.date_de_streaming, self.date_de_tv],
|
||||
DatePreference.ORIGINAL: [self.date_original]
|
||||
}
|
||||
|
||||
dates = priority_map.get(preference, [])
|
||||
for date in dates:
|
||||
if date is not None:
|
||||
return date
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Application settings"""
|
||||
id: Optional[int]
|
||||
key: str
|
||||
value: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: tuple) -> 'Settings':
|
||||
"""Create Settings from database row"""
|
||||
return cls(
|
||||
id=row[0],
|
||||
key=row[1],
|
||||
value=row[2]
|
||||
)
|
||||
Reference in New Issue
Block a user