Kompletter Rewrite

This commit is contained in:
Akamaru
2025-12-21 14:35:08 +01:00
parent 79b8aa2a34
commit 5fa6bfeb62
27 changed files with 3946 additions and 1197 deletions

58
.gitignore vendored
View File

@@ -1,5 +1,55 @@
/build # Python
/dist __pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec.bak
# Logs
logs/
*.log
# Database
*.db
*.db-journal
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Project specific
serien_checker.db
.serien_checker/
serien_checker_plan.md
/versions /versions
series_config.json /.claude
release.md /tests

176
README.md
View File

@@ -1,108 +1,132 @@
# Serien-Checker # Serien-Checker
Ein Programm zum Überprüfen von Ausstrahlungsterminen deutscher TV-Serien. Die Daten werden von fernsehserien.de abgerufen. Ein Desktop-Programm zum Tracken von TV-Serien-Episoden von fernsehserien.de.
## Features ## Features
- Verfolgen Sie mehrere Serien gleichzeitig - **Episoden-Tracking**: Verfolgen Sie Staffeln und Episoden Ihrer Lieblingsserien
- Anzeige deutscher Ausstrahlungstermine (TV und Streaming) - **Ausstrahlungsdaten**: Anzeige verschiedener Datums-Typen (DE TV, DE Streaming, Sync, Original)
- Staffel-spezifische Filterung - **Zukünftige Episoden**: Automatische Markierung kommender Folgen
- Datumspräferenz (TV, Streaming oder früheste Ausstrahlung) - **Delta-Updates**: Intelligente Aktualisierung nur neuer/geänderter Daten
- Übersichtliche Episodenliste mit Datum, Staffel, Folge und Titel - **Portable Modus**: Wahlweise portable Installation ohne Registry-Einträge
- **Offline-Fähig**: Lokale SQLite-Datenbank
## Screenshots - **Automatisches Scraping**: Vollständig funktionsfähiger Web-Scraper für fernsehserien.de
- **Threading**: Asynchrone Updates ohne UI-Blockierung
### Hauptfenster
![Hauptfenster1](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/1.png)
![Hauptfenster2](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/2.png)
### Serien verwalten
![Neue Serie](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/3.png)
### Neue Serie hinzufügen
![Debug Log](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/4.png)
## Installation ## Installation
### Option 1: Ausführbare Datei (Windows) ### Methode 1: uv (empfohlen)
1. Laden Sie die neueste Version von der [Releases](https://git.ponywave.de/Akamaru/Serien-Checker/releases) Seite herunter
2. Entpacken Sie die ZIP-Datei
3. Starten Sie `Serien-Checker.exe`
### Option 2: Aus dem Quellcode
1. Stellen Sie sicher, dass Python 3.8 oder höher installiert ist
2. Klonen Sie das Repository:
```bash ```bash
git clone https://git.ponywave.de/Akamaru/Serien-Checker.git # Programm starten (installiert Dependencies automatisch)
cd Serien-Checker uv run main.py
``` ```
3. Installieren Sie die Abhängigkeiten:
### Methode 2: Traditionell mit pip
```bash ```bash
# Dependencies installieren
pip install -r requirements.txt pip install -r requirements.txt
```
4. Starten Sie das Programm: # Programm starten
```bash python main.py
python serien_checker.py
``` ```
### Executable erstellen ### Methode 3: Windows EXE erstellen
Um Ihre eigene ausführbare Datei zu erstellen:
1. Führen Sie `build.bat` aus, oder
2. Manuell:
```bash ```bash
pip install -r requirements.txt # PyInstaller installieren
pip install pyinstaller pip install pyinstaller
python build.py
```
Die ausführbare Datei finden Sie dann im `dist` Ordner. # Build ausführen
build.bat
# oder
pyinstaller build.spec
# EXE findet sich in: dist/Serien-Checker.exe
```
## Verwendung ## Verwendung
### Serien hinzufügen ### Serie hinzufügen
1. Klicken Sie auf "Serien verwalten" 1. Öffnen Sie **Einstellungen → Optionen**
2. Klicken Sie auf "Neue Serie" 2. Im Tab "Serien" die fernsehserien.de URL eingeben
3. Geben Sie die URL oder den Slug von fernsehserien.de ein 3. Bevorzugten Datumstyp wählen
- Beispiel URL: `https://www.fernsehserien.de/9-1-1-notruf-l-a` 4. "Serie hinzufügen" klicken
- Beispiel Slug: `9-1-1-notruf-l-a`
4. Wählen Sie die gewünschten Einstellungen:
- Staffel-Modus (Neuste, Alle, Bestimmte)
- Datumspräferenz (Erstausstrahlung, TV, Streaming)
### Serien verwalten ### Serie aktualisieren
- Wählen Sie eine Serie aus der Liste - Rechtsklick auf Serie → "Aktualisieren"
- Ändern Sie die Einstellungen nach Bedarf - Oder: Menü → Serien → Aktualisieren
- Klicken Sie auf "Einstellungen speichern"
- Löschen Sie unerwünschte Serien mit dem "Löschen" Button
### Episoden anzeigen ### Staffeln/Episoden anzeigen
- Wählen Sie eine Serie aus der Liste im Hauptfenster - Serie in linker Spalte auswählen
- Die Episoden werden automatisch geladen - Staffel in rechter Spalte auswählen
- Die Liste wird alle 30 Minuten automatisch aktualisiert - Episoden erscheinen in der Mitte
- Klicken Sie auf "Aktualisieren" für sofortige Aktualisierung - Zukünftige Episoden sind grün markiert
## Konfiguration ## Projektstruktur
Die Einstellungen werden automatisch in `series_config.json` gespeichert. Diese Datei wird beim ersten Start erstellt und enthält: ```
- Liste der Serien serien_checker/
- Staffel-Einstellungen pro Serie ├── main.py # Entry Point
- Datumspräferenzen pro Serie ├── serien_checker/
│ ├── database/ # SQLite Datenbank-Layer
│ ├── scraper/ # Web-Scraping (browser-tools)
│ ├── ui/ # PyQt5 Benutzeroberfläche
│ └── utils/ # Hilfsfunktionen
├── icon.ico # Programm-Icon
├── requirements.txt # Python-Dependencies
└── build.spec # PyInstaller-Konfiguration
```
## Fehlerbehebung ## Technische Details
### Keine Episoden werden angezeigt - **Python**: 3.11+
- Prüfen Sie Ihre Internetverbindung - **GUI**: PyQt5
- Prüfen Sie, ob die Serie auf fernsehserien.de verfügbar ist - **Datenbank**: SQLite (nativ)
- Prüfen Sie die Staffel-Einstellungen - **Scraping**: browser-tools Skill (geplant)
- **Packaging**: PyInstaller
### Keine deutschen Titel ## Datenspeicherung
- Einige Episoden haben noch keine deutschen Titel
- Diese werden als "Noch kein Titel" angezeigt ### Standard-Modus
- Die Titel werden automatisch aktualisiert, sobald sie verfügbar sind - Windows: `%USERPROFILE%\.serien_checker\serien_checker.db`
- Linux: `~/.serien_checker/serien_checker.db`
### Portable-Modus
- Datenbank im Programmverzeichnis: `serien_checker.db`
## Unterstützte Serien-Strukturen
1. Normale Staffeln
2. Normale Staffeln + Specials
3. Normale Staffeln + Extras + Best-Of
4. Nur Extras (keine klassischen Staffeln)
5. Jahresbasierte Sortierung
6. Mehrteilige Episoden (A/B-Parts)
## Entwicklung
### Browser-Tools Integration
Der HTML-Parser nutzt das `browser-tools` Skill für robustes DOM-basiertes Scraping.
Die Integration ist vorbereitet in `serien_checker/scraper/browser_scraper.py`.
### Logging
Logs werden gespeichert:
- Standard: `~/.serien_checker/logs/`
- Portable: `./logs/`
## Lizenz
Dieses Projekt ist für private Nutzung bestimmt.
## Hinweise
- **Datenquelle**: fernsehserien.de
- Bitte respektieren Sie die Nutzungsbedingungen der Website
- Scraping sollte mit angemessenen Delays erfolgen

View File

@@ -1,31 +1,33 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_submodules
hiddenimports = ['serien_checker', 'serien_checker.database', 'serien_checker.database.db_manager', 'serien_checker.database.models', 'serien_checker.scraper', 'serien_checker.scraper.browser_scraper', 'serien_checker.scraper.fernsehserien_scraper', 'serien_checker.ui', 'serien_checker.ui.main_window', 'serien_checker.ui.options_dialog', 'serien_checker.ui.widgets', 'serien_checker.utils', 'serien_checker.utils.logger', 'serien_checker.utils.threading']
block_cipher = None hiddenimports += collect_submodules('PyQt5')
hiddenimports += collect_submodules('requests')
hiddenimports += collect_submodules('bs4')
hiddenimports += collect_submodules('lxml')
hiddenimports += collect_submodules('serien_checker')
a = Analysis( a = Analysis(
['serien_checker.py'], ['D:\\GitHub\\Serien-Checker\\main.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[('series_config.json', '.')], datas=[('D:\\GitHub\\Serien-Checker\\icon.ico', '.')],
hiddenimports=[], hiddenimports=hiddenimports,
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False, noarchive=False,
optimize=0,
) )
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) pyz = PYZ(a.pure)
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
a.binaries, a.binaries,
a.zipfiles,
a.datas, a.datas,
[], [],
name='Serien-Checker', name='Serien-Checker',
@@ -41,5 +43,5 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
icon=['icon.ico'], icon=['D:\\GitHub\\Serien-Checker\\icon.ico'],
) )

