Kompletter Rewrite
This commit is contained in:
58
.gitignore
vendored
58
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
184
README.md
184
README.md
@@ -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
|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
### Serien verwalten
|
|
||||||

|
|
||||||
|
|
||||||
### Neue Serie hinzufügen
|
|
||||||

|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
2. Entpacken Sie die ZIP-Datei
|
# Programm starten (installiert Dependencies automatisch)
|
||||||
3. Starten Sie `Serien-Checker.exe`
|
uv run main.py
|
||||||
|
```
|
||||||
|
|
||||||
### Option 2: Aus dem Quellcode
|
### Methode 2: Traditionell mit pip
|
||||||
|
|
||||||
1. Stellen Sie sicher, dass Python 3.8 oder höher installiert ist
|
```bash
|
||||||
2. Klonen Sie das Repository:
|
# Dependencies installieren
|
||||||
```bash
|
pip install -r requirements.txt
|
||||||
git clone https://git.ponywave.de/Akamaru/Serien-Checker.git
|
|
||||||
cd Serien-Checker
|
|
||||||
```
|
|
||||||
3. Installieren Sie die Abhängigkeiten:
|
|
||||||
```bash
|
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
|
||||||
4. Starten Sie das Programm:
|
|
||||||
```bash
|
|
||||||
python serien_checker.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Executable erstellen
|
# Programm starten
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
Um Ihre eigene ausführbare Datei zu erstellen:
|
### Methode 3: Windows EXE erstellen
|
||||||
|
|
||||||
1. Führen Sie `build.bat` aus, oder
|
```bash
|
||||||
2. Manuell:
|
# PyInstaller installieren
|
||||||
```bash
|
pip install pyinstaller
|
||||||
pip install -r requirements.txt
|
|
||||||
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
|
||||||
|
|||||||
@@ -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'],
|
||||||
)
|
)
|
||||||
|
|||||||
21
build.bat
21
build.bat
@@ -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
109
build.py
@@ -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
55
main.py
Normal 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()
|
||||||
@@ -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 |
1069
serien_checker.py
1069
serien_checker.py
File diff suppressed because it is too large
Load Diff
6
serien_checker/__init__.py
Normal file
6
serien_checker/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Serien-Checker - TV Series Episode Tracker
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "2.0.0"
|
||||||
|
__author__ = "Serien-Checker Project"
|
||||||
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]
|
||||||
|
)
|
||||||
1
serien_checker/scraper/__init__.py
Normal file
1
serien_checker/scraper/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Web scraping module for fernsehserien.de"""
|
||||||
307
serien_checker/scraper/browser_scraper.py
Normal file
307
serien_checker/scraper/browser_scraper.py
Normal 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
|
||||||
1095
serien_checker/scraper/fernsehserien_scraper.py
Normal file
1095
serien_checker/scraper/fernsehserien_scraper.py
Normal file
File diff suppressed because it is too large
Load Diff
1
serien_checker/ui/__init__.py
Normal file
1
serien_checker/ui/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""PyQt5 user interface components"""
|
||||||
622
serien_checker/ui/main_window.py
Normal file
622
serien_checker/ui/main_window.py
Normal 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()
|
||||||
599
serien_checker/ui/options_dialog.py
Normal file
599
serien_checker/ui/options_dialog.py
Normal 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
|
||||||
105
serien_checker/ui/widgets.py
Normal file
105
serien_checker/ui/widgets.py
Normal 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)
|
||||||
1
serien_checker/utils/__init__.py
Normal file
1
serien_checker/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Utility functions and helpers"""
|
||||||
74
serien_checker/utils/logger.py
Normal file
74
serien_checker/utils/logger.py
Normal 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
|
||||||
90
serien_checker/utils/threading.py
Normal file
90
serien_checker/utils/threading.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user