Kompletter Rewrite
This commit is contained in:
58
.gitignore
vendored
58
.gitignore
vendored
@@ -1,5 +1,55 @@
|
||||
/build
|
||||
/dist
|
||||
# Python
|
||||
__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
|
||||
series_config.json
|
||||
release.md
|
||||
/.claude
|
||||
/tests
|
||||
|
||||
184
README.md
184
README.md
@@ -1,108 +1,132 @@
|
||||
# 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
|
||||
|
||||
- Verfolgen Sie mehrere Serien gleichzeitig
|
||||
- Anzeige deutscher Ausstrahlungstermine (TV und Streaming)
|
||||
- Staffel-spezifische Filterung
|
||||
- Datumspräferenz (TV, Streaming oder früheste Ausstrahlung)
|
||||
- Übersichtliche Episodenliste mit Datum, Staffel, Folge und Titel
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Hauptfenster
|
||||

|
||||

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

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

|
||||
- **Episoden-Tracking**: Verfolgen Sie Staffeln und Episoden Ihrer Lieblingsserien
|
||||
- **Ausstrahlungsdaten**: Anzeige verschiedener Datums-Typen (DE TV, DE Streaming, Sync, Original)
|
||||
- **Zukünftige Episoden**: Automatische Markierung kommender Folgen
|
||||
- **Delta-Updates**: Intelligente Aktualisierung nur neuer/geänderter Daten
|
||||
- **Portable Modus**: Wahlweise portable Installation ohne Registry-Einträge
|
||||
- **Offline-Fähig**: Lokale SQLite-Datenbank
|
||||
- **Automatisches Scraping**: Vollständig funktionsfähiger Web-Scraper für fernsehserien.de
|
||||
- **Threading**: Asynchrone Updates ohne UI-Blockierung
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Ausführbare Datei (Windows)
|
||||
### Methode 1: uv (empfohlen)
|
||||
|
||||
1. Laden Sie die neueste Version von der [Releases](https://git.ponywave.de/Akamaru/Serien-Checker/releases) Seite herunter
|
||||
2. Entpacken Sie die ZIP-Datei
|
||||
3. Starten Sie `Serien-Checker.exe`
|
||||
```bash
|
||||
# Programm starten (installiert Dependencies automatisch)
|
||||
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
|
||||
2. Klonen Sie das Repository:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
pip install -r requirements.txt
|
||||
|
||||
### 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
|
||||
2. Manuell:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
python build.py
|
||||
```
|
||||
```bash
|
||||
# PyInstaller installieren
|
||||
pip install pyinstaller
|
||||
|
||||
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
|
||||
|
||||
### Serien hinzufügen
|
||||
### Serie hinzufügen
|
||||
|
||||
1. Klicken Sie auf "Serien verwalten"
|
||||
2. Klicken Sie auf "Neue Serie"
|
||||
3. Geben Sie die URL oder den Slug von fernsehserien.de ein
|
||||
- Beispiel URL: `https://www.fernsehserien.de/9-1-1-notruf-l-a`
|
||||
- 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)
|
||||
1. Öffnen Sie **Einstellungen → Optionen**
|
||||
2. Im Tab "Serien" die fernsehserien.de URL eingeben
|
||||
3. Bevorzugten Datumstyp wählen
|
||||
4. "Serie hinzufügen" klicken
|
||||
|
||||
### Serien verwalten
|
||||
### Serie aktualisieren
|
||||
|
||||
- Wählen Sie eine Serie aus der Liste
|
||||
- Ändern Sie die Einstellungen nach Bedarf
|
||||
- Klicken Sie auf "Einstellungen speichern"
|
||||
- Löschen Sie unerwünschte Serien mit dem "Löschen" Button
|
||||
- Rechtsklick auf Serie → "Aktualisieren"
|
||||
- Oder: Menü → Serien → Aktualisieren
|
||||
|
||||
### Episoden anzeigen
|
||||
### Staffeln/Episoden anzeigen
|
||||
|
||||
- Wählen Sie eine Serie aus der Liste im Hauptfenster
|
||||
- Die Episoden werden automatisch geladen
|
||||
- Die Liste wird alle 30 Minuten automatisch aktualisiert
|
||||
- Klicken Sie auf "Aktualisieren" für sofortige Aktualisierung
|
||||
- Serie in linker Spalte auswählen
|
||||
- Staffel in rechter Spalte auswählen
|
||||
- Episoden erscheinen in der Mitte
|
||||
- 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
|
||||
- Staffel-Einstellungen pro Serie
|
||||
- Datumspräferenzen pro Serie
|
||||
```
|
||||
serien_checker/
|
||||
├── main.py # Entry Point
|
||||
├── 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
|
||||
- Prüfen Sie Ihre Internetverbindung
|
||||
- Prüfen Sie, ob die Serie auf fernsehserien.de verfügbar ist
|
||||
- Prüfen Sie die Staffel-Einstellungen
|
||||
- **Python**: 3.11+
|
||||
- **GUI**: PyQt5
|
||||
- **Datenbank**: SQLite (nativ)
|
||||
- **Scraping**: browser-tools Skill (geplant)
|
||||
- **Packaging**: PyInstaller
|
||||
|
||||
### Keine deutschen Titel
|
||||
- Einige Episoden haben noch keine deutschen Titel
|
||||
- Diese werden als "Noch kein Titel" angezeigt
|
||||
- Die Titel werden automatisch aktualisiert, sobald sie verfügbar sind
|
||||
## Datenspeicherung
|
||||
|
||||
### Standard-Modus
|
||||
- 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 -*-
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
|
||||
|
||||
block_cipher = None
|
||||
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']
|
||||
hiddenimports += collect_submodules('PyQt5')
|
||||
hiddenimports += collect_submodules('requests')
|
||||
hiddenimports += collect_submodules('bs4')
|
||||
hiddenimports += collect_submodules('lxml')
|
||||
hiddenimports += collect_submodules('serien_checker')
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['serien_checker.py'],
|
||||
['D:\\GitHub\\Serien-Checker\\main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('series_config.json', '.')],
|
||||
hiddenimports=[],
|
||||
datas=[('D:\\GitHub\\Serien-Checker\\icon.ico', '.')],
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='Serien-Checker',
|
||||
@@ -41,5 +43,5 @@ exe = EXE(
|
||||
target_arch=None,
|
||||
codesign_identity=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 Installing required packages...
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
echo Building Serien-Checker.exe...
|
||||
echo.
|
||||
|
||||
echo Building executable...
|
||||
python build.py
|
||||
uv run build.py
|
||||
|
||||
echo Done!
|
||||
if exist "dist\Serien-Checker.exe" (
|
||||
echo Executable created successfully at dist\Serien-Checker.exe
|
||||
) else (
|
||||
echo Error: Build failed!
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo Build failed with error code %errorlevel%
|
||||
pause
|
||||
exit /b %errorlevel%
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Build completed successfully!
|
||||
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 os
|
||||
import shutil
|
||||
|
||||
# Lösche alte build und dist Ordner
|
||||
if os.path.exists('build'):
|
||||
shutil.rmtree('build')
|
||||
if os.path.exists('dist'):
|
||||
shutil.rmtree('dist')
|
||||
def build_exe():
|
||||
"""Build the Serien-Checker executable."""
|
||||
|
||||
# PyInstaller Konfiguration
|
||||
PyInstaller.__main__.run([
|
||||
'serien_checker.py',
|
||||
'--onefile',
|
||||
'--windowed',
|
||||
'--name=Serien-Checker',
|
||||
'--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
|
||||
'--clean'
|
||||
])
|
||||
# Get project root directory
|
||||
project_root = Path(__file__).parent
|
||||
|
||||
# Define paths
|
||||
main_script = project_root / "main.py"
|
||||
icon_file = project_root / "icon.ico"
|
||||
|
||||
# Verify required files exist
|
||||
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
|
||||
requests==2.31.0
|
||||
beautifulsoup4==4.12.2
|
||||
PyQt5>=5.15.0
|
||||
requests>=2.31.0
|
||||
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