View File

@@ -1,15 +1,16 @@
@echo off @echo off
echo Installing required packages... echo Building Serien-Checker.exe...
pip install -r requirements.txt echo.
pip install pyinstaller
echo Building executable... uv run build.py
python build.py
echo Done! if %errorlevel% neq 0 (
if exist "dist\Serien-Checker.exe" ( echo.
echo Executable created successfully at dist\Serien-Checker.exe echo Build failed with error code %errorlevel%
) else ( pause
echo Error: Build failed! exit /b %errorlevel%
) )
echo.
echo Build completed successfully!
pause pause

109
build.py
View File

@@ -1,20 +1,95 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "pyinstaller>=6.11.1",
# "PyQt5>=5.15.0",
# "requests>=2.31.0",
# "beautifulsoup4>=4.12.0",
# "lxml>=4.9.0",
# ]
# ///
"""
Build script for creating a onefile EXE of Serien-Checker using PyInstaller.
Usage: uv run build.py
"""
import sys
from pathlib import Path
import PyInstaller.__main__ import PyInstaller.__main__
import os
import shutil
# Lösche alte build und dist Ordner def build_exe():
if os.path.exists('build'): """Build the Serien-Checker executable."""
shutil.rmtree('build')
if os.path.exists('dist'):
shutil.rmtree('dist')
# PyInstaller Konfiguration # Get project root directory
PyInstaller.__main__.run([ project_root = Path(__file__).parent
'serien_checker.py',
'--onefile', # Define paths
'--windowed', main_script = project_root / "main.py"
'--name=Serien-Checker', icon_file = project_root / "icon.ico"
'--icon=icon.ico', # Optional: Fügen Sie ein Icon hinzu wenn gewünscht
'--add-data=series_config.json;.', # Fügt die Konfigurationsdatei hinzu wenn sie existiert # Verify required files exist
'--clean' if not main_script.exists():
]) print(f"Error: main.py not found at {main_script}")
sys.exit(1)
if not icon_file.exists():
print(f"Error: icon.ico not found at {icon_file}")
sys.exit(1)
print("Building Serien-Checker.exe...")
print(f"Entry point: {main_script}")
print(f"Icon: {icon_file}")
# PyInstaller arguments
args = [
str(main_script), # Entry point
"--name=Serien-Checker", # Name of the executable
"--onefile", # Create a single executable
"--windowed", # No console window (GUI app)
f"--icon={icon_file}", # Application icon (for EXE itself)
"--noconfirm", # Replace output directory without asking
# Add icon.ico as data file so it's available at runtime
f"--add-data={icon_file};.",
# Collect all PyQt5 submodules
"--collect-submodules=PyQt5",
# Collect other third-party dependencies
"--collect-submodules=requests",
"--collect-submodules=bs4",
"--collect-submodules=lxml",
# Hidden imports for all serien_checker submodules
"--hidden-import=serien_checker",
"--hidden-import=serien_checker.database",
"--hidden-import=serien_checker.database.db_manager",
"--hidden-import=serien_checker.database.models",
"--hidden-import=serien_checker.scraper",
"--hidden-import=serien_checker.scraper.browser_scraper",
"--hidden-import=serien_checker.scraper.fernsehserien_scraper",
"--hidden-import=serien_checker.ui",
"--hidden-import=serien_checker.ui.main_window",
"--hidden-import=serien_checker.ui.options_dialog",
"--hidden-import=serien_checker.ui.widgets",
"--hidden-import=serien_checker.utils",
"--hidden-import=serien_checker.utils.logger",
"--hidden-import=serien_checker.utils.threading",
# Collect all submodules from serien_checker package
"--collect-submodules=serien_checker",
]
# Run PyInstaller
try:
PyInstaller.__main__.run(args)
print("\n✓ Build successful!")
print(f"Executable created: {project_root / 'dist' / 'Serien-Checker.exe'}")
except Exception as e:
print(f"\n✗ Build failed: {e}")
sys.exit(1)
if __name__ == "__main__":
build_exe()

55
main.py Normal file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "PyQt5>=5.15.0",
# "requests>=2.31.0",
# "beautifulsoup4>=4.12.0",
# "lxml>=4.9.0",
# ]
# ///
"""
Serien-Checker - TV Series Episode Tracker
Main entry point for the application
"""
import sys
from pathlib import Path
# Add serien_checker to path
sys.path.insert(0, str(Path(__file__).parent))
from PyQt5.QtWidgets import QApplication
from serien_checker.database.db_manager import DatabaseManager
from serien_checker.ui.main_window import MainWindow
from serien_checker.utils.logger import setup_logger
def main():
"""Main application entry point"""
# Setup logger
logger = setup_logger()
logger.info("Starting Serien-Checker...")
# Create Qt application
app = QApplication(sys.argv)
app.setApplicationName("Serien-Checker")
app.setOrganizationName("Serien-Checker")
# Initialize database
db_manager = DatabaseManager()
logger.info(f"Database initialized at: {db_manager.db_path}")
# Create and show main window
window = MainWindow(db_manager)
window.show()
logger.info("Application started successfully")
# Run event loop
sys.exit(app.exec_())
if __name__ == "__main__":
main()

View File

@@ -1,3 +1,5 @@
PyQt5==5.15.9 PyQt5>=5.15.0
requests==2.31.0 requests>=2.31.0
beautifulsoup4==4.12.2 beautifulsoup4>=4.12.0
lxml>=4.9.0
pyinstaller==6.11.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
"""
Serien-Checker - TV Series Episode Tracker
"""
__version__ = "2.0.0"
__author__ = "Serien-Checker Project"

View File

@@ -0,0 +1 @@
"""Database module for Serien-Checker"""

View 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))

View 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]
)

View File

@@ -0,0 +1 @@
"""Web scraping module for fernsehserien.de"""

View File

@@ -0,0 +1,307 @@
"""
Browser-based scraper for fernsehserien.de using browser-tools
"""
import re
from typing import List, Dict, Optional, Tuple
from datetime import datetime
from dataclasses import dataclass
from ..database.models import SeasonType, DatePreference
from ..utils.logger import setup_logger
logger = setup_logger()
@dataclass
class ScrapedEpisode:
"""Scraped episode data"""
episode_code: str
title: str
episode_number: Optional[int] = None # Overall episode number from fernsehserien.de
episode_id: Optional[str] = None # Episode ID from fernsehserien.de URL (e.g., "1828679")
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
@dataclass
class ScrapedSeason:
"""Scraped season data"""
name: str
season_type: SeasonType
sort_order: int
episodes: List[ScrapedEpisode]
class BrowserScraper:
"""
Scraper for fernsehserien.de using browser automation
This class uses the browser-tools skill to interact with web pages
and extract structured data from the DOM.
"""
BASE_URL = "https://www.fernsehserien.de"
def __init__(self, browser_page=None):
"""
Initialize scraper
Args:
browser_page: Browser page instance from browser-tools skill
"""
self.page = browser_page
@staticmethod
def extract_series_slug(url: str) -> str:
"""Extract series slug from URL"""
# https://www.fernsehserien.de/black-mirror/episodenguide -> black-mirror
match = re.search(r'fernsehserien\.de/([^/]+)', url)
return match.group(1) if match else ""
@staticmethod
def parse_german_date(date_str: str) -> Optional[datetime]:
"""
Parse German date format to datetime
Supports formats:
- DD.MM.YYYY
- DD.MM.YY
- YYYY
"""
if not date_str or date_str.strip() == "":
return None
date_str = date_str.strip()
# Try DD.MM.YYYY or DD.MM.YY
patterns = [
r'(\d{1,2})\.(\d{1,2})\.(\d{4})', # DD.MM.YYYY
r'(\d{1,2})\.(\d{1,2})\.(\d{2})', # DD.MM.YY
]
for pattern in patterns:
match = re.search(pattern, date_str)
if match:
day, month, year = match.groups()
if len(year) == 2:
year = f"20{year}"
try:
return datetime(int(year), int(month), int(day))
except ValueError:
continue
# Try just year (YYYY)
year_match = re.search(r'\b(19\d{2}|20\d{2})\b', date_str)
if year_match:
try:
return datetime(int(year_match.group(1)), 1, 1)
except ValueError:
pass
return None
@staticmethod
def classify_season_type(season_name: str) -> SeasonType:
"""
Classify season type based on name
Args:
season_name: Season name (e.g., "Staffel 1", "Specials", "2022")
Returns:
SeasonType enum value
"""
name_lower = season_name.lower()
# Check for specials
if any(keyword in name_lower for keyword in ['special', 'specials']):
return SeasonType.SPECIALS
# Check for extras
if any(keyword in name_lower for keyword in ['extra', 'extras', 'bonus']):
return SeasonType.EXTRAS
# Check for best-of
if any(keyword in name_lower for keyword in ['best', 'best-of', 'best of']):
return SeasonType.BEST_OF
# Check for year-based (e.g., "2021", "2022")
if re.match(r'^(19|20)\d{2}$', season_name.strip()):
return SeasonType.YEAR_BASED
# Default to normal
return SeasonType.NORMAL
@staticmethod
def extract_episode_code(episode_text: str) -> str:
"""
Extract episode code from text
Examples:
- "1. Folge" -> "01"
- "1a. Teil A" -> "01a"
- "12b. Teil B" -> "12b"
"""
# Match patterns like "1.", "12a.", "5b."
match = re.search(r'^(\d+[a-z]?)\.', episode_text.strip())
if match:
code = match.group(1)
# Pad single digits
if code.isdigit():
return code.zfill(2)
# Handle "1a" -> "01a"
elif len(code) >= 2 and code[:-1].isdigit():
return code[:-1].zfill(2) + code[-1]
return "00"
def scrape_series(self, url: str) -> Tuple[str, List[ScrapedSeason]]:
"""
Scrape series data from fernsehserien.de
Args:
url: Full URL to episode guide
Returns:
Tuple of (series_title, list of ScrapedSeason)
"""
logger.info(f"Scraping series from {url}")
# Use the BeautifulSoup-based scraper
try:
from .fernsehserien_scraper import FernsehserienScraper
scraper = FernsehserienScraper()
return scraper.scrape_series(url)
except Exception as e:
logger.error(f"Error scraping series: {e}")
return "Unknown Series", []
def scrape_season_episodes(self, season_url: str) -> List[ScrapedEpisode]:
"""
Scrape episodes for a specific season
Args:
season_url: URL to season page
Returns:
List of ScrapedEpisode objects
"""
logger.info(f"Scraping season from {season_url}")
# Placeholder - to be implemented with browser-tools
episodes = []
return episodes
class SeriesUpdater:
"""
Handles delta updates for series data
"""
def __init__(self, db_manager, scraper: BrowserScraper, progress_callback=None):
"""
Initialize updater
Args:
db_manager: DatabaseManager instance
scraper: BrowserScraper instance
progress_callback: Optional callback for progress updates (percent, message)
"""
self.db = db_manager
self.scraper = scraper
self.progress_callback = progress_callback
self.logger = setup_logger()
def _report_progress(self, percent: int, message: str):
"""Report progress if callback is set"""
if self.progress_callback:
self.progress_callback(percent, message)
self.logger.info(message)
def update_series(self, series_id: int) -> Dict[str, int]:
"""
Update a series by clearing old data and re-scraping
Args:
series_id: Database ID of series to update
Returns:
Dictionary with counts of new/updated/unchanged items
"""
stats = {
'new_seasons': 0,
'new_episodes': 0,
'updated_episodes': 0,
'unchanged': 0
}
series = self.db.get_series(series_id)
if not series:
self.logger.error(f"Series {series_id} not found")
return stats
self._report_progress(0, f"Aktualisiere: {series.title}")
# Clear existing seasons and episodes to avoid duplicates
self._report_progress(5, "Lösche alte Daten...")
self.db.clear_series_data(series_id)
# Scrape fresh data
self._report_progress(10, "Lade Episodenführer...")
title, scraped_seasons = self.scraper.scrape_series(series.url)
# Update series title if it changed
if title and title != series.title:
series.title = title
self.db.update_series(series)
# Add all seasons and episodes (they're all new since we cleared the data)
total_seasons = len(scraped_seasons)
for idx, scraped_season in enumerate(scraped_seasons):
# Calculate progress: 10-90% for scraping seasons
progress = 10 + int((idx / total_seasons) * 80) if total_seasons > 0 else 10
self._report_progress(progress, f"Speichere Staffel: {scraped_season.name}")
# Add new season
from ..database.models import Season
season = Season(
id=None,
series_id=series_id,
name=scraped_season.name,
season_type=scraped_season.season_type,
sort_order=scraped_season.sort_order
)
season.id = self.db.add_season(season)
stats['new_seasons'] += 1
# Add all episodes
for scraped_ep in scraped_season.episodes:
from ..database.models import Episode
episode = Episode(
id=None,
season_id=season.id,
episode_number=scraped_ep.episode_number,
episode_code=scraped_ep.episode_code,
title=scraped_ep.title,
episode_id=scraped_ep.episode_id,
date_de_tv=scraped_ep.date_de_tv,
date_de_streaming=scraped_ep.date_de_streaming,
date_de_home_media=scraped_ep.date_de_home_media,
date_de_sync=scraped_ep.date_de_sync,
date_original=scraped_ep.date_original
)
episode.comparison_date = episode.calculate_comparison_date(series.date_preference)
self.db.add_episode(episode)
stats['new_episodes'] += 1
# Update last_updated timestamp
self._report_progress(95, "Schließe Update ab...")
series.last_updated = datetime.now()
self.db.update_series(series)
self._report_progress(100, "Fertig!")
return stats

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""PyQt5 user interface components"""

View File

@@ -0,0 +1,622 @@
"""
Main window for Serien-Checker application
"""
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QListWidget, QTableWidget, QTableWidgetItem, QLabel, QPushButton,
QStatusBar, QMessageBox, QMenu, QAction, QHeaderView, QGroupBox
)
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtGui import QDesktopServices, QColor, QIcon
from datetime import datetime
from typing import Optional
from ..database.db_manager import DatabaseManager
from ..database.models import Series, Season, Episode, DatePreference
from ..utils.logger import setup_logger
from ..utils.threading import UpdateWorker
from .options_dialog import OptionsDialog
logger = setup_logger()
class MainWindow(QMainWindow):
"""Main application window with 3-column layout"""
def __init__(self, db_manager: DatabaseManager):
super().__init__()
self.db = db_manager
self.current_series: Optional[Series] = None
self.current_season: Optional[Season] = None
self.worker = None # Keep reference to worker thread
self.init_ui()
self.load_series_list()
def init_ui(self):
"""Initialize user interface"""
self.setWindowTitle("Serien-Checker")
self.setGeometry(100, 100, 1400, 800)
# Set window icon if available
try:
import sys
from pathlib import Path
if getattr(sys, 'frozen', False):
# Running as EXE - PyInstaller extracts to _MEIPASS
if hasattr(sys, '_MEIPASS'):
icon_path = Path(sys._MEIPASS) / "icon.ico"
else:
icon_path = Path(sys.executable).parent / "icon.ico"
else:
# Running as script
icon_path = Path(__file__).parent.parent.parent / "icon.ico"
if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path)))
except Exception:
pass
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
# Main layout
main_layout = QVBoxLayout(central_widget)
# Create splitter for 3-column layout
splitter = QSplitter(Qt.Horizontal)
# Left column: Series list
self.series_list_widget = self._create_series_list()
splitter.addWidget(self.series_list_widget)
# Middle column: Episode list
self.episode_list_widget = self._create_episode_list()
splitter.addWidget(self.episode_list_widget)
# Right column: Series info
self.info_widget = self._create_info_panel()
splitter.addWidget(self.info_widget)
# Set initial splitter sizes (30% - 40% - 30%)
splitter.setSizes([400, 600, 400])
main_layout.addWidget(splitter)
# Status bar
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_label = QLabel("Bereit")
self.last_update_label = QLabel("Letztes Update: Nie")
self.new_episodes_label = QLabel("Neue Folgen: 0")
self.status_bar.addWidget(self.status_label)
self.status_bar.addPermanentWidget(self.new_episodes_label)
self.status_bar.addPermanentWidget(self.last_update_label)
# Menu bar
self._create_menu_bar()
def _create_series_list(self) -> QWidget:
"""Create series list widget"""
widget = QWidget()
layout = QVBoxLayout(widget)
# Title
title = QLabel("Serien")
title.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(title)
# List widget
self.series_list = QListWidget()
self.series_list.currentItemChanged.connect(self.on_series_selected)
self.series_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.series_list.customContextMenuRequested.connect(self.show_series_context_menu)
layout.addWidget(self.series_list)
# Add series button
add_btn = QPushButton("+ Serie hinzufügen")
add_btn.clicked.connect(self.add_series)
layout.addWidget(add_btn)
return widget
def _create_episode_list(self) -> QWidget:
"""Create episode list widget"""
widget = QWidget()
layout = QVBoxLayout(widget)
# Title
title = QLabel("Folgen")
title.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(title)
# Table widget
self.episode_table = QTableWidget()
self.episode_table.setColumnCount(5)
self.episode_table.setHorizontalHeaderLabels(["Nr.", "Staffel", "Folge", "Titel", "Datum"])
self.episode_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) # Titel column stretches
self.episode_table.setSelectionBehavior(QTableWidget.SelectRows)
self.episode_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.episode_table.verticalHeader().setVisible(False) # Hide row numbers (1, 2, 3, 4...)
layout.addWidget(self.episode_table)
return widget
def _create_info_panel(self) -> QWidget:
"""Create series info panel"""
widget = QWidget()
layout = QVBoxLayout(widget)
# Title
title = QLabel("Informationen")
title.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(title)
# Info group
info_group = QGroupBox("Serie")
info_layout = QVBoxLayout(info_group)
self.info_title = QLabel("Keine Serie ausgewählt")
self.info_title.setWordWrap(True)
self.info_title.setStyleSheet("font-weight: bold;")
info_layout.addWidget(self.info_title)
self.info_seasons = QLabel("")
info_layout.addWidget(self.info_seasons)
self.info_episodes = QLabel("")
info_layout.addWidget(self.info_episodes)
self.info_link = QLabel("")
self.info_link.setOpenExternalLinks(True)
self.info_link.setTextFormat(Qt.RichText)
self.info_link.setTextInteractionFlags(Qt.TextBrowserInteraction)
info_layout.addWidget(self.info_link)
info_layout.addStretch()
layout.addWidget(info_group)
# Seasons group
seasons_group = QGroupBox("Staffeln")
seasons_layout = QVBoxLayout(seasons_group)
self.seasons_list = QListWidget()
self.seasons_list.currentItemChanged.connect(self.on_season_selected)
seasons_layout.addWidget(self.seasons_list)
layout.addWidget(seasons_group)
return widget
def _create_menu_bar(self):
"""Create menu bar"""
menubar = self.menuBar()
# File menu
file_menu = menubar.addMenu("Datei")
add_action = QAction("Serie hinzufügen", self)
add_action.triggered.connect(self.add_series)
file_menu.addAction(add_action)
file_menu.addSeparator()
exit_action = QAction("Beenden", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
# Series menu
series_menu = menubar.addMenu("Serien")
update_action = QAction("Aktualisieren", self)
update_action.triggered.connect(self.update_current_series)
series_menu.addAction(update_action)
update_all_action = QAction("Alle aktualisieren", self)
update_all_action.triggered.connect(self.update_all_series)
series_menu.addAction(update_all_action)
# Settings menu
settings_menu = menubar.addMenu("Einstellungen")
options_action = QAction("Optionen", self)
options_action.triggered.connect(self.show_options)
settings_menu.addAction(options_action)
# Help menu
help_menu = menubar.addMenu("Hilfe")
about_action = QAction("Über", self)
about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action)
def load_series_list(self):
"""Load all series into the list"""
self.series_list.clear()
series_list = self.db.get_all_series()
for series in series_list:
# Check if series has future episodes
has_new = self.db.has_future_episodes(series.id)
title = f"⭐️ {series.title}" if has_new else series.title
self.series_list.addItem(title)
self.status_label.setText(f"{len(series_list)} Serien geladen")
def on_series_selected(self, current, previous):
"""Handle series selection"""
if not current:
return
series_title = current.text()
# Remove star emoji if present
series_title = series_title.replace("⭐️ ", "")
series_list = self.db.get_all_series()
self.current_series = next((s for s in series_list if s.title == series_title), None)
if self.current_series:
self.load_series_info()
self.load_seasons_list()
# Show recent episodes automatically
self.load_recent_episodes()
def load_series_info(self):
"""Load series information panel"""
if not self.current_series:
return
self.info_title.setText(self.current_series.title)
seasons = self.db.get_seasons_by_series(self.current_series.id)
self.info_seasons.setText(f"Staffeln: {len(seasons)}")
episode_count = self.db.get_episode_count(self.current_series.id)
self.info_episodes.setText(f"Folgen: {episode_count}")
self.info_link.setText(f'<a href="{self.current_series.url}">fernsehserien.de öffnen</a>')
# Update last update time
if self.current_series.last_updated:
last_update_str = self.current_series.last_updated.strftime("%d.%m.%Y %H:%M")
self.last_update_label.setText(f"Letztes Update: {last_update_str}")
def load_seasons_list(self):
"""Load seasons list"""
if not self.current_series:
return
self.seasons_list.clear()
# Add "Neuste Folgen" as first item
self.seasons_list.addItem("Neuste Folgen")
# Add regular seasons
seasons = self.db.get_seasons_by_series(self.current_series.id)
for season in seasons:
self.seasons_list.addItem(season.name)
# Auto-select "Neuste Folgen" by default
self.seasons_list.setCurrentRow(0)
def on_season_selected(self, current, previous):
"""Handle season selection"""
if not current or not self.current_series:
return
season_name = current.text()
# Check if "Neuste Folgen" was selected
if season_name == "Neuste Folgen":
self.current_season = None # No specific season
self.load_recent_episodes()
return
# Regular season selection
seasons = self.db.get_seasons_by_series(self.current_series.id)
self.current_season = next((s for s in seasons if s.name == season_name), None)
if self.current_season:
self.load_episodes()
def load_recent_episodes(self):
"""Load 20 most recent episodes for current series"""
if not self.current_series:
return
recent_episodes = self.db.get_recent_episodes(self.current_series.id, limit=20)
self.episode_table.setRowCount(len(recent_episodes))
now = datetime.now()
for row, (season, episode) in enumerate(recent_episodes):
# Nr. (fortlaufende Gesamtnummer)
nr_text = str(episode.episode_number) if episode.episode_number else ""
nr_item = QTableWidgetItem(nr_text)
self.episode_table.setItem(row, 0, nr_item)
# Staffel
season_item = QTableWidgetItem(season.name)
self.episode_table.setItem(row, 1, season_item)
# Folge (Episodennummer der Staffel)
episode_item = QTableWidgetItem(episode.episode_code)
self.episode_table.setItem(row, 2, episode_item)
# Title
title_item = QTableWidgetItem(episode.title)
self.episode_table.setItem(row, 3, title_item)
# Date
date_str = episode.comparison_date.strftime("%d.%m.%Y") if episode.comparison_date else ""
date_item = QTableWidgetItem(date_str)
self.episode_table.setItem(row, 4, date_item)
# Highlight future episodes in green
highlight_enabled = self.db.get_setting("highlight_future", "true") == "true"
if highlight_enabled and episode.comparison_date and episode.comparison_date > now:
green = QColor(200, 255, 200)
nr_item.setBackground(green)
season_item.setBackground(green)
episode_item.setBackground(green)
title_item.setBackground(green)
date_item.setBackground(green)
def load_episodes(self):
"""Load episodes for current season"""
if not self.current_season:
return
episodes = self.db.get_episodes_by_season(self.current_season.id)
self.episode_table.setRowCount(len(episodes))
now = datetime.now()
for row, episode in enumerate(episodes):
# Nr. (episode_number from fernsehserien.de)
nr_text = str(episode.episode_number) if episode.episode_number else ""
nr_item = QTableWidgetItem(nr_text)
self.episode_table.setItem(row, 0, nr_item)
# Staffel (current season name)
season_item = QTableWidgetItem(self.current_season.name)
self.episode_table.setItem(row, 1, season_item)
# Folge (episode code)
episode_item = QTableWidgetItem(episode.episode_code)
self.episode_table.setItem(row, 2, episode_item)
# Title
title_item = QTableWidgetItem(episode.title)
self.episode_table.setItem(row, 3, title_item)
# Date
date_str = episode.comparison_date.strftime("%d.%m.%Y") if episode.comparison_date else ""
date_item = QTableWidgetItem(date_str)
self.episode_table.setItem(row, 4, date_item)
# Highlight future episodes in green
highlight_enabled = self.db.get_setting("highlight_future", "true") == "true"
if highlight_enabled and episode.comparison_date and episode.comparison_date > now:
green = QColor(200, 255, 200)
nr_item.setBackground(green)
season_item.setBackground(green)
episode_item.setBackground(green)
title_item.setBackground(green)
date_item.setBackground(green)
def show_series_context_menu(self, position):
"""Show context menu for series list"""
menu = QMenu()
update_action = QAction("Aktualisieren", self)
update_action.triggered.connect(self.update_current_series)
menu.addAction(update_action)
settings_action = QAction("Einstellungen", self)
settings_action.triggered.connect(self.show_series_settings)
menu.addAction(settings_action)
remove_action = QAction("Entfernen", self)
remove_action.triggered.connect(self.remove_current_series)
menu.addAction(remove_action)
menu.exec_(self.series_list.mapToGlobal(position))
def add_series(self):
"""Add a new series"""
from .options_dialog import OptionsDialog
dialog = OptionsDialog(self, self.db)
if dialog.exec_():
self.load_series_list()
def update_current_series(self):
"""Update currently selected series"""
if not self.current_series:
QMessageBox.warning(self, "Warnung", "Bitte wählen Sie zuerst eine Serie aus")
return
from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater
from ..utils.threading import UpdateWorker
from .widgets import ProgressDialog, UpdateResultDialog
# Create progress dialog
progress = ProgressDialog(self, "Serie aktualisieren")
progress.set_determinate() # Use determinate mode for progress bar
progress.show()
# Create worker
scraper = BrowserScraper()
def do_update():
# Progress callback that will be called from the worker thread
def progress_callback(percent, message):
self.worker.signals.progress.emit(percent, message)
updater = SeriesUpdater(self.db, scraper, progress_callback=progress_callback)
return updater.update_series(self.current_series.id)
self.worker = UpdateWorker(do_update)
def on_progress(percent, message):
progress.set_progress(percent, message)
def on_finished(stats):
progress.close()
UpdateResultDialog(self, stats).exec_()
# Reload UI
self.load_series_info()
self.load_seasons_list()
if self.current_season:
self.load_episodes()
self.worker = None # Clear worker reference
def on_error(error, traceback):
progress.close()
QMessageBox.critical(self, "Fehler", f"Update fehlgeschlagen:\n{error}")
self.worker = None # Clear worker reference
self.worker.signals.progress.connect(on_progress)
self.worker.signals.finished.connect(on_finished)
self.worker.signals.error.connect(on_error)
self.worker.start()
def update_all_series(self):
"""Update all series"""
all_series = self.db.get_all_series()
if not all_series:
QMessageBox.information(self, "Info", "Keine Serien vorhanden")
return
reply = QMessageBox.question(
self,
"Alle Serien aktualisieren",
f"Möchten Sie alle {len(all_series)} Serien aktualisieren?\nDies kann einige Minuten dauern.",
QMessageBox.Yes | QMessageBox.No
)
if reply != QMessageBox.Yes:
return
from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater
from ..utils.threading import UpdateWorker
from .widgets import ProgressDialog, UpdateResultDialog
# Create progress dialog
progress = ProgressDialog(self, "Alle Serien aktualisieren")
progress.set_determinate()
progress.show()
# Create worker
scraper = BrowserScraper()
def do_update_all():
total_stats = {
'new_seasons': 0,
'new_episodes': 0,
'updated_episodes': 0,
'unchanged': 0
}
total_series = len(all_series)
for idx, series in enumerate(all_series):
try:
# Progress callback for individual series
def progress_callback(percent, message):
# Calculate overall progress: each series gets an equal portion
overall_percent = int((idx / total_series) * 100 + (percent / total_series))
overall_message = f"[{idx + 1}/{total_series}] {series.title}: {message}"
self.worker.signals.progress.emit(overall_percent, overall_message)
updater = SeriesUpdater(self.db, scraper, progress_callback=progress_callback)
stats = updater.update_series(series.id)
for key in total_stats:
total_stats[key] += stats.get(key, 0)
except Exception as e:
logger.error(f"Error updating {series.title}: {e}")
return total_stats
self.worker = UpdateWorker(do_update_all)
def on_progress(percent, message):
progress.set_progress(percent, message)
def on_finished(stats):
progress.close()
UpdateResultDialog(self, stats).exec_()
# Reload UI
self.load_series_list()
if self.current_series:
self.load_series_info()
self.load_seasons_list()
if self.current_season:
self.load_episodes()
self.worker = None # Clear worker reference
def on_error(error, traceback):
progress.close()
QMessageBox.critical(self, "Fehler", f"Update fehlgeschlagen:\n{error}")
self.worker = None # Clear worker reference
self.worker.signals.progress.connect(on_progress)
self.worker.signals.finished.connect(on_finished)
self.worker.signals.error.connect(on_error)
self.worker.start()
def remove_current_series(self):
"""Remove currently selected series"""
if not self.current_series:
return
reply = QMessageBox.question(
self,
"Serie entfernen",
f"Möchten Sie '{self.current_series.title}' wirklich entfernen?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_series(self.current_series.id)
self.current_series = None
self.current_season = None
self.load_series_list()
self.episode_table.setRowCount(0)
self.seasons_list.clear()
self.info_title.setText("Keine Serie ausgewählt")
def show_series_settings(self):
"""Show settings for current series"""
QMessageBox.information(self, "Hinweis", "Einstellungen werden noch implementiert")
def show_options(self):
"""Show options dialog"""
dialog = OptionsDialog(self, self.db)
if dialog.exec_():
self.load_series_list()
def show_about(self):
"""Show about dialog"""
QMessageBox.about(
self,
"Über Serien-Checker",
"Serien-Checker v2.0\n\n"
"TV-Serien Episode Tracker\n"
"Datenquelle: fernsehserien.de"
)
def closeEvent(self, event):
"""Handle window close event"""
# Wait for worker thread to finish if running
if self.worker and self.worker.isRunning():
logger.info("Waiting for worker thread to finish...")
self.worker.wait(5000) # Wait max 5 seconds
if self.worker.isRunning():
logger.warning("Worker thread did not finish, terminating...")
self.worker.terminate()
self.worker.wait()
event.accept()

View File

@@ -0,0 +1,599 @@
"""
Options dialog for Serien-Checker
"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox,
QListWidget, QMessageBox, QGroupBox, QFormLayout, QFileDialog,
QProgressDialog
)
from PyQt5.QtCore import Qt
import json
import os
import sys
from ..database.db_manager import DatabaseManager
from ..database.models import Series, DatePreference
from ..utils.logger import setup_logger
logger = setup_logger()
class OptionsDialog(QDialog):
"""Options and settings dialog"""
def __init__(self, parent, db_manager: DatabaseManager):
super().__init__(parent)
self.db = db_manager
self.setWindowTitle("Optionen")
self.setModal(True)
self.resize(700, 500)
self.init_ui()
def init_ui(self):
"""Initialize UI"""
layout = QVBoxLayout(self)
# Tab widget
tabs = QTabWidget()
tabs.addTab(self._create_series_tab(), "Serien")
tabs.addTab(self._create_settings_tab(), "Einstellungen")
layout.addWidget(tabs)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
close_btn = QPushButton("Schließen")
close_btn.clicked.connect(self.accept)
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
def _create_series_tab(self) -> QWidget:
"""Create series management tab"""
widget = QWidget()
layout = QVBoxLayout(widget)
# Add series group
add_group = QGroupBox("Serie hinzufügen")
add_layout = QFormLayout(add_group)
self.series_url_input = QLineEdit()
self.series_url_input.setPlaceholderText("https://www.fernsehserien.de/.../episodenguide")
add_layout.addRow("URL:", self.series_url_input)
self.date_pref_combo = QComboBox()
self.date_pref_combo.addItem("Deutsche Erstausstrahlung (frühestes DE-Datum)", DatePreference.DE_FIRST.value)
self.date_pref_combo.addItem("Deutsche TV-Premiere", DatePreference.DE_TV.value)
self.date_pref_combo.addItem("Deutsche Streaming-Premiere", DatePreference.DE_STREAMING.value)
self.date_pref_combo.addItem("Deutsche Home-Media-Premiere", DatePreference.DE_HOME_MEDIA.value)
self.date_pref_combo.addItem("Deutsche Synchronfassung", DatePreference.DE_SYNC.value)
self.date_pref_combo.addItem("Original-Erstausstrahlung", DatePreference.ORIGINAL.value)
add_layout.addRow("Bevorzugtes Datum:", self.date_pref_combo)
add_btn = QPushButton("Serie hinzufügen")
add_btn.clicked.connect(self.add_series)
add_layout.addRow("", add_btn)
layout.addWidget(add_group)
# Manage series group
manage_group = QGroupBox("Serien verwalten")
manage_layout = QVBoxLayout(manage_group)
self.series_list = QListWidget()
self.load_series_list()
manage_layout.addWidget(self.series_list)
button_row = QHBoxLayout()
edit_btn = QPushButton("Bearbeiten")
edit_btn.clicked.connect(self.edit_series)
button_row.addWidget(edit_btn)
remove_btn = QPushButton("Entfernen")
remove_btn.clicked.connect(self.remove_series)
button_row.addWidget(remove_btn)
manage_layout.addLayout(button_row)
layout.addWidget(manage_group)
return widget
def _create_settings_tab(self) -> QWidget:
"""Create settings tab"""
widget = QWidget()
layout = QVBoxLayout(widget)
# General settings
general_group = QGroupBox("Allgemein")
general_layout = QVBoxLayout(general_group)
self.portable_mode_check = QCheckBox("Portable Modus (Datenbank im Programmverzeichnis)")
current_portable = self.db.get_setting("portable_mode", "false")
self.portable_mode_check.setChecked(current_portable == "true")
general_layout.addWidget(self.portable_mode_check)
layout.addWidget(general_group)
# Update settings
update_group = QGroupBox("Aktualisierung")
update_layout = QVBoxLayout(update_group)
self.auto_update_check = QCheckBox("Automatisch beim Start aktualisieren")
auto_update = self.db.get_setting("auto_update", "false")
self.auto_update_check.setChecked(auto_update == "true")
update_layout.addWidget(self.auto_update_check)
layout.addWidget(update_group)
# Display settings
display_group = QGroupBox("Anzeige")
display_layout = QVBoxLayout(display_group)
self.highlight_future_check = QCheckBox("Zukünftige Folgen grün markieren")
highlight = self.db.get_setting("highlight_future", "true")
self.highlight_future_check.setChecked(highlight == "true")
display_layout.addWidget(self.highlight_future_check)
layout.addWidget(display_group)
# Import/Export settings
import_group = QGroupBox("Import/Export")
import_layout = QVBoxLayout(import_group)
import_btn = QPushButton("Serien aus series_config.json importieren")
import_btn.clicked.connect(self.import_from_config)
import_layout.addWidget(import_btn)
import_info = QLabel("Importiert Serien aus der alten series_config.json Datei.\n"
"Duplikate werden automatisch übersprungen.")
import_info.setWordWrap(True)
import_info.setStyleSheet("color: gray; font-size: 10px;")
import_layout.addWidget(import_info)
layout.addWidget(import_group)
layout.addStretch()
# Save button
save_btn = QPushButton("Einstellungen speichern")
save_btn.clicked.connect(self.save_settings)
layout.addWidget(save_btn)
return widget
def load_series_list(self):
"""Load series into list"""
self.series_list.clear()
series_list = self.db.get_all_series()
for series in series_list:
pref_name = self._get_date_pref_name(series.date_preference)
self.series_list.addItem(f"{series.title} ({pref_name})")
def _get_date_pref_name(self, pref: DatePreference) -> str:
"""Get display name for date preference"""
names = {
DatePreference.DE_FIRST: "DE Erst.",
DatePreference.DE_TV: "DE TV",
DatePreference.DE_STREAMING: "DE Stream",
DatePreference.DE_HOME_MEDIA: "DE HMedia",
DatePreference.DE_SYNC: "DE Sync",
DatePreference.ORIGINAL: "Original"
}
return names.get(pref, "Unbekannt")
def add_series(self):
"""Add a new series"""
url = self.series_url_input.text().strip()
if not url:
QMessageBox.warning(self, "Fehler", "Bitte geben Sie eine URL ein")
return
if "fernsehserien.de" not in url:
QMessageBox.warning(self, "Fehler", "Bitte geben Sie eine gültige fernsehserien.de URL ein")
return
# Get date preference
date_pref_value = self.date_pref_combo.currentData()
date_pref = DatePreference(date_pref_value)
# Extract series title from URL (placeholder)
# In real implementation, this would scrape the title
import re
match = re.search(r'fernsehserien\.de/([^/]+)', url)
if match:
title = match.group(1).replace('-', ' ').title()
else:
title = "Unbekannte Serie"
# Check if series already exists
existing = self.db.get_all_series()
if any(s.url == url for s in existing):
QMessageBox.warning(self, "Fehler", "Diese Serie wurde bereits hinzugefügt")
return
# Add series
series = Series(
id=None,
title=title,
url=url,
date_preference=date_pref
)
try:
series_id = self.db.add_series(series)
self.series_url_input.clear()
self.load_series_list()
# Automatically update the newly added series
reply = QMessageBox.question(
self,
"Serie hinzugefügt",
f"Serie '{title}' wurde hinzugefügt.\n\n"
"Möchten Sie die Episodendaten jetzt abrufen?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self._auto_update_series(series_id)
except Exception as e:
logger.error(f"Error adding series: {e}")
QMessageBox.critical(self, "Fehler", f"Serie konnte nicht hinzugefügt werden:\n{str(e)}")
def edit_series(self):
"""Edit selected series"""
current_item = self.series_list.currentItem()
if not current_item:
QMessageBox.warning(self, "Fehler", "Bitte wählen Sie eine Serie aus")
return
# Extract series title
text = current_item.text()
title = text.split(" (")[0]
series_list = self.db.get_all_series()
series = next((s for s in series_list if s.title == title), None)
if not series:
return
# Show edit dialog (simplified)
QMessageBox.information(
self,
"Info",
f"Bearbeiten von '{series.title}' wird noch implementiert"
)
def remove_series(self):
"""Remove selected series"""
current_item = self.series_list.currentItem()
if not current_item:
QMessageBox.warning(self, "Fehler", "Bitte wählen Sie eine Serie aus")
return
# Extract series title
text = current_item.text()
title = text.split(" (")[0]
series_list = self.db.get_all_series()
series = next((s for s in series_list if s.title == title), None)
if not series:
return
reply = QMessageBox.question(
self,
"Serie entfernen",
f"Möchten Sie '{series.title}' wirklich entfernen?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_series(series.id)
self.load_series_list()
def _auto_update_series(self, series_id: int):
"""Automatically update newly added series"""
from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater
from ..utils.threading import UpdateWorker
from .widgets import ProgressDialog, UpdateResultDialog
# Create progress dialog
progress = ProgressDialog(self, "Serie wird aktualisiert")
progress.set_determinate()
progress.show()
# Create worker
scraper = BrowserScraper()
def do_update():
# Progress callback
def progress_callback(percent, message):
worker.signals.progress.emit(percent, message)
updater = SeriesUpdater(self.db, scraper, progress_callback=progress_callback)
return updater.update_series(series_id)
worker = UpdateWorker(do_update)
def on_progress(percent, message):
progress.set_progress(percent, message)
def on_finished(stats):
progress.close()
UpdateResultDialog(self, stats).exec_()
self.load_series_list()
def on_error(error, traceback):
progress.close()
QMessageBox.critical(self, "Fehler", f"Aktualisierung fehlgeschlagen:\n{error}")
worker.signals.progress.connect(on_progress)
worker.signals.finished.connect(on_finished)
worker.signals.error.connect(on_error)
worker.start()
# Keep reference to prevent garbage collection
self._update_worker = worker
def save_settings(self):
"""Save settings to database"""
self.db.set_setting("portable_mode", "true" if self.portable_mode_check.isChecked() else "false")
self.db.set_setting("auto_update", "true" if self.auto_update_check.isChecked() else "false")
self.db.set_setting("highlight_future", "true" if self.highlight_future_check.isChecked() else "false")
QMessageBox.information(self, "Erfolg", "Einstellungen wurden gespeichert")
def import_from_config(self):
"""Import series from old series_config.json file"""
# Get directory of the executable (or script)
if getattr(sys, 'frozen', False):
# Running as compiled executable
app_dir = os.path.dirname(sys.executable)
else:
# Running as script
app_dir = os.getcwd()
default_path = os.path.join(app_dir, "series_config.json")
# File dialog to select config file
file_path, _ = QFileDialog.getOpenFileName(
self,
"series_config.json auswählen",
default_path,
"JSON Dateien (*.json);;Alle Dateien (*.*)"
)
if not file_path:
return
try:
# Load and parse JSON file
with open(file_path, 'r', encoding='utf-8') as f:
config = json.load(f)
if 'series' not in config:
QMessageBox.warning(
self,
"Fehler",
"Ungültige Datei: 'series' Schlüssel nicht gefunden"
)
return
# Get existing series URLs to check for duplicates
existing_series = self.db.get_all_series()
existing_urls = {s.url for s in existing_series}
# Import statistics
imported_count = 0
skipped_count = 0
error_count = 0
errors = []
series_to_import = []
# Process each series in config
for slug, series_data in config['series'].items():
try:
# Construct URL from slug
url = f"https://www.fernsehserien.de/{slug}/episodenguide"
# Skip if already exists
if url in existing_urls:
skipped_count += 1
continue
# Map date preference from German to enum
date_pref = self._map_date_preference(
series_data.get('date_preference', 'Bevorzuge Erstausstrahlung')
)
# Get series title
title = series_data.get('name', slug.replace('-', ' ').title())
# Create series object
series = Series(
id=None,
title=title,
url=url,
date_preference=date_pref
)
series_to_import.append(series)
except Exception as e:
error_count += 1
errors.append(f"{slug}: {str(e)}")
logger.error(f"Error processing series {slug}: {e}")
# Import series to database
imported_series_ids = []
for series in series_to_import:
try:
series_id = self.db.add_series(series)
imported_series_ids.append(series_id)
imported_count += 1
except Exception as e:
error_count += 1
errors.append(f"{series.title}: {str(e)}")
logger.error(f"Error adding series {series.title}: {e}")
# Reload series list
self.load_series_list()
# Show result dialog
self._show_import_results(
imported_count,
skipped_count,
error_count,
errors,
imported_series_ids
)
except json.JSONDecodeError as e:
QMessageBox.critical(
self,
"Fehler",
f"Fehler beim Parsen der JSON-Datei:\n{str(e)}"
)
except Exception as e:
logger.error(f"Error importing config: {e}")
QMessageBox.critical(
self,
"Fehler",
f"Fehler beim Importieren:\n{str(e)}"
)
def _map_date_preference(self, pref_string: str) -> DatePreference:
"""Map German date preference string to DatePreference enum"""
mapping = {
"Bevorzuge deutsche Synchro": DatePreference.DE_SYNC,
"Bevorzuge Streaming": DatePreference.DE_STREAMING,
"Bevorzuge Erstausstrahlung": DatePreference.DE_FIRST,
}
return mapping.get(pref_string, DatePreference.DE_FIRST)
def _show_import_results(self, imported: int, skipped: int, errors: int,
error_list: list, imported_ids: list):
"""Show import results dialog and ask about updating"""
# Build result message
msg = f"Import abgeschlossen:\n\n"
msg += f"{imported} Serien importiert\n"
msg += f"{skipped} Duplikate übersprungen\n"
if errors > 0:
msg += f"{errors} Fehler\n\n"
msg += "Fehlerdetails:\n"
for error in error_list[:5]: # Show first 5 errors
msg += f"{error}\n"
if len(error_list) > 5:
msg += f" ... und {len(error_list) - 5} weitere\n"
# Show result message
QMessageBox.information(self, "Import abgeschlossen", msg)
# Ask about updating imported series
if imported > 0:
reply = QMessageBox.question(
self,
"Serien aktualisieren?",
f"{imported} Serien wurden importiert.\n\n"
"Möchten Sie die Episodendaten für alle importierten Serien jetzt abrufen?\n\n"
f"Hinweis: Dies kann bei {imported} Serien einige Zeit dauern.",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self._batch_update_series(imported_ids)
def _batch_update_series(self, series_ids: list):
"""Batch update multiple series"""
from ..scraper.browser_scraper import BrowserScraper, SeriesUpdater
from ..utils.threading import UpdateWorker
# Create progress dialog
progress = QProgressDialog(
"Serien werden aktualisiert...",
"Abbrechen",
0,
len(series_ids),
self
)
progress.setWindowTitle("Batch-Update")
progress.setWindowModality(Qt.WindowModal)
progress.show()
# Create worker
scraper = BrowserScraper()
self._batch_update_cancelled = False
def do_batch_update():
results = {
'success': 0,
'failed': 0,
'episodes_added': 0,
'episodes_updated': 0
}
for i, series_id in enumerate(series_ids):
if self._batch_update_cancelled:
break
# Update progress
series = self.db.get_series(series_id)
worker.signals.progress.emit(
i,
f"Aktualisiere {series.title}... ({i + 1}/{len(series_ids)})"
)
try:
updater = SeriesUpdater(self.db, scraper)
stats = updater.update_series(series_id)
results['success'] += 1
results['episodes_added'] += stats.get('episodes_added', 0)
results['episodes_updated'] += stats.get('episodes_updated', 0)
except Exception as e:
logger.error(f"Error updating series {series_id}: {e}")
results['failed'] += 1
return results
worker = UpdateWorker(do_batch_update)
def on_progress(value, message):
progress.setValue(value)
progress.setLabelText(message)
def on_finished(stats):
progress.close()
msg = f"Batch-Update abgeschlossen:\n\n"
msg += f"{stats['success']} Serien erfolgreich aktualisiert\n"
msg += f"{stats['failed']} Fehlgeschlagen\n"
msg += f"+ {stats['episodes_added']} neue Episoden\n"
msg += f"{stats['episodes_updated']} Episoden aktualisiert"
QMessageBox.information(self, "Update abgeschlossen", msg)
self.load_series_list()
def on_error(error, traceback):
progress.close()
QMessageBox.critical(self, "Fehler", f"Batch-Update fehlgeschlagen:\n{error}")
def on_cancelled():
self._batch_update_cancelled = True
progress.canceled.connect(on_cancelled)
worker.signals.progress.connect(on_progress)
worker.signals.finished.connect(on_finished)
worker.signals.error.connect(on_error)
worker.start()
# Keep reference to prevent garbage collection
self._batch_update_worker = worker

View File

@@ -0,0 +1,105 @@
"""
Custom widgets for Serien-Checker UI
"""
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QProgressBar,
QPushButton, QTextEdit
)
from PyQt5.QtCore import Qt
class ProgressDialog(QDialog):
"""Dialog showing progress for long-running operations"""
def __init__(self, parent, title: str = "Bitte warten..."):
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.setFixedSize(400, 150)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
self.init_ui()
def init_ui(self):
"""Initialize UI"""
layout = QVBoxLayout(self)
# Status label
self.status_label = QLabel("Initialisiere...")
layout.addWidget(self.status_label)
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(0)
layout.addWidget(self.progress_bar)
# Cancel button
button_layout = QHBoxLayout()
button_layout.addStretch()
self.cancel_btn = QPushButton("Abbrechen")
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout)
def set_progress(self, value: int, message: str = None):
"""Update progress"""
self.progress_bar.setValue(value)
if message:
self.status_label.setText(message)
def set_indeterminate(self):
"""Set progress bar to indeterminate mode"""
self.progress_bar.setRange(0, 0)
def set_determinate(self):
"""Set progress bar to determinate mode"""
self.progress_bar.setRange(0, 100)
class UpdateResultDialog(QDialog):
"""Dialog showing update results"""
def __init__(self, parent, stats: dict):
super().__init__(parent)
self.setWindowTitle("Update-Ergebnis")
self.setModal(True)
self.resize(450, 300)
self.init_ui(stats)
def init_ui(self, stats: dict):
"""Initialize UI"""
layout = QVBoxLayout(self)
# Title
title = QLabel("Update abgeschlossen")
title.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(title)
# Results text
results = QTextEdit()
results.setReadOnly(True)
text = "Zusammenfassung:\n\n"
text += f"Neue Staffeln: {stats.get('new_seasons', 0)}\n"
text += f"Neue Folgen: {stats.get('new_episodes', 0)}\n"
text += f"Aktualisierte Folgen: {stats.get('updated_episodes', 0)}\n"
text += f"Unverändert: {stats.get('unchanged', 0)}\n"
results.setText(text)
layout.addWidget(results)
# OK button
button_layout = QHBoxLayout()
button_layout.addStretch()
ok_btn = QPushButton("OK")
ok_btn.clicked.connect(self.accept)
ok_btn.setDefault(True)
button_layout.addWidget(ok_btn)
layout.addLayout(button_layout)

View File

@@ -0,0 +1 @@
"""Utility functions and helpers"""

View File

@@ -0,0 +1,74 @@
"""
Logging configuration for Serien-Checker
"""
import logging
import sys
from pathlib import Path
from datetime import datetime
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 setup_logger(name: str = "serien_checker", log_to_file: bool = True, portable: bool = False) -> logging.Logger:
"""
Setup application logger
Args:
name: Logger name
log_to_file: If True, also log to file
portable: If True, use portable mode (logs in program directory)
Returns:
Configured logger instance
"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
# Clear existing handlers
logger.handlers.clear()
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_format = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%H:%M:%S'
)
console_handler.setFormatter(console_format)
logger.addHandler(console_handler)
# File handler
if log_to_file:
# Check if running as EXE or if portable mode is enabled
if getattr(sys, 'frozen', False) or portable:
# Running as EXE or portable mode: logs in program directory (next to EXE)
log_dir = get_executable_dir() / "logs"
else:
# Running as script in development: logs in user's AppData
log_dir = Path.home() / ".serien_checker" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"serien_checker_{datetime.now().strftime('%Y%m%d')}.log"
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_format = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(file_format)
logger.addHandler(file_handler)
return logger

View File

@@ -0,0 +1,90 @@
"""
Threading utilities for PyQt5 asynchronous operations
"""
from PyQt5.QtCore import QThread, pyqtSignal, QObject
from typing import Callable, Any, Optional
import traceback
from .logger import setup_logger
logger = setup_logger()
class WorkerSignals(QObject):
"""Signals for worker threads"""
started = pyqtSignal()
finished = pyqtSignal(object) # Result
error = pyqtSignal(str, str) # Error message, traceback
progress = pyqtSignal(int, str) # Progress percent, status message
class Worker(QThread):
"""
Generic worker thread for running tasks in background
Usage:
worker = Worker(my_function, arg1, arg2, kwarg1=value1)
worker.signals.finished.connect(on_finished)
worker.signals.error.connect(on_error)
worker.start()
"""
def __init__(self, func: Callable, *args, **kwargs):
"""
Initialize worker
Args:
func: Function to execute
*args: Positional arguments for func
**kwargs: Keyword arguments for func
"""
super().__init__()
self.func = func
self.args = args
self.kwargs = kwargs
self.signals = WorkerSignals()
self.result = None
def run(self):
"""Execute the function in background thread"""
try:
logger.debug(f"Worker started: {self.func.__name__}")
self.signals.started.emit()
# Execute function
self.result = self.func(*self.args, **self.kwargs)
# Emit success
self.signals.finished.emit(self.result)
logger.debug(f"Worker finished: {self.func.__name__}")
except Exception as e:
# Emit error
error_msg = str(e)
error_tb = traceback.format_exc()
logger.error(f"Worker error in {self.func.__name__}: {error_msg}\n{error_tb}")
self.signals.error.emit(error_msg, error_tb)
class UpdateWorker(Worker):
"""
Specialized worker for series updates with progress reporting
This worker extends the base Worker to provide progress updates
during long-running scraping operations.
"""
def __init__(self, func: Callable, *args, **kwargs):
super().__init__(func, *args, **kwargs)
def report_progress(self, percent: int, message: str):
"""
Report progress (can be called from within the worker function)
Args:
percent: Progress percentage (0-100)
message: Status message
"""
self.signals.progress.emit(percent, message)
logger.debug(f"Progress: {percent}% - {message}")

View File

@@ -1,2 +0,0 @@
@echo off
start "" /b pythonw.exe serien_checker.py