From 5fa6bfeb6299150a46dcdd3f803c8d1cf6d1dcf6 Mon Sep 17 00:00:00 2001 From: Akamaru Date: Sun, 21 Dec 2025 14:35:08 +0100 Subject: [PATCH] Kompletter Rewrite --- .gitignore | 58 +- README.md | 184 +-- Serien-Checker.spec | 24 +- build.bat | 23 +- build.py | 109 +- main.py | 55 + requirements.txt | 8 +- screenshots/1.png | Bin 63081 -> 0 bytes screenshots/2.png | Bin 67796 -> 0 bytes screenshots/3.png | Bin 18493 -> 0 bytes screenshots/4.png | Bin 37916 -> 0 bytes serien_checker.py | 1069 ---------------- serien_checker/__init__.py | 6 + serien_checker/database/__init__.py | 1 + serien_checker/database/db_manager.py | 477 +++++++ serien_checker/database/models.py | 232 ++++ serien_checker/scraper/__init__.py | 1 + serien_checker/scraper/browser_scraper.py | 307 +++++ .../scraper/fernsehserien_scraper.py | 1095 +++++++++++++++++ serien_checker/ui/__init__.py | 1 + serien_checker/ui/main_window.py | 622 ++++++++++ serien_checker/ui/options_dialog.py | 599 +++++++++ serien_checker/ui/widgets.py | 105 ++ serien_checker/utils/__init__.py | 1 + serien_checker/utils/logger.py | 74 ++ serien_checker/utils/threading.py | 90 ++ start.bat | 2 - 27 files changed, 3946 insertions(+), 1197 deletions(-) create mode 100644 main.py delete mode 100644 screenshots/1.png delete mode 100644 screenshots/2.png delete mode 100644 screenshots/3.png delete mode 100644 screenshots/4.png delete mode 100644 serien_checker.py create mode 100644 serien_checker/__init__.py create mode 100644 serien_checker/database/__init__.py create mode 100644 serien_checker/database/db_manager.py create mode 100644 serien_checker/database/models.py create mode 100644 serien_checker/scraper/__init__.py create mode 100644 serien_checker/scraper/browser_scraper.py create mode 100644 serien_checker/scraper/fernsehserien_scraper.py create mode 100644 serien_checker/ui/__init__.py create mode 100644 serien_checker/ui/main_window.py create mode 100644 serien_checker/ui/options_dialog.py create mode 100644 serien_checker/ui/widgets.py create mode 100644 serien_checker/utils/__init__.py create mode 100644 serien_checker/utils/logger.py create mode 100644 serien_checker/utils/threading.py delete mode 100644 start.bat diff --git a/.gitignore b/.gitignore index 3a533f8..b3b0ece 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index ccf1fb7..6e837bf 100644 --- a/README.md +++ b/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 -![Hauptfenster1](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/1.png) -![Hauptfenster2](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/2.png) - -### Serien verwalten -![Neue Serie](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/3.png) - -### Neue Serie hinzufügen -![Debug Log](https://git.ponywave.de/Akamaru/Serien-Checker/raw/branch/master/screenshots/4.png) +- **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 \ No newline at end of file +## 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 diff --git a/Serien-Checker.spec b/Serien-Checker.spec index 3a94dd7..5b0458c 100644 --- a/Serien-Checker.spec +++ b/Serien-Checker.spec @@ -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'], ) diff --git a/build.bat b/build.bat index 79ee5e9..f620ca1 100644 --- a/build.bat +++ b/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% ) -pause \ No newline at end of file + +echo. +echo Build completed successfully! +pause diff --git a/build.py b/build.py index 864908c..64d7cfa 100644 --- a/build.py +++ b/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' -]) \ No newline at end of file + # 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() diff --git a/main.py b/main.py new file mode 100644 index 0000000..9cb3cd8 --- /dev/null +++ b/main.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 72acf73..4a377a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ -PyQt5==5.15.9 -requests==2.31.0 -beautifulsoup4==4.12.2 \ No newline at end of file +PyQt5>=5.15.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +lxml>=4.9.0 +pyinstaller==6.11.1 diff --git a/screenshots/1.png b/screenshots/1.png deleted file mode 100644 index 224faffbb915c2cf30687e93081e35a7b50a13e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 63081 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 Iw7mWQ0HC1##{d8T diff --git a/screenshots/2.png b/screenshots/2.png deleted file mode 100644 index 2a27797fd9ebe54808a8a9ef3ef4ba42e872fd5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67796 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

{*l65~08dz+z`)lz!6% zgavm_ercf1(CPk|Z#AmzMHdgGwA^+2#<&uEF;(j>N0D2{WkbP#4%C5=74GU}YHf?i zurb0u2|7o(G&o8dTsA?3UoTwk(RiqK5To6ADMn@Q)f-}F?5d$iZoKAW`_C*@s<}p{ zQY-x?B($>Bl7Yjm(z0~IgCq5FCl?<$8`vGr6(eLLPyJy&0K57sfgZS}Zm5@%L-kyE zO&o2OL;=sVGcbCXsKjBSLUul@z9e(%zE*muL=O4>q?Y7fvzn3(An~4uuKj2TQ2pts z%Xv>R2H=O&xjZAWQ{6`6$123QX}yOrn>gUFma!E63?}PxA)MkqyK&z9_7*j|#P*Kc z)z;uP7fsZLPSb(?G(t_KveFM0Uuq}8b>(k~M+Lpn`&q}$NX$rCw|pJvP)6`-Q`s9QerJTy;;G@0Pj zNuREY^=GDD*|r@ufCveei6esX(hI0dM57qziLg_J) zrcE&7V!v6*x~Ra%pmTX*pzp8w&5U1Kh6P{Ph{-#BjaA?CbKiUXtIP}XSM+ZA5~y!} zdCwlcVX#2T)pPi;`p|TU{IxhCP==TMNRi5d#UMBFep2T5@YfO0HkZ~19AE4(tGI=% zkea0uF09Coi5A5Y;xZ;AjpUsL^IY9Mw&DGvRH(^)i4($)D~djNZ>1`6;UmUmG)$3n-QO--}P8*YRU4 zd;0zLbv@UeO96u>s5)i8Q08(O3fHg^`{U+VzxWowkHz17;Mj|~^$_`i<*{)k?&;0t zt(Xvu8z9n1S%!G+<=PbcH`f>}dl#rb1mj+&BG>AI?Vhd#Z2RRB|1&0Ob4bw)1CGJJ zb01rnoT%DZdC%geTq{bxNB}>K5=jav$qHLmY3@32^^d2@S@o`GbB`5GVcm{UYc-Cp7z ziNWNv+>Ca|Z)>*WzqKA_7128v0RhY2VX#QC_oi~!ZZu3=;JxQOQ$(FW0Rb929)DA& zxxV1)C;H`YCJ`kVWmqCyF_YWLHV&HYUpLN3U$qkMP&Ym7dhvmFmu{3LB4v_`9EX4B zdmm25p(&j2(&12RWgQIlG3-mOvdV3^mg%9A93zeG+c?0r&fQ_ttG$2Ey;uP=hDOw< z{Gh2lO2Nn0)wZ+vlax{z#VKzco2(Es%!yBrYu4nT*KRB^ak%)Frb-nl#SxG&frjW~ zU1BDyR9Y-feSmk@pVDfRml#|(Is|I;0!4(Jj9;y~u&eMpdXK&?)6Ln{Zc5hwFvdjocm+zK z_w*6{rW8e)Cl#Zq3JfhA_fm{&o!@81s_up_#5+f-HJD#d(fcx^n|w%CB#spo#<1(J z0^V!4BX*7ADMS@Mzx~UL=y})?5^1F@^q!sWcg-`oFa$ncYO8UqduytCq3eR^slt4j zXuv;}3fv%&iL6`xr7jCN#g6d2t1F7cFle#7nAc^xHT%_3aB$W+$K<_Feo+;Qy4Z!8 z!XbMjo6!&8)cexyN=+Hx$Jy6C92&_})N5B{*0H1{KZ@bhZE2j4%v6)M%9~RU@CmjC zvy~2}HZ{*DwnKL#`2J(~t>u@xBl3D-heSr=>&-Qg7kHnSEAl=ze7XNMv4dzLzK}Mf z`od!5gc-hZre)$MgL$Ac{iU)7C}U%4c-hX*nu_;!ceVQv>~sWvf-=)Cs|!OtzrYc_ zj96}pELz74fQ=D0CB-Is1!r6#9k?VMF$S&Rp_#)#CV8c}e9axvJcN+}O_xaHPU_X~ zF4qTuT372dO@YSI7LPa z`!UbHSN7BCsNLf?*n8%UiWDw`d8Z6l2Be1`x@Z}58F3T5vDo@!D0h6llt^z zf4FKmYvE*V?UAAw(7FX7&uLAhU+rZ~alzi2sAD#WPSw&QJEF!_5dDGuj(u3jx z*2q6H$+l9zu;iu2W~#S-CS^@&Z#qA;L3*Qhe3SZ41Hv6W(ap(?!6y^Qg^1m z`Y|Y9TMGTq?U~k&$-o=IYF;9-UG8X=SeHBJcX?He4vqwrH5Q@UhLEDVq8SnA||VcR5i0TIr8YHP>65XYcLc{1ryJbSXZ1i;bRnfyMoNOR_KCx<-oP{1%rL(|S_$d*WBul$S}m%eLdi zVB#aaci4VL++aHoFxx-)i{sE?&sSwdGp;e8s8v)VHmyV>>hKHIzN>+o)yaQVe`%+i z5AtJO_hzI&uh~`Jk!E|RfVE7|{noQpM4M2%YYhC~=N#=+^Mvhgl`l%1!h-_| z@0F~+YP}XROtik!V2T2WMT#-FN%qDvkE-#PXdf5~Ai(gk+e}6-bSWs!r&6idq23gL zv?Zz_Kr$p|yf{1P{OqI(dog8td38-)TH+{*JLe>w*B!e~-ACyoG!8t`^jtWJx5xvF zV<$#;4fz}E2B(?^ANAUASp%B9g?FZzMtpoDewp=Sbwg$A+WfWk`M|ZB4wtFLQdVNB zzzX*k&fi}BzJ8NjuMY-!WvTG(_GRQSWYmag0T>eC_0z~`7vAvlI#{P|Q`hi7JKOhJ zak!P|V1R^1W*r*emPuABUf4R)5T?{_$;*DDJ$Ar#b%)=UhnpoE)iN3EQwK3In`K!& zyY$Ci$jD0z?uhixw4m73upUUr98(6JusuB6RxUSNG=lx%vPz2cfq1OJ*1ma-g8DtS};|fU3SR92%Oi#-&Dg6#t=ee^uIfZGR$iWXU)a zHZ};)Fd#ec&baah=e**lGws@B7Rd%)jjPj@J}xbV4VC#Wu_Z2$nPB$PlMjZqXNe)G z95xgA!xC=w<#vEk5qd{Joj5~>3=w6qt)QpPNp+>@DR0$%t4Zot+pxX%*o&l7UIL(f zrwhC+KqwXJj1y)`JM{bIw=Sa^8(-TF20=)GbNs)>C^Rw;v7Z{KXzlRq-RUEL+%T~q zRMwDr?+`ri@Y~nq*Mj?7%Q(HD- z-S*#TsQaTIVKKqMV!9Ix-N*JF%i3l`b*=9P2_C&Jw^w#Ee0?W$nvf=8hA25v{8GT> z1L8yzP-XVGH$TMDH^_%_GgORqyqq|oC(jgXH@(*F!ZdA7$#<$XzHuBl+!k%+r$}e& zMMfQq`aX6-JoH&*-Ko$Zy(M11|3=&OLc7Q<5J9!8I@8--J2X__7rtIfG7SsP86PGE zGW+aVm6cNXF3k@uN?#+&sz4Ra*C+km5yET@F@gnYM2jixRpPQR)8)B%`KcyBV>#c34hr8%-aja-c(X6jI zicbt&Ow>QNV>sp^&k)~l5|@9RA!hNqwzOobew+NQHrN|_R>q8HZ_I1v|lev&l{a0T4zb1_7UDrx>XivFvE0#ld{Wicq1;_tf3;8Eb z^Y60rH-c$38Tds1jo{n1g;U002;< ze`Ci*02oSz8BwXrOTn)C7d+p{+0ZMn<3^dSexu^LLhnts6I^zVT-SwhSmP0XDGC}= zOY3d_uP==8T^FjUY3~DDm(gcvwZoRve$up5_-+txP zKbHq$6EwCQ87KwCS?IO4nM3u2m|!rD%2bcT6WMX@0hq!(1eeS5ZcdHMv?-FscvsG{ z^P!>@_4E)6bbNP_{PoL}S!i{;)xNmm^rPVYFlCd;@eeBaH*InYh2LG)s;3I!RfP@% z!4n%kJn^PBUq;BY8;VkkTdNUzlc4MFEXvM8X>ctcuV2V{Zt)jXmJxfl>Gm@nJt$pr zP>)e>ka6FUxBGOb%`2+Wazi0Do>Y=e& zml(@hHS|6xL>RNBiGOK%LtQ(e=?((o7J2Zh$fKElrBEySwT zC-PQRK14&hUId0WdQ)hkB|AdNw<(3SutMP$0aH_m(@SB%G(}rE? zhIH8}Wj9UEk@H?IVw4&1 zQwsB<@^?}ubr314+HRR6=!IYh`-_9xMtLextK6jWyu?v;h{3g`E-efFu_*Yhhsmc1 zZG(egs?jCu{M`dJc05lj^ZklN3gprcU40^nPzK33J$R+*r3*#gxh8Fg?+sLQlA^6W z{D5vzY`U4L^pw{|DgXwV&zCYk4X{twG#kgYAbk z&ubXnVASbpp!i*z{1Ber_6B(_^fujx;)5P#8_7#u2ZA!jOA1m&2Hd`D6={gQ{Sx~v zw^Jg{Sb9NwDIudf=}e3Yf*I*@>gt6IJ(BQxRgNgQNhw{wQK$*w0r$(JtOt5sPBI6r zTa{2EX;t7R&tB1lX9e6^xeISq3b`qkfK5C%+DF)W^V*^@_kZH4p5N}{4|{1?d7uAq zc?cQeo-v+@rk+KsU{;|P$oW$xD(=(a!JWC6Qv~0|&WxQZ?iz#&v>Q~7UtRNO3GE~~ z!opjw2M0O}e124$MiTGGP9AHK$Y2h!UohL+&p$uypAR)!-bz=(S$ZlA8wrnL1Gx3o~OM< zJy%8kz9?Y{vgEolNnz+eZEH?6PbB7hMQXu zS`KTy40{r>HOIyKCL@t$Y8CvNPE; zd(Sh!_Lwg;tXo0NK=SKKxw`lW}PAx$Img;D^$e%kO1@2T(0KnQt6@ zC$9?TUjO3Df?1?t>+)7X;%B^Aotgu(*QvAyW>uw?j06)o28PRYV*dAEVNcE;9p5v* z+yoJ;r~|Na4bsddZ_yH_$&-(KY=?nYE8F%a`k$r~H%7wI_2Z_|-fKc`EA_BI!x8ly z1l?bAfxe#sm?+iGzu|jF|3Akj!g|oLsx5ZUb;T;>j-%<=gVPF@MxZ`(m}CrFx4Rzw zo3;__-x%b@dMD3@n$pqQ^<`Qw&S`xbwy5VwX!a(yOsG$$FEr8(I_K%*`^~Zoz`_5Y zT3-Gc8BmnqmYgvNIcEeq&bP?scD2f#x6{-V5eiWMgoBors7_alNAfYQTauC3Pqc-F zt+F2W>zR4NZ75Cv49u6D4%Z652w$I8QM^G`jFqRYc#f!}jW|qf-j~r{GQyxfPYc=| zIo?O870(#n*_d$g*E*0UaZR|P+h1Qv1hnEm(7&E83#?*p z`YHzWYSmt1GJY{ckBR`Qzx+*qZ^Svj!|h|0hV{5LRmJ-B79<$=QVTJIsW-_-e0LLQ ze-{C0)>z*BC0Q$%Tul{5+q@7-1HtAQT@ogvn~Mn5dvi7T{oJeHJT`pqKJzBbN$YP5hf|MS37@W2JSwfD z_W;29IB~VE+PO5qCV}vC9 z$T~Y1Ja}vQjc{uqGSqR({{Vx$4E^N|kg3mFjVkv!0Qg=8C8I2uKbHwIi9PtJQsi3* z?aPs8*RJvWgBj|f2biAy;4oX99oohWKP`$xCtL!FTuVia+)!i@*Uq*s_iIj&vxm-T*6Yx%2d?wEOfjAKb zE00Ronv)9^&Qx@98cx3w7m_n+0$G_HoL>)cB`}2+WxFr$Wq3xV%P*8ZXvZn&OW ztLC&M$+sp8d~Z#1o*sIa?pA0+*w8a+lAn16U(5P8d{;9-fUBB42Gt-YDjnb541tSHI8N8@s;*(C2yZZ}=ho#@FsqwFsMD zXzANtG=HjILVtGSN3PT=-=47d^HxiJn_3iXa3B6ILd`s5q=KmN|OUvypfY@6k>5JPU1= z$bP-0(QY;S@)KYCXjjX)VW?aB5nflZCSwJxb7|v877Km%^G7E$Aj?kGq!R?6Gph5l+%@jGyHMD)RvPeqg9-1`0}SYd&wO!iwLw%z+8;ep5hH zTg@4yNzlwreDi&pLZ_cI<=G1z{=q7Lgj|Mvsg(z<<{ly(MGy}`=H%0*N+5X)mfUwl z+-ogBqR$N#ktfR?mxOCsFQL&%t|u(cp3t3TRzTRJ?dkHiayeQ+vv+l@tn|o+)GRRc zx`ym=PH>g8t4q$>!+GPzJ;c4L)LNx0K`*%y)2Nye%#lww0VR=iNxg8R=YWNyUpQdm&_l)O%$aT{Ro%bA?3cIT{ zf#LHZ$!~hxuwFq#9GKq^Xb~?jTsaxs1=U?(=t~s^p6r8p7H17w5cr8GDm>q2Bp`Ew zJ~ea>Y~D_ldgw2Nlb_9GvVZ@xuIJi*mo%Tab?p0#CjT3@teXVF)jlUwbnIoA-j%jx z+1Qr?Y}+7?pGAdDSAQVloL3_2clA@18v?jxBsPnh)MQayb412l=|PAUGdv);$ln{#k}CoA=wxm2U8-++N{bERhgDO5wV z<5wp4^}pwYzp)kW9H6@_=@zhKBngqT^@*!=O0$QRGD`^W@On z!k|qq2&hvDJ4&)z&fZa`p+FjI>aljsq#)ND+IcwZNYrf!O`mGq-4k^3?bl`WmjlM{ zVnW{pFiP{C{5_b91cd4>l>t);mDo`hNW|G13r<`&KavmqI8K9fi6gmc(i{v@(V}7ps zKOr9(O1A&Uv#fXvJ`7s>ea-7wMcp)E(p6a1Je^_{gT>vU3&7Nq1T5`NoTU@2d)G4& zge`BOZRBUs?J^Ovr76-~Jx;jyO07LAHvS%5NRg9cf!}CO4S^Cd319$OTlnhkHzqe*b}JQjot8VI-_T`=^#AA)i;-_%_kk^^zH0uQ z^f$r!ndf?iislFBrAT>gJ{AWu zdV08wSSK2>uV3aTW#v<=&HOc8R=BZ~s;7Eaj}srDSRDY>Jay*FAHfc4P8MGbm&8(a zk_0>_4(1|EllqR~;m%)*A2bKcNBbtTgb2M0sOs*E?AaiyrA~K*L!=`>cdo5tv68Tn z>f(r_LHS<~Lz*pywbH<2@oT6fVQ6EhDQUPW33`uIx}eTIcVez6^2)7ml#M{&5~}vx z^alZ4>`)~ezKK{zF%04@>WCTP1GjkbK@~&yv(rz=e;+6!guBNBp86K6=(6Dj1UgsL z&vDtMdm9F044=)>SygD#3-*I4q6Y~doTKmaR2QGwnu_AYkGpW0obNt4+)p;7l$^fI zy?WF1qin+XmBBc%vFdN{k)*7krIYymtG;WV@>@9~{I>c@ zoMvxC?o;4tOx~{C0FrQmnKut=MoGY=hBpQOfeQp^evOC@7!2o#r z7J?;60y<{~`LpzL+KLH+`Vro6{r36p>)#tfZNeE}j;RhZu3me%KEXLo*NDxz?Egoh z#@vU%b;Mi}&~!*MbOtnIR>pBX*j(JV!p>!mxLL{#^De$Y!&f2;YPna*XnsB2LM2p{ z@*6`5xW;K%>RNGk9|7j=y0$@nTigi&l%Gky?c;-DN9DMS)ZZ!eje4KzF69JL7oM$d z-@upJMFlXnG;=et>Amlb9n_e-%4ufijYno0D%3qKuoV$-3x5^$eHIEL+r9>2kv znoEy1mFeQOF}>zM)N@_s5^$Hz5_36*vTjYV2`6MAAW+`I@Y8puqc9Ye_3v(%bE?@M z;LofI->uM-OW7o!0k)BTIr{Acz{3~cz8R+RrEuBt^c}9OY9-%i;jwdB9OI&fk3AGK zDUhRkK%o(o#;P0b65aMN?#xNWX}tjNAO%_IZFv4jJB2;X6zO8s@>S6wvQ*Q+{=WED zk5oL3HitAlvvPH-T^&}ix@H|DB|ZMSwzzY21&4AasEy~hVDQt=<`G_dT;6Gw__o3q zt7mwKXsFX>G;S?$ujwxTDytgC@aYuA+zEemiF3rCV;(;U8Q|lrX?p6E14|e(5@8yF zl}=Y!@Fp_W=34PG5Ra&ub>Yl376S>C@oIPaJ4K#@{#$>#L-#!#Ze}xlR`*g{Zlllo zT5h(ga*cNsqZcrhdwwy+)trU@2s}5Z_YAPnO8Q>8W*!`BSdcRZ=hx7f zQR)^D)i}4X1zqxp^8$4HP8a}~D2>Sh@)I4)>-Hm+QxM{VyBQd&cm8b6obxUtj^MK~ zWZ(!E@^oPwkc9cXG34)`*$=G5`C~ut=3o1I=zXM$VnU67`HSArmFn8l_nl8b6@i(2 zCbL6Y1rgR{(ke-Q!e?=ZKL8^37`J4Vf;b(8aYNy%s5hXT`zjeXz9WjpVPOx#ier0q z!mzkg!RtZruQ(~k$7&Oz^+~J1jQZ>xbcBv)M0yuf-vjKydxGgPvcwtSsJOiUGEoHS z+d0BC{aP{?l@c3nGQxLLzdT$4%F*g>Fj6i&2atCE!j4_i>?R8I!6H(>yW_+PS$9<>g7xMjsI4XVZ#?@q+-z&>=H4PdU9t4L zA>ZBx_gdVTUO9fPs6i#5PoTd~4wNa2y+tLIL5BndO3?1Hma>G0TvR}qH?pJJ^On4k zVNktCb0YAf=>0l4=eh)DVXFq?oj$@=EA5!rgmW$pWg zm7XOG88~3qZp(eswfUzS+tU-f>_B28W$jZs_Cf7qB)^BYwV9ivN93JnYs|K5tM*lm zG4sJVJ(%qE6NzS=MD%6bA8r+w2I>c;Rq=-%f5_@1`upZW8XsOkRTf)xV)QN&%&f&e z_K#;ZzO8w(mjz#)>0aU@036X=>GgOuD_)4zs2_Z&oV=L;_%3y}xRPc`h&3ZY%2hF! zt@ZC+;$)eHoZiG`qgI?F#IY#k1wgH_dKo|#A_rXL!bbH|???%CH~2lRJEdXzNFi&p z-ML0Tq&;dMRbVpxfNfDaL@f5?9{WhnejY}`u&&a8JS$*Hp!4Z}YV5^ATfEZ0Zj0Dv zrU9!+qs5b^LrO+rdC4R{&L{<#-5-VcmcQ`laaBIi{q^v>3`EWMb;nh2B z53N9(;eCeHBc%XH<#W2jFb4hYSbOBA*GfWJM8jJtr5_ur9R6gd++>F&U<>`UEjgVF z@V-3tC5fw)QoYcuI-<*oA4G7}kqnQy+`k4FL`XhZ1Gm^5kE|DQKIYjH5@UZ5f|aY3 zN?I%xd8JD22sfDWj85E}1_GzN1tXkIX_?kyF1{Z5hargdll<_Af^I7HHx6gVi4jVCDz~#F45nhj-(r@P4o^$#Bt? zqUK?~$cl*h_%Ck&qtoANwNd{nwvv_TT8mE{oP?Bj+MH?H#%HFV>fX_3-hIm8+xxj> z!R{#e17U_Um2b5z6azCsi2{<{_JqApVemy=v2rT1ea$ehyNg#38A)ClO?qQhfaN1& z9OK%rESU80#^h#lo4(sK;O#?-hCX3wtf3$-NL)=7BW zK0(tlb)f~gZoNxy$tAi^rjPR--Sx8oJSx)N^P*hLQpGE6G4<1pNdeaHRO8wUYWmm> zOS-0C#^r?T0xD43ndj;Qe0w8<>>TJJT!@+^mtNO#`3xJ~E=|HzHHbs>J&0(kMYVx9N zdF_-N)Oq(+|B??DbR6&f+zxRhR~w-+Q#lOQ!hgLOI`)5(OcD6IEhE~t4AHyOMz3qD0@Yde-J zd2D3A44hALu{Jfg${w4Ec`3_<$m5VoULUuer624jB!f=DcmwLw6u#U26?sMVN`@c>?uJD>?F zHO;@!&w2@K0RIC;krIqtl2VE|!YRsCvysI+?6I3n=NEt=H_<6<{9USisY(wt_1T=(C0_*Ety=p8 zmV*rIGGzE3w&R`*ACwe2U%e27H56skm~8Vkp*h$s7lhY7@1e>gR7}bWI)d!2Oc$xh zo^aj=0urnf0*x8SSZVSj3|`Yx2Vu$8JAD zF%Dg!4oE*T>2dQ>ev}mh3oG_!#jh85vHO{Wk+?3{)ug2=-aq1;u8La>uTy>Jh7#=&cmFXC@IgZ4R=RNju$1;4E-g!@?US;Xrv~@Wbzo2>FhxL*h z?Xp;*<*0sMR~%1yTyT4(Z6&5;0DWRu%&;!Wx4XZ;s$?{`rvBnu+Yt0!S?f7($h-US zASwOe{=3eHH$bs)yrMLy1U5$z$<2s#^TM8YEXG#CcYU^|lYnMgIdM)E#N$OhDiXtS zyhSKBkk8tY!I{Bi01yMW8(G@sU)NFSfpeCgSP5R_)5R5C|H9mJE!5P^Y`roj10qOOXg38mxdvT z39Z7W(|0UVH9+jczBDsQRa4oy(s&eB-CzIq`Sa6d3xw8)>cO~53=*?4m+tj2po+&{ zhU2P(3pGf!U12x}nOsGiP&VyKO&qsuO->Y2_ae$Woz3bjFGmL364=;?9+wEVl;CF*%v>HAuNa7jN4PZOhR7hr_VJh6ettQ7zF1tN`o}4Gx_TJc zQ``K=M|7$LZ_VH3qfK7bG3@+wVrv89XlWOb2@hd1jHD^bX2Y zVz$Kj$9i7MMtQ0&zZDL@)uBJO`5pLKm;U-$2PVj|Y*v|6RlvW%j=yonrx_HbaLE8P z*CpL61yrt4scv7edn-xbja7$<;VD`aYN6I-pcc;AX!M?NNL$-*IWn4zCiy@Ykfjfx zsA~28bLhtqs zKtE#^)F7(3D&lpv53ghfC14zDL{r>wN~M*32>GMC)B@}{<(}n|9<5z6!%tS$F{K=3mv$AJxwNCC1;oQ_e2pWK~DouoXCl zQhNJdj=?@ZUxzWOY~*13$pfrs%?2mr8iMSq=a|h>6>pfKqA8FceF!(ts0^+|C#{n~ zVDMCZ8tm&Mc{D6xtU`fbS7N-HD-W!xt4Cm-sgQE3Se4ZRhd~ z*v6^@!uUw3%<8;F`Ou2nO7Ks>d%i-|%*hjf{eDW5Fup@o_gv33C!)hr8j;gRKD?1w zHmEji^A7ixExC^cpMaDa^I}PKPjVfXy#kD!qH7s({nq)tclc_DaZk=g)-FaZx;^$d zTWDC%i7)jB>B86}dgi65`WjGuXRS z6j@G8!&bdtR2Y8~-moEFo^cO#p?b*c7x&DAfGRQais+AEGA+krBq{(Z5^+?>*#Fw6 zwTXr+qm8N^(=|M-l~wKA0P^JiqDUY^%dtSAFW=%o_TTB zK5#RuynBtE?R^pM~p1xR~Z$*?yw5Q@0HnWCOAouXs*f0fGpHFkW-Yq7#V*h92|qC4yG z1V=C&6eDc>_#^dts-o7FlYj+;CF&Pq^;5`Y!iyV`P0^p*qr>OTT>NiJCCMK}Ud+TirPf~LwVTgalJ2C0TJ=)O7eXv`A=jl2x+^sjGOLzPe{GAb zKzcdm`QqLV!(n)Q9@_Xs(5UByg>#fHiiTregN4T2*K$YK`~nQptndyKK1GHvz;LLd z1$luuAf>+ACK?A{%Kv38$E(&x%UR=l)@5`#YABB|F8t+M9xvG*ueKX)?B$A#``6UE z=>`>}J1KT`dH0pg|0S50keSohn7q73EWg0#14z>UwT_c$CGUMLgXnXzAN?8?VQlVs9on|mF<=q znysKh3WYNFuZNGM{74%M^;^=m+6>JpmFmSufGCphf=AAt3#m7DxP>B$Npzvhb0VqJ z6f@UD zcE%@*c8!r^(*P_wde8fv`KyBNB5~`dd_luUfjCV}FQNSh=Rq5UBnNK1EmB=Yh^%Nz z3Uk(KTjH=SmuC2puyZcHr+7})_nE$7n~V~U7kaLHOspIlb1@fjhEZ_dmlh2%W{d@C z^vDUz*5Uk(9Ws3#MXbiu5qrE*O~CEG zv&1#9(i?-+4*Ju3e?*9OUw#>?Mu!XZS@z|nWm4fKlP1Z zCMn0Rj!6QY`+_Z#OgtLV0KUj9CnQ$h4>d+wm^9CD&AVS-!o&=ejJ!j!4uJVQ><$aK znB}SIC}$CD?zB0J7fgB1DbT@2tp>x=H9UFd|{D3PN>_mg=mi+lV+pJXgjw|5n?xIk-b_~ z8H(q9-HMeS`kliWU6cU$jgO>)%%&{6y8Q75@}6gy`W>t7(?OEt8`1Cnj~ChJ4~bjD zzuzV7W`Hv6IJO26g5$%tn5!dv$jDQuZ@Q0|%F6MZXM1^na(KLmzud*7~$(D(VL!=WQPqkI=226 z8AcK9FY*0~CYqb4id(l3lJGBQ-JSXaj|4a3L4N*2%b!m9H6I|>r7v$hKD2O63ksMr zJl^L6U7B?XV`VTr4vu-bBarKwQ5L zPkKLRz0jo7u#~GwR|GRYsA0S~P{FZ(RLbWx3trmGX6YgS?B}v#SKVCCL{598NCzL}TUOJnslS@wzv$ zvrj?4%NaFSrJCN3Jzr)B8^X5D!p+dh{34|-9fqGRVaGRFjxbKTo@BEY zk{~&ZwaD~J@kYM4dNn8r<@#*&T94reAQn#HF<2X@_3F)yapamj zYrWQ11pvewSv5}I%PN3bI#N&eR$D;Oq}RgDoHAr-2s=jP($p;^XsTguOx~T-*4;tF zu`jr2({rL3zFt|RwiIRG@@aS}6h^duOZ}m9s+)H_AP1bHG{0+xxqUMr|0zpLR?__F zUV`oErc{=yq>s+=Ty}*0w+E1|Pt+tr9$4qUb)G*5P>J*g5XE%|A+;AIW4TEu)6ChJ zL`uFM_01X&Sw-K>_pbd8K+sy`8wWo=wati3w!X~-k>MDYuc~mh&mThO)L5XW(8~Am zpa!wSs3*eH>w#Ax2bj+%eBda8Ila|vWS?a&9wB&I+d#kFFc33tL z4Q)@`);hi|Ze1%_$g6B+!%re(suoU&j>y#+l=+P;kK$h+dbOPE&Fihy4fIy3x17G3 z`m!NDA9qxSkM(V=icOVUuKZ)&Acdu*0MGM|U5-y?YO+jomtHwaM)5P7$wC(`H(rC{ zl#_2RHGiOFj|V|c0h=8DRMbnkA*qrB?+1E089jDKAN8&kPeq3oqP{oUE>8ea5DIpX zL*4*d@yRyRQ0<}L2^oO)V+Fw2{tka{X{{a(8gGbpBuAzD$Z?00=P?flj@G{K^-_0< zg=&_yuof8H&;&9OU>SX`PI^UkW$%qNW#KuX5GlkWPg6m0Bg}5zET7w=3i+i!uJ4b1 zsXDc)H49^NWbx>z1&UcM*(^+C8sR>FtEzJL46+)Q@k2I#nYiKZ4K9Z_5N=gBYh}Sk z?w~VtiB=S7D_bf*TnxH*`yE|8wWoFp|FwWZf3iT+dK4yz=jt7ig1eVo zD$AG~FQ!K4894H=COBYB!OZqQ?&DALeWLbFb2oNJizHJdQ1(ODu-)<#$lDUG$voJ* z<2oYPq3O;KzJ3N;NDKt)4Q}$?ILCr7s!zU2NoEj?I4;V-@Q}WJ(lf}T8w}O&<)kK+ zd>@wk39W9a4khOe@qXcT{b0C@;S@b=*#WFd1)_!c4*-~K61P7&JKbEx1wJj@t@etY zfnjRr&JBnV{?B^0{1*iNzg;(u6WJv#L{*My zSnwj2>V<+8vcQgHF0W9W2S76vie6-1MA$jbPCQ2{O6T zQ~YiA`a()RxJu=@G1z)Ai+mZsQWT1H<<+6Jv!}qBr3n}=40im%=H3@zQl8ZzDqA0l zSW)8A-RS&OQtmK0+<~7=o_zEYXTU|v+B)z|g{}+wmqbF?DGq%>ZxnR#n!2`xz%zkj ztLt|xBq+2$YKu-iVY8@eH#K$p3dok6E>E^K+P@|q88Z!WSHrzJFoO6w=@;pYr`k8|Z3mo7dGo0$tH!yey!ug}2{u~X1` zK=<^qlyebZzD{&tHRBJsOmgqlrJa%HkY%@M!0Z~}wCv*OOO>?01}RJ)@U)trThzRK zbgaGC#7;{*^*x*tpH5VVb@*a^)9#_K!734Z8+lbo(wAJ6;iu;K3;<$pWQ0iz7`hermyF<^IQOtg@>$?&cj}gpMPa#x%uK-gjEwEt-$XL z$lQd?&p`Z_J6&eDJl3uv6ajQbK6IjVp-Cy7w;Cne^ zn6jyh)#aInQ(sbU@>(UP*i=fgMQnU^XNPJv?p64S8^L+#fz_2%rfUZ0yRApLH*>E) zM*M&tj)~?#29)e6DGC(@?2B7KD$#QA%>vtZ`uaqg`=qakP#0S1f%{Mo(sacOd#Boiq%c2bfkR-y&CM4I+>9wCBbimwyF2y{W*j` zT}ak%;169=86m7B_0izZ>J7;5CMg#4NA~ir(ux}G$fKIpl_6pQF9AF&;?EvgDTl0#G$x?2E$2@g z9_JPuq`mX3ik`2U{L{<)DcwnIA+;%5JI>`~YR)^C*~V`4V{~4ub9+37B$nugkHb{8 zvwPW;XIzbd7y#j*5k3Q{ydOXoy<`1>V@^X5V^As&$^kTxG{7hH6#XN-DEMMZ4s)QD z`D6?6PHu#%f~}TE?V$y(!Yg-iNXON|eL_Hto|^H|Q%8YQ@pfma;*5g?&s}qzk&^+d zx@qR?2IqMeiVhLFfr0ub1q1R9%v$)Y&n!=0OeU4-D-%Tg_+2PI>+}6WRgsL|1h>`e z@nxf)Iusj9572Za?JXjcxA3-d)~KOGq*hbvpv8|lY67&w%MK>y5Sb;J+Rgo>@6&gs z4+BtdrEqc3^qqkuzaI1TBAGgr=2x$a3noamG-Kh`-D#k2t_&{Ub}@W**)FkwG9|d; zNT{~gkxS;68(V4okn;QDZ^S8gIV}v2i-ul%ru_qTHYFk{M**dcS-&%zrDjVz2AGyE zfHZ$ZM_Au|%1_Rwx37t)5X;G5-3oTvewgS2?rf-Q!WEI@*}$@j{0a zb+ZB9xz1r5QC4*a*E+ZMi5fKDUgOkq% zIw0RILEkw8)1PC(Z@?DpAQjQn}X z92PGJX!>dD0uc(Gm64dL73HVYpw@2z%UtsFf53nAm&-Ln`v*>lILl@~pfR!Yx|1ZJ z-`I}8{%xx!e?GuO=mDC+F!a(Z%inGT;rWYCzH!2%qY%%`l3})$AI3h5w(%ofby~B2;}B{ z<5o)96$hn`5sJ(U^R1u}N8>a=nEEd@0rP~v_XDi*5s%p5$9m?WF6#=uhro=k2Aabx zjBQ zo1h}-EN5z_yJt<&CC76Q_@5PP2Clx;#tAO=tZ#k*;Iwbs&5z_%VN6o7r`hoWEWlfl zu@;QGI;nYoa<5~q!+TEtN(Iy^iR)w|;Sr3EN3zM$^mGPr{uQ{pW)v%WLDLG2Xp~uO zsRUY_6`0@#iXX40X8+MQ1`l+fSh@8s$;PBy&C0SRiN;_9G_@A}GE9oB_zdhJJ@lh4 zAR|Bo7n4-au;K+6qf46ow89FnN^Ms$3Ubq#ZLp!zF`YjF$n11!?ZpwETZ;UbC$tYaK1u6kQ4Vu6OlEAZ!UL_}DZJPpz3Q4reC&XQ&&xf7{R}bM zc1??Q+XNRIeQ~cpL^y+5p8mQpmlMl=dgAWr;WQ8&a!*+YchlnQRR-ruxP?Xz7J1OJ zXlT3DN4oaX(AOi+J8%`?EwOqlxx=L=WvIxA4wcf)9emEO{`@H^#g-RvaZnQmRl{Vp)X_#>L?blwSxx zH5R-^KtUEM#QR24bn^|$f#*?z?DhoDtn1dA^%mP&vucbH^fJg)r+2DvW95vy8W8pdM4Kz*JJ*a z3mloNEwLdhJ>(CA7JSP>)89L#_HO`Sv; zdJPUOB-|2le2A}N_;Cn$b5Eu#54GK!TJnsO;BfnD>JN%~l6L28oT;f~1OV@V=y7rV zP61V|J_=Izj!SzTOOrS})*!KghSV9B=L#C!8{eybzPnBjOx2o?>>E~79L72P@GWCRfRvr? zq=a_7iFJ{>A-9`cmA|C>PBC&LSFT@>j?55uzeWYO+BkZp^#nzWiZPae5W&DWw+#yp zML?oq9f3-+Wem^AqAeps+(|fPE1RtBP)Q1Z95bCef`PkXWHPS zj0CpRTD4Kx>kNJL3a`NqC2sNh!BQ(zhyBIMKI`T&IqzZL3P_GQGrrZY@}@-Umzu(f z()!8tc;3p2H4slw3oyyTzwM70p?sxP`$x}2>EFK_&RH|MnBwCo!RQ)=&`1;x7C#@k zp{4KA78L2+O%fQqI+E)u>v$eGNyzWnL7vh7zQi%*p^}L}Ne`|0hzE;LYVr4?jVGjW zVbVB_sNZbe)d|m(;{(imGdpNepc3kDcE52Zdt$GPn*KfW^^jG6$-0E&Lv5-m1v1m3 z5x}vx{n?K5^MUhUhSCs|tKE9jFAQU|XFIfReeeRBKH&E8ALXw*^+B-MOjP)E&2=zh z6fW}$8unjYCBGuM-~Eex4KjtT%KRqtY_eBJ9y0$9E$iW$;^xLvpN+A~p^SxdfyVQ( z8%*QrxUXXM!1j*b>1z4=Df)d+{GWgNf5kuae_Evht9NM~F^x5`L!b>X6zl?NqtnK; z)yDw23h`S)SOI<*GZp(ylMhG{Kev^K8$jNPY&fvAcUXggVDm^rR_r;u15grHeEMqV zMBwMxF%Qiedn{l^Qrjs7*mJMGki8taNe`-Qa{);)^70y$H=Y;*41q zhox#cNpHX%S%62YhVIt*9a&-KVx5C4{o&>Fw9{Dj`m_CiIIg(c=APqS4gSQtmLF_2 z-POzajY8p49X%7+;Y{)yDM0mm9FM!jgZd1+ ze&@ely}ab__=A(ERnOuiAkRT|a|{l6Dh1C(^11{4wavrn6y;l0vo@bfM8iEH~PNfN1*bXE<>%bG8`LBMea`?jr=$v+`b;(Ot zM`YE9GmZjEo(1`^mySyi?v!OEbfAG#I-vmdutFTpH7=;21x;;U7DRV=nh#VVf-^JZ z&kBtx_ar_lJ=aqSQDNu;j)3zaM%Y-4o`>Nb(pI|zaksGT=tB?Ab7op9fftrJnfBb%pR1RLtfJ_@VF zyIn@yZNnx{(mQi_S4!3qYe0p1$M7c5rcbmI9MCk!4zvgcjXX}~9-N1TSr{fs!_9ikl(p2Qh$b<%sIY2La8Yi zQ9^xk07ptfzS&w6fI!7-N6@P|2O%m`8IKKSo%F7mNu~&nX0PZYvRC$Z`vOg65$s#Z zMJ#^Kg=z34fm~F}Fn>`;TR7u#_MFr>vVvb@+0iUg*v>#B^^e?Nw1>z5?>jkDE7`3S zenOF=#iJ|YkM^yA?H^Ghb?6~ z{fG(JS2~e!+?1(h@H+JUL}|3ecypE%(8d-s%OielIt;rPk|vR826HB7z2$B>7pvXg zsEbW$=E<2{74Vu-~}Irmq!;r|z_PcTx*cU!-!$fB4G4Og*X= zA{MmW`z>B&0NumJxf-9y8|=R1m+3oy4sP!Y4sYcx0))JI_tVV8Vf(XxkyiS!OpN+A!NE0_oRq_~k}l0rKm8$E z=?@gJBWVBUc1}kQ@);$9#|Gk87Bxh(XmW+^gYiK5(!BE}|NUhE`K$mBT{XC{^IzYe z%EzHoZv`V&WXUA|p(xjJtrQ^QB>Dmv#Zt>*ak~pN=@yql83=6Z2QL3==LJ3o51;bS zz`Z$~-Z!z(IhOj2mAvwYfY4%BpjihkUMsW7hUOD)y$j3^Y#^p|Ui&|Hw&x~TS*;qq z(-Qrg_$K)9;%murHwio2T8xQsXBRHe?AU+thx-1N83Hvx5s6Y(e6He@&w@|Bi5iyH znu)6itoa5zwN`uBA1r>u0!>%gWNydtm${!Q%LU^ap>g5g)UlZ)jHy$?B50}QAR=@E5}PUt8K;SZ_D66h!q9~ zi5EM{MWAT=fBY#>9-(l!6ldq$VLl6$KixX1E>cSgE0r+rktGZQJDyv5D+w|CqH7BU zc)ss0l(ahNg(pbe;vd3Mj;Lih%_=n=Cv}s$r`srBP8A2rzB$K!#n#^^#US`|!Cd|ZlYsJfe|PAf@s{z}%Ct0?uP%RO{|ou0 z86bZrBXdjZ6*q$>onWU|BEz0X+`?Mr#@h^i^zOY8$#d~NFLdG0fVf2X@m-WjTWNY|a}k|_MUe+Hh%$lxK`21Z#`tyQ}5 zYVNoK$j#P1Br?F(c)Xh+R^TRKzbSFfL|H;TdIhQvzAcq8k`w+vNPF+7rm`<=7<)lM zL@5Fyh#=BLluj7wNH5YMN(4ljfT1T?KtP&^G$|2~Dn&pDp+%%bx-*mCpR8Gf$vx-ZbI*D9-p_s>u5OuEkk%YUhbh^M1Ic+r;r0VSoW}SG zPPJyZhq|2ftdr{-Ja9+UcivVOLE+qgd1p(S65e&xG)>s+|w(r4LOPvmW4vH9Xx4V0AyL2p(Q@*Um- zON~~YoM{_bKJC5abLg?q9{T~n8(`7qTl*!|d9l;aAHRvtK7_K(C~&Uk=mfNrE&5~ToiFY_dBu1(f!S_2r$cNIP&B_2aMESXPKhZA+v;uNvMYGgyr2#1#D+Nj zXv=TBVZTZMLwLfgE*a>MyeIDNJZo$|dUeNR+R1$#bgo%ML3iJ`TYJlY&%oBh`n`3a z=fFnK4wDd;d4+#qWF=#Xy{KHguk>1A+bkXrmiU?AitseJ9w-Qu>DpjPYc73ysju{- zxvw6bNIWqQcUzpO_vuv-os7!Tx6g&>p4a&z7MO!iA)zkjds;C^I?3`FHD7v8TlHR> zpz?t#)WIlPEu!aubi?2-fA#|Efi3q}ssy5p(dvOCSDbh!H|r{@P0mXvR z#LAoXc9@2Voaq~ik2-*ODK|Nq^GaP9)EtJG|43k6tIMNQDR}Iw_C8|p$l&snXJ7L5 zq?!;mPqK;a<#Gccc$m!F^qO1jnLTm$W(EuYUE4^3L75#L>sZ<)Z5M|*>NPnvON_p} zqzn6>&8d*1WJ8O>2nUotDDo?^T50C0!zO1&UUr4Y zR*nzjz$^sN^SfVuAw4#iH&@2XJ7$w()g@+DtRwNFF_h3FG8E` zJY`br;jnG0h_IbVloSV<0xO=tSvo}Ber%sj6LG{g{}IRU~# z@s32OyvqcjJ~MRpm9TK$7V3sS;4gcC8G?bM=0Fqs(O4dx@on zdebR%o0y*dsF&l(cZ%L?i($>eMhMJnoeKZsE4R2H+-Aq?GH6*{pW8py)y{8Dw~g=@ z9RgVEceUf!!RcK+ld2!4XOKCSwQE*MYiPi`>VVNR`y)P-Zwb$g6;FEO8rW?}#bWxU zd3tn4-mm%-VxLK37gy&7JrReJ=~=K{=e6v9 z`txNhN6$?7E>8!1H)G)Kd+i!rm5=FRO+Y(!`RQBNg|>{hpKzwFZC?JFD6@Xu`Bi{`99D6;w+i*D|+S5 z(Nis9r!Rgvato+Q-1u2IrKHrg9RIk!J`h~xOex3u^kO3HLNA?@n7lX~--Svx02YTf zSGlx9povr8+-97P@2cF`_A8eyWY;{!HibLk+kl}Xhs)3_G3Mg08#lMeb8m{R79@9H zeYA6lB}3kzGdKUp%i0CBw8YaykpTr*FA~b|$D7hnGXCrs5C!k04xEvIADT0e4;=;r zTK1nm=u6T2a6D^h?`++n6IQOq@wE6V%5v|I%N=Y8M5WeQ5sfu6_Ler^}(`?J;*B!I}h8F6JNOmFJ|s+ zb4Fz@oJ>s-t3|JwCnopDaG@=dcFbCvooNU0AI)i|oRgQPWs{ww-y$tL;{;6}PdPv<~J!J8AZbGBk?eF&g1MV~a&`tw<<}xbAN&fKYGOITge`^lWJ+%K7RSm-@ z-L@k;JsgMsS3kD_htlY?y=*EJkdXbW=&9;*6w3I+r*Gf4_Fwggavm&Tw=fAb6Frsy z`fmUb3ec0W&-FL{vXE&95Y~79h!#C$Ksf=|*W|Vq8-#LmLTtO3T4#TES#DX@DN-er zz)F%xQ8MEeyZLPVgwdMfeRLpJIUp~P>;IQv_ROjhtnmd>=4t8EvlBE%iqnJudeMUQ zt?cu-m3ICC#?d6ssNkc9WbSam>8U1mlAk4f9xf|gNHfcn8?vl>0;n>+;oo_fs7#V zoX#`-ZtpRu8SE#J{gjSboF&XT4yo04tY~EyTfJS2*w9s}D!_{w#_U^^+ZYO+;fp?9 zq}dLU@%_A;jl24mcF4oF?^2~~PeqQn9Wie6Trfq$!j$jr?I-u;Pw##oNKcH z%RCT3$ouEJbT}P;J42v$);@sDN%w8|KQBr+KJA(V!X9w~#p53AwV$pXsEF*Xz`xDE z!H3CJm1Sj$I*g0ceI9g=f#l-vZ^{4I3Qqi0zx(~Q1k%`heB-?bp=Xk-j<(6zLF9h~ zOT-9l5U=jrfL%lVXu8?SF;KRk6uYS!%oB!|(XAq3{_pEjw9Z*hB-o53P%E~R$9lj~ zg42N=VqaA-Q&Unq@Ou$>e@-Xyy2otmzZ4P;a1%ikXK|K5^)@W8{9aosBl&XuDVpCc zO^q|H3}5O*iva$?w&HU$=+IAJV`73uHBivr06abk!`wd-wvH!+U@{3qO0`K@JGZuw zQBH@m;9{#W;^)Q>sFOr*;l6RAlN) zUcharv-VCE$7&=~*R?m-a<>5w@2W3=Qu!YgcXURU$Wn=$ePsH48|ng9=!>s3Cn-NL zllCS$qMHK08Ax{qpsx*hkCy|Ha-pn2Z}mVD^SE|}V3qd7=Lr@rOB?-n=W(pbGRujc z`vRaRt~usX8s3%of)D&qB*l7F3J{hoKqAey5A5Vkas}{(i0awU8bkAtcEMHDW!>wL zk=uv^RjDf}_K-6_V*rkZeh}blLf+XSWpeb6_@JN~gS(;Is>Gp%VGmszSANt=WZWj( z>XIk*o_A3|D_e1%f1Z9cq1VaH(%zoBxMlnxdKeubOgG~WP#mvq1f^+Cs36i#UH@wv zx3sxG^Kmj*d~Nc~?Y>10M9;w)KtXlS+S>q!3i9I-5qbcbIS$O9QfU6T_%HsnVK#7k&CsDxQh}aex!$%kA>k$jy#%C&$jix_7motAhuQT+ZFG} z%N0B$>^95gz}&H}Zg@Z>(eX0A|6y>2_9h!uiCZeW{cBp~*feC{RmQ!5JIN%? z${rsVJ6Jm=X`|DuSrr;JQKq}Dt+4bN*{7nA!&UsEUax0JhIgW@85lHRl%Bh&4S1*t zphqX{>LgR43-D`F=b zr{X3*l{X->Y@(a7;+u0iQHAh8ENdK0(>Jn>Ia)`Wf4aD1NwCr8`!sCun#8b}G(v&` z=`*9-3##>{)5kHf9|Oc&b!tzNIWb|Zs!X#3uc zpxgKT>rQfI`AsXuTc-rro;a5vAQk`QRKj*2Ip8dY{|xRh-FMl&8F4DXl&nyP9?ejS z6RC=X_<}EA;^P7fjXX6z-0S>GJgO?5rUX|O?Ih8YvtSZpfsuMZrkavTFvF*Z2MWu^KWYQS8waOJr1OJ@GOS$yE#(DmYhxnu# zYYSM<(&`5Mx!5Cd=EczURBD0f1%_mG>$z8n7>}LL=UN>zdt0`K;LX?QQ8SUUz56r=KZw{MEu(1pu0wo0itP?Ipv?upLM!qwzBWKRXVcLa& zYSI+m#HN2f{?w-Fh_g z?oJm_DBL<{ASGxqnu$~~hKo^%7#dtjOK<#CyBhnvvxEr*;-}}=7(n(n6 zj&ZHB;`I74)GAAIj^a)TT(4jMbY|=L(AD!XV!a@)Oln-HA>gwX5pC#i5_#y8Ja9^{ z?>8Cp5?G7~(^`eHCT}c#maxjXH%=3O(fPscd(J_}Gtba3N2)$8qy4I*?KEz! z9==9}1{f5)Fyycwc<+1r#18&VvXOk16+xMwElK(_7*OlmJFwMR393MT>bX03>)dk7 zZ&~m3Cp}eX?py)_W1*S{xK;Df4x!Ua`R0nHdZ}MMOhu1sdY2OYdb(DJ$IQGz(U|NZ zxn)_m3$w~%&f8u+>kl}NlL^Q~o__w<30`~j z?EAnZEAH?z?KEecmdo7bKFhO&S}_N1Hf08)f2jD9o>bJUqULK{mVlwb&r|Uy>Ylif zOU?$Q${PTp1Nw??gvZ^Xb4-M}M|6Wiza#DwO0>$Pe^Ve&@UolLS zid9(!x=#%M0a$;Q;(tHy|D@Fa4kG^S|Ia;{zI5|ff6<@Cg@2*8fBfjbVJskJK}{;1 zNdBr`tx)GTW*QLgP4ORI&O>idNrjY5T$Ofq%{MqBtj&!h!Ot1&JX4eTm7z&JvgPrb z|CD#zdX}&pOE%TvO_!_*-M9Py1E>BdQdF7?CKep^<`rZK2A@YBi^+uyDt+qU0pfQq`o_F~(IIkQ3A6y*1PbPhmC;P<=B zct&NSI<&G~2oRe#*rhzuopM|`3kvUgX&Sgxu|^zw_PAGksEY^8-vtRMuRcR6WPSmW ziCkyBx*z#hND*s)>5h@JFwKjYSh<#0@@6~)!c`Zj1Rs6W^$K!Vlfx5wtd{Y1JM((- z%9%AYZSqArPCyj@_vZ-^W+I)vl`e&{ZnS=6GU9PdUXckMD+a z?l4H-WZ4e(gPzy{=Ks3n;Vn``3;&G9yWyHABRk*En1M`wZj_$J!;|_r&*sMIV;QEI z!vluuX!Mo)4#4Hfn3d@LmJry%HTYJ6D_xNIo8D-;hi1Te@Y@QeJU%NGx0vxwLZeQX zuGU-0`Kv4tW9WbWFfO$z6sEQ{5>_uN!lEk~z4{(WHGBS!&+S4P7{l6>f4TgwSFOyI zMfcoALn|gq3ySv&&L$+#@`6B$90i^z&%)9_E{cWKv_w7)lidHQD{p>u@L?dhO0hq00m@8GF=7wweJyV>|yj?)r{;BRoiEtme! zEx+wq>X?qWnOa{L_!5wZAbNSRwjAzokOYd$gfbI7}mI_j-<}};rrrX zfk3JW|5n&E#g5C`7G)ynnjPW3%Sn|Vg10mktjt4LlSv=MSQA7r*Wu!&Q|xUOoC0(= z{?1NyUc&0U5iYf3vOY_n*2P31sC|w|RM|YJxHnx$#O9vJYBWWS;`(K_DP?K)2Hq)3 zpSznnU6wEP!XTDpM|%w-PAca#!LH)lPPk>Bfe;=#;)}&HaRv)o9N<}UI;lB6mK zis}LE(j0=`=NPJ8#MrM(FGVdsIO58zTsi-$IDc8p7pB;lm`9(^;E!Kbvex}_uXLeZ z@7pu z5WOERoUqpNs4zlpJ(*nmu{YH_Zc<}Ibvg6K^M#XTX&0t2kC@$70wPJiin1aDtaLx5N4IyPl zyI2>knSXJK2ElMSt(UdOgA<>NYuLUHXigwcJk4!<+ZjSQxo;L}Vp@;JaT3`8#}Gd9 zv?artA0(*OLvIhstn@W$i0Uul>_cLuy=TFRmdmZ10Exk4@1}MK``sQq+&+?$DU9vX z`nv;$UD_Qz&SCcOJb}xSnrH= z`3tb}_3JfZ4GKwkoZ`B?$JM+)~pAp$~q`%^@S^>=fi6j$&^k+Ztqdw&iU zByXturPk>_d8XEhV)X$#f=HcB?`ebxfEZ2xd&U?C6*nB8VE69j0~+=M5_a_%lkCQ6 z={Di4kF2xD?9)LxCjffJv4386&npq6&s4_%=oQS?D4n;7D}mobu=MmJJHh^Bm}5Z2 z|KTglbx()9^Z=2CX~8AS`yH&hjc8B7#=2gF@5?nf*a7aX^?&&4{~fOVJ300bu&k2X zhCkW>w1wNaD7nI|Y=30aQ}^NsAe*j-jc)kciRT~R`YY%C%Ovu5fbzc}HUM(R_w4$R zSl8&{v<)PVw35}8yR~P!=NNy<7qM)eYE9eNfXmIaP4+iQ4w(KCpc^+&ylrx{{wfCW zU0eW&;{Qa0Khy1|^Z=mS&+qJU zRV_Qdax^ruiRv)~B80XEuMh6MR%QQ* zXbz|qQ~`3NhwIPPG8C;=35?!A4aJ+PY`(mGusFP-ne8ie+H)!UDO&So@mRt!CHbiC z9MzJw_G&W_!30SE7pWd?BUDgE6mN*qcb)b1-zWdVi#fL17wEv_ zmkb%NgYv@Q5d=--=+fopUh#Ki7p_TS`=xhwz%YS6=zqmX2YjC-kLxyN@2ahjFcbT0 z_L!#@Ti?%B-HJZ=$mfO)2uHbB%{*`Ix8(@8Qp0no(0J2}(uN&ceVqZfhfU$1pi1yK zd9FiN)iLmoQRA-BtKLpe@1{k^Y)M)IJdW3j1#&f6B`m7b#yFeySC!$gA@S@;VywlE z%-~=nxaLG#K?vUx-a_O~YcM4J;R)C|LSn$8c$sr5dn!jPY<%VJ;)oAy0WeCUaXmWG zbpnWc&z~t@+}?NQ*vON!zEo!W_MDzeE}cTN4=nk^w(l8N?KO(kJJ|?^-Ny3mNI`Nzls7?Af7sD6`^qyS1x)& z1U}7RNg22GqRx;LdP0xk=ZIa%zNVuo9GQHlP!|DcmuNqH(FEnDg)h zxm+nzUC_xm$&JON@AG?YN!7lxEg2ez+}`$7aD8P#E+vbOzp4ZOaXPsm%-FhNkP#w- zJnxwNB9PJPiVS}lZS%z@3zj`Hj1VYca)5id6X@?Xz#rATJUpn5c5p$Z`(mkNA z?o@RbliLQOPwd!MFC>8NIz^AvsinnERcX2{FVM7+;^n0>nS-emS(=6n24^DX2FhlSLQ@s9!F0)gv1pA0rznLZ z2~dxcFfm1A>i}9Jqf|_W`|Hg(X>VSk#ypIEAPe5Z=ED5iV{&&YNBT=*>{oA3+ObJr z$wscei`WXP^k>vJ{0GQc%#A4>3Wg%c*&wRZO168%9T9o;`o}WTEUrX#WY`<5hT$T| zbS8vjJU)rb%luvIbT|z(IOWes1o?dgD)+R{=G0tz@?Qkoq`ELl5#%no|4TE=_Itn2 zKKz)VFvZhbF>ehpo*u$UkMc|pzASsnSgSBC780(&`d%Mr6N|*fE^_m95HGKlMAXl{ zS1L?}4G#{aq9TOwHnLt6Pw}+<>g)~lzhoJ)UJU7D{Khx9cNGa>=vGIDxiAy4C%zqj zooICJ>?(%`@6@&u%my5VBT-7#Bqf>t7?nnQLnMubs7uk6Ac5C-cvmyy1#Hbt# zmVwFK1WAI`w<(p47pM#3@1lC~?(Y=o-DLzSl&V;=yWL-s(tD5ZT;FpILUxbKm)4cD z5nm?SH}Sl4KPffxf_8Cm@$m-J$+B=vRIsg=oKC<2``wYsWQ=ZLXH%y16k8hi-w+wd zfSO-bd%tv|n^&qUTWQqgc@vuH(7rIii`d%BtJ6mfD&g0w#xxTHe}g zRBC753_?>-auz4w5|^yrQt}>9(_o>Y)VVlT641U+rhDvnEnYguZ#%xzUbfUOZz|VW zHrm-{-Rg+AIJ}M^p=Ga6t22?^9cYy&Cf%A_sD@ikiy!Mocls+Wn>*jxO?GSS1W<~8 ze}AoWi<$D@l{?gtAQ1`FaCfOy; z`>V#4sfDz1pgIp)YvKeB{g45fh%GUH-;RA#wykxqJc15b;6K9ho_cvhWW<5;HQ+7x zt1ySmHGP*_2&2@NqYD8+Aa05+!;hA%$G+YB_>?3myRo0t8;wXq7T_Z8>ToKy`Z*B( zPW&ezf0FWP#DY!l;x0r$?`^|)Omt|@Oj&uIHfnTRkD2aM`G3!NlRN9UW!`HvA;o#2 zkxAkolLsa@Z=~N1e2wO_$a3a>=3iR0%b0s3O{uyLaK#DSZmfQ3QlJbWG6x_wxA|cv zXrLLH7_25J__4qpD?t3TtJW4J1 zaUSii(CM9OjU#*oWF3O4S}X~Gz2xA*-$4%mG+#1j2fGLuPUfIj4?WZkt@=pmb#(|! zOmJQyE-mHQKM#;RZ|T63W+8VCq^@mI&Y;toQ#b#%$6=kGj?Q`SaQKZ70d#zol`1HI zd_L|ea%}$0ikr&&GJukvnP#|bovUtln^FJ=bi)e&3k6K>^jA!moz6Q{F9YJ)*m_?K zZ4)B3xET$bHpfM)Y{%ofL9!!4+f`guk>`?4-YZKOh~C<8?A!abj{p9e%oFn=FQpsu zWD{!YsS3RYxs2`9$ksj{y&TV{&;Z)#Kp>hbK(>tuHp($Pnu=A3|z=H0>fI)HSo*22Lb$%~Pn59wbtfqx`_|0hWP zzks`c=L&u+OaH&c&`AK>{a2T~79ta%`1<-+E0Bv`0|wsLx>%fV109PWeGgcTkBw6c znI(t-u=D}Mt^dtz`C_pz0MLt|t$l_G-4(%;_d36|Y-t6cgLIXETiieYIRKXK6l!b> z$DnR2uq!ULe6Q6q?@GXQD5Nt6&OQOCGn*Q}0Nh^Y`>J}Y)rOb9d|7XTkSW}=(8L5y zX8l1Y_J*!eJzXxpXuXxZ@#$4%!um8|0~8af(?^z}T_NTd6@6A_8*dJS4yd`ERHMnP z=-=zVj=8#v1j-Jke_{2o*~zfk zRHI^7ZdB7~X?s?wjbbQ@g4*%HGke7({6 z=u0|!2Psd}+_XYg1O4SQ20iTXVR8!tE(ArzRc1xHPeQ=fP|*Dan^ccE})Wh#jbj5UfekQPlZcs92x7N34qq*jrxU3E!bo~-PTGH zp-k}AU`CZ}Mk46=c(&#GqM9a=a;to+A$QF)6_Bkc^^pf%jkjNCV|G3U2t;r%teWes zO6sJZ8C9yECD}c1>9?k%1e7KoHT7>9x4!z!jR_9fDjmPhqR3vZ$D0jOZ^>zWk26#n z1_{eH<`iN2-y=O8ubdyvfen8hKb^27fGiaKbfBWHJ8-MBG*as&45f*oVkc9h0on>_ zZ=#b&$-%bT6|&toIy0fI#dSpDPG~#VMo8Mm8T(Go*#Lkc7xYF;Aixig+Mv14egNR@ z;Z>J0dKdR-SS&~M2ZcTdBLLu5>O_(HH%Wh-U+2Sw%0*3aS`2iWzwQt2-r-#Ftq}~z zOdfzZ+DD7m1EPyZ*6Lju1Iia0Ge({m&+xae-D6*B&Zm#5{@!ME2k9beQnpQ5v_xuX z3fSN5iR5W z7LY$=nkn7hhKZ!xEVa4&S9N7;Kw|yq>!#S|i01~JUZLflOSnDg@4XHKZS-d>ML&cG zJwluIOQv@;K`k%!uPk=G?nbWMR#ySR6Dly{oB3B!PB$uV0sb{_LH^Hn+c z{d7KDds!CI^rD`T8!j|W%V~hy6Dcf5I6tTD!M!DVcq>T#(N5xa%A-r^=gE<4 zqc8QE`3Bs!09z>TLQi0<@?87^=gXYq z$9Aq)!s9(a;}2`>qej^*k&e$a!?zhG^I>KllLm4O#t2j5R~*VC3!o8ztH|-^@l7jrM zG&d8!V@G66bJacHwllYkIIJE$ttB4fjK~-_d>X>^FtvMvhsNfXwu@G~#>ReYq z`#DQ*g1|VpU-fCrw{8ifxI9eRyC{_7)1l7Dn_AkiWR}RY3k(211N2bnNZlPqIP@FU zRC9Z04nNsy#yz8w2kc~cfmCC*uAAGrRnEfo$anJy^Kj7%^QsrE0z_PSPjF?x5_h9t zt-=ElQiNDT#iJ4w&458fgi%Q&m_I>GqLHvJzay!uo=diEubcksr`?ePVUR@nuW%8ajnM46#Xk$uDPjoND7U{<7o^%ZNR;)AmFy9BPQWR=Zp z1{)jhQ{b)scTh(7tDUc8oJZ2&i^+6Frw7o@Ys$_=uq=uYu1WlUm?lV!O9-%S8V(N9oyHHnxH&NJ6{7&OkU?znL4IGLrjN+19Rw9aM7{i}6uv~wPK zQ6fdw*}2~9z8<>X0`@a1i_c!!dJgI4+O0@kK}~F{x^bW5jx)pfov6?btYMrqwDRvG z^pl4uWG_Lbml+@3X18$N*WndfkP8}hpu#e9@$v{j=Q>E%iVb@6 zQN?)E(VzJ8XEv5_$m!z+JwbXh=}+bS;^;#D+*6ixrvC_seEt&E^g|z`%Q^U$7XGh9 z-T(aGA)fS5z>n%i`c8)%ym!buaKl@U9*3&=Ei3pJ$F`k`aCd3X;+l>V5Hk8#Zf=yu zmq2+wcxQf@q3GsL!rVn#S0julvfF!*?&0md zM9Xs{k+a|R6;^ocK6^RvKuScH#ir2c+13zg?a|Lv+Lo99BO6(M955gC{}#aSi!Z7! zZ3a%s>U{9MZ6V;(yQ_$tJ|q&a1$sbkwf`~;(;0YQp_(X^CA#|_91SF5q5$*Iy|M9P z?c9U*fM~HKkCdW~RW9qnB4rnKJ%oSw`P=jrH5c%Hts#P@t+VT0zORar)1H@mVVxiy zQ-F2&Y2s(35+Xpr-2J;-?nptX$T6#%F087h&GE}$r)Q2Y8rGqvJJ$EWnh0YcHC1Bx z|1b$Ar$a?s2v=Vh`Bpiyr;|*5!LEAE)J3pp$G~?&^@WUW03XV61pDRz8D?p~mh4~n z@Q-jap@(uQ(u~DVz486W$6n6#ERybv!9yMqEiZ4f!Dj)#?f$a^OkS92m+G2NdiYdl zKoW=(XlDH9>iGYVH2(jIqS=2%;J;IJ|Hk`i8T#Iey?%iNZeiMLWu&6rgPz0KuIjN@ zGRcVd+B0$ZwRq?EYfwBE?jld7JqTmwk83(9<280->hWs9jgh#{=Fvo8B zPCTa0kBV%EfL0{8=(&8 zE)q4ZEopf9+5O_dWG4QEr8OvPW^|nou-y89!0aa|Y`^fiRuYo{hOr`6c&RjyxM|yG zE5Un~Nau0x(G;-Hp0xTB@y(AEV)ovUF1V1={w(coE*5Q1C1M4Sk^QAuT6|FAag)Ay z9|A1GH@gBkQ${rUGVH^~3=L4H-T*|3XtnXI>#QkH_x6BG0$nTu77LKYR7;?wCkX5k z06$gX0%`6eTX-HoqGu%-s?!b!@@ABOVR^#j+Nt;AbIDSE*FG-dqdkA~={nAeBRm5e zme1~s9V25{IV`13l&i)$6f`_0h35Unf!_L;Ii!^_E+g7b3Cw6Gc+*UiL1?mp9J|CJ z6aTC$J+eys90JQjJ5OlmLOc<-Kid$b?*{aD4oowZEaR9HT9BrY)j*HH`jwlxjES*% zt*>e>sc|m_X%z!`2)bljAa6W-nh^ZaHC?{^gY~Kkj1MjPLFuaqb~J#y@o9kYu}CJE z1bV?!Wj4A6Iyebm|2ge883BRvrAv1om7^L09=M1p*G82;+76tNvCD3cYH_4 z#M}Dq)@Rf-2hO%_B#n(HMB{}bL+Z1_5M%1O)odiQ<*J>2h+D7mR(kC@@XjULp&UA( zb5S>uP_dJOre0zm?BazwGc+wp*j{GYx1L?QB7#z6;ke2f<%6CYX{-ETS1^&Y6VW^{B@5_c29Hmh1}i_ZL#D2W=udb~S-f z0^)lgrtaw@1jNR&Vl~#O%n~9%3%)?Hb89eP${b)Lvem+G3gkjQoelC+&29Z$DGT=R zKT2+_HNWT+LyJQU6kf8eeF)+CGwbM{2F?{*;Ha)hwjyR32m6Jp+c=(Yw$4(|_|ev+ zRWZA{5tPj87z^2*ESZ(G<&o77trssJy+}hSC{~fOrA3Daj;wi?)V`_Q&LOj=90r<| zUd7&oc?nq98(d(GySu+LRi&13BTzuHRzINu%Vk~Z4461R-rMBl>h$#vg{ZN#cz!gA z)%SLK(q6+Q(oFC9=z79?KqB63RYqM~%{}Ue8bGqf2|~&0(}$fUnR)ckp*3QoJMDh+ zwMIkP3eLSja)+r*e|o(;qEo7LA`0kk+Te=3IUYD|ijFMqpyDvED@=P0$7TfMSQjKy zPW~7pkAiqcb#ynMg@yEG?j?M>*!A)%Yz0#I zV*>wtedlrHC{ktthxar4P*TLcoU}c%^B|k%Te9-h`mq7`ha>*7jv=0+b-sPNDM2X# zE0N$<7J+o{({bW2n303_xKi8#-(op%et&Sw1X+C$iu8WpS}H!SF0*ijMAWFn|Io9I(p= z)L?RV0Pmo#JJ=1C8h`Xc6p*XpWCRw?T`V&jdGWCUAcpm0Ec*PAjofQ`lo*cgKi^zLAh2h;#F_Y=Se-M0$>!lutm`;_Z;Btn*M zfgSju`*ai7&{@(bAs|3K+0QjJzJ5t{nZox)A z`1dG*;V}T+_vKd!$08_RpK=d;N|UKENKkb2x&psK%ac=(_i~^~b*AZ#Dw}_KOOz%9 zUohG0vH-pyPVjIKI;C{9*6G;Tw$IT_kobgK*XU$}@kv{~?ijD}VHSAr~ z8;fjsLmDF_?*HWXDK}g^P~LmUG+aw;#k zH_;4QIU!3E=>vCWoYbss-h0qSDaK$-;DT&_>BQcTzHk zir|X1cAeAZiEtXt+ggo9k2bkhS{H!b)4DqU@9$QuZl#`U_qKiQ4WTfr3--YjnWRKf3!og?4rX{f;K z1{jz=GXR7{6aMowF3yXSR~S$)Pk67?x}%cQ0)0f!Ex$3)E1r&_tQ7gVeZw5~+k24T z0msYV*MVQOJRX$zJ#DkWol>-UXYSc~z0o{Bbp`ZN8|zG6u^7JF8%Q1A{P$r0uhvw? z11!*%nzVD-dHapk_ASe4Cuc%&lXhTE^De>KKEj1yV%y(}k))7qxbw41duGcYYK|pD z*vZ(KCqzC&;N*6yGHJ){rz@)&kwpKJz!$~2x-m4H1-F~)D6A0Njlf{V4Cab<0&V0| zNn;SdtA8)uWC=pob;yluwc6dKqJ1!3Bs(S>a@$($kqv%()6)IF@GooyZfPF{+V@RBMt|KUJK21 zhP+}OaZnxK-{f8`IW|!eaN+N3!Nw%Snezj_&109!+^!tL%|hL+$0i4Ts1Vusfynx*2tQee_~c&yhnaQZNnD+* z&=gkU+5T;t8b$C!L<>rRYV(0*tgj?M__xa@^&j%4?T0V94%=p)KUo%F_Wk8jzZ~lz z^JI=r#nh!@r^A_doU7SA|CoKFZ9xos3SlQx%&OEBB8sK#J%z6kx>cg6_9KybBlCRG zJ{vTBJMOMoHO`!IcWOI4rDZyR0b`C9i4KV{wPEcu?a`}<{at2Xm&a)!#?F8 z`+MnZFDtxSoO-KVx#Q|HhbO>_|6v+~9ZoP9z)kTQf%W?de4^I~GrS`0D#NS91zN2j(WN)c8q*fZ~^wYoq=?S}5%hnmMh=6dgq zCqtaCH@11QN0r#NUo&jxa7(pZEEgV5aa!450?QM!)3#AQa%)ZIA~jGroAiAhea9oR zEnz{*TFS^$?47)tGUr!VY{_xVJy*yo{8aZ!ZOWCcWO2^sRxaF#TC4ZS2!X@u_^-{^ zNly)CYdqPMi`p!FIK%}#+m1>sOd{CUiW^)qBI$=GdG4A%p}8>R;d1RUJnB0&T3EU!hI$Nw3AV$f>bmOdotG1xApxbu}IxrJHI{0O9xWuBh?Hm~eyN6dTYHt$7P1c2GVPJPV z$9!MMxGede?nKN)5lCz&Ts9&RF8#4wojhYH3l2wcUm>azr&^KQKX3v_o}30u!dimUx0p$x_05&GYZ&*k#d^XG~A63gnP0lOk4G=^!Ub~ zu(x1oNFctE*6oDLKPwzaU|%mWRSzXUQao7f23e4gP_~(D@metpz7^-*{d$p=+C8H< z-}Tv@HGhj9#yRTcJ~HL`dES!KKS!bRT!f!|<=PYe;^_;$M>6~}MHyTJVOxiXxj%Fk z+o_HC5bn{R>x_HA;lU6&-P;mpJ4X^uyI(GK6)0`)RjY=p4BXgpwwB`Jx_KOAnxRw; z(Z0iX{07oTA-Cw}zB>tbum@~CdTY4&X=7g)0zHk}7q8}RWXs8oe$WZzO%BhZ-|vF$ z3%{tj+AOAn<42!KEvA{0IPGr1K4;r5q|(_J8odN>Gyg*gE)v ziKRDXciA+*dc5{sO@Y|h)$)B(_Eus@eDHvBwNlwh^Vf`Y9-N~U6#d6iP`by;04eTI z+!O`Dwh4&jM9&}ibiO{9zV;aWcy>VLq%cv}fov9l?)3L`esdp^OZE`4(SsX{&0rs7 zhGue4>xuJoO9QZs2MD$Pgk>0UplMj;Dj{ZDGwTL+-Kg!O>jR%&it}o0eP_w*^r*RM zi<+43J5s|htLKLr)q9JE!w;j^GtUC}0_r&&G%y7|p z1@Ay@*Q3tywLVRd1;VqZ4>$-hmocxaX~4VR0K87WL%}1DxnqD}@IUxtLpNY8^~awg zD5w8{N-b-aM*-q{%-=Kme+M*!*q|tBrK3DSW90|k;Ej7p#D$|j!SD!xR-HQi0rMC- z;vV>=dwA;KGyNanB&mUeJU`ABNYGtR=s^IWl*AZAl+~rt`})Hd&Yz!R#XYm%rK|cl zkPvCnP+X%Jc*4T!W7kwyuXRcrjD!x{_IGy~Gt}eGWE`Cwl|}QZ3wj>k-T!ZZ?&-6K zWxEIC)RZDTac1{D9~LIfhb=lVmtY@7*k!b!3Cr1v63b65n>{L9Ku+$n+so&7Z9cBn zdEpApd*WZ$=QfD{=T?dP0*kR;ZV&AqZ5_V|_LxAl$@jiKvz1$+lXkJ9)(IDAKXwQi zG5s(W){pPp8dunB`XmfkPqhf`ZGH@IgRa6=BARr9h6^dpcyPNxL4VLEkih9Flgkva&wq=?W$-9yO8 zyaB0VDVRHyS>n6OoY(-JoBc!Lk-=}EU3yg=BW7=J&jt_%2F`OMn&YkTu`If^l%4Ms zqc+R_XuBM0d9jqHR3SI!>Q@3)e_R+`zP#%681kPb_oO@EIsN9?3B^OCm-3U^fE5to#&?wkgv#(}6f>J>M)mB071t z%In1SirThHSHx|~nSp3uexvFF-6j7a`)q6PK|MpH{VkW&5j6w#$tFo(6XLX2FPwvL z!s?~>O|YKhp!WoMFe=Po%^K6+CfM9W?Nm^wTL+}G{u%uMFS@Pt)Gftb?l0M%*Aebn zRdNGD@yKQ#Y>8Tqg(*|pYyMj&@*3GA^DeQ+i5H3A?be=lfknYYRpF}K`^y9DY9*UJ zyl;qxqmtPV)VDc&TEEmL9s$TuKCgNs|s8xIPOc_|p>CP1Ns&l%{ zwL=PhRma73g6B{5jCSyvJh>tMB16aJ9rOHBW?ePvU9pVrWkt3%ece_@loPgi%6;zD64%Jg$R_%)*=w5oe1}5TJxp} zI~ovw-!Z?v(S*;IC$F-1hWqHwc@+NX;lx#nqVGn^u`wU_#}a5>BB zuoV0O3{#TwXjNQd`Knz}FX#L0o;6<=t5+LYkR-=N1dF89XkGv0mV>>hm4Nszem~i8 zAtg}m#iYL^=8?@$pypoWc$l(+rG z{u1ViJeC7@+SZ^UQt2)Cx@FT#lj8hxmR>I>=4)!|-D}``MarLc((|ge;(B%qWLoNs zNLtAw=OyM^Dr&?7?Q(JA*2dljQ4-L@Gp+N}{W~u`APkO)!zBWkuE&K5?)_qeii|lg z-xqoJ)mbhF3w%-lwwV4I;AGx#gEU_^${a(RHgBX+{6pF`dj~1BW`D8QP@eUBu8ct& z#iXx^MV4XaBHZzxE49booHc5wF6df*)8sqQ%-_jmx`bfQ=jPQOUKzG#zoy$&=UQ!~ z=&+IA^jeTG{?vc1jD>U_hEm^oDDo?{P`JoOwoER0wHFK_y_l?q__N-fI9XvD?98n-*sEv;U0d=h@hIHbH_Udy(DHHciwKs|3AH5 zXn37z_asxpt z7jnx?g%p7j!GzpJ5)|Hx&C<-gpWYAeCw`ab-shfY`=4|E=iKKhT0HZzDlINiGnJjX z(36ph5k89*%eC4+cBvLg??4_bp1Xlr`S%T;~z@Qu$BtF$^7xQkZ!2;+Lwb>OxC@K~}H=kV`wsdt~c zcT=n*s~Q7V7cbVLgb5PEZAdxnB-%#X#5?RA-D0pYLDG-!!Z~MRwZNjiBJjlIhLILC zefeiaeKRz2su5!(W+RzqJ=t2H4L1dmysfu|fSNkKoLvmf(}XH3a0c894)G>r-OOik zD35Cnl{=qbDqwQNcYI<({~k=yF>Yt~C6pD4(6X@6CI$BBZ6cV-Q zthS8P4`$c)y!gz-Ne%nxc5%S-!&qobcDk7{Q(6b7FQ-UuQV&RlNV}a^qXm7XXg#58|s9 zaF*7#U=hVfL$mD#Lyiih)|>doT~6B-*d=LNqpNBF)X2FMozpyt65gu{weh^{os${5 z4Xf%|%BqPqJdqj6JWPJ^}J`4sA@p7Pu?_$*XkWkbxlzdI*aj~lkd-M#8meH(UE8w+(S23?nB zU6wO52UR5JwGkFUk0`(Ner_qlV6s>S;AE0A-v{O}?pKA(3|Gdg-sT(`i!mX2>u2+J z!Cn^fy8F{yclX5X=^lMJBAw`0g7Ofa8$}7vrheOavGFzJ_kER|us}B-h&6XLFxmI; zg)#OuQEN!V2)Dwi9c0Mx+P^493I!#6-j($zxL;sJcdW=gF-98DT62~E3t%lJu6~2) z*#4_s4H5kivl)_od?vWVM_~_O)wu&!U5ybIq4uXa$(aT`=vN{O=sebV_7F+%spoKv zfxO!H(;i!8Wc>eQH+XjqM1xy03g39qFEdDLDJ~kN;-7%QW9a`|Ij+!z$f@$BP40~> zsPUBoRSX8QsGYdv4za96ocZx!;?57{_XnV-9z45lsQ3bH1d!#c{>{wFr!gJmpAhsK z>|RD#gnJk&Roc{P7Nn?^%uM-~8Ze^iq{y16;b-5?IRbv_RcT=Q8|Dd1qXabPQEVptPx&UaFr9{< zPAO6ch7=ERdv?VG(*DQVk00^4GG`RBI4K;xJL{h!BKMkwicVueXqS$sgWu&rClS=U#DVFKl`G zEpPjy)(3S{%&RT66W_^v-u9n5rCx%&aRjT4FVUa5%Yw-0?{btLQ7(R%k&%#jUB7D*O4QH26gM)+P)t#7C{|d}K$XDKZI*rh!5u4sSX9rKbCbYyjA9(Zf!D6ve)7Ixs(4INQ zlG}*~RVpEkP+83A1jyYlPYhxoev9pO#IuASp6C?F&2seePIC6Gr@hN1 zl?gOHJVUe<1jXU$ZO5|7dhDfJ6rAUW*7LD?Fef23q|?JX1`uJNo|X$!o3v$=vF`=e>NJXyCMFE=3gH^ST9>tp8); zMyvDTE*8NhUH}O4_7Dui zEF(4fUjg z7g|!{GAb?3oO6AM_^9@%cC9)b3k~e^galZP{TP0i2-VR*&E#syMgZG5q15ihCqgpq zD}y$$PO9YcL`|=8ECG*QN`G-lgny zR}N2rp*RGkW*+88;?uO;K4TXHXmh+x-oCcPon*r-{%{509rQ8a;M}X4D4+fZ zu^zrb7Ov(VpfFNwS#2Yuwkvem(CLJdx`FtfxPNxNTUSEP$$Rz!viSu1I!^=vn8q3s zV!RGd##J0i6_(yL5CkZjYTh|DU%b=KA@eRQL?`pm!lmM!J!Rm`xxmD3=e-<|k3)l5 z;Q`qGjEPKqATeR&)=yVDr(j3N+z-H`9eAu1mxVgNi1-@nb^1>4u|_Vd+tX}pOXQWa1YI1; z>%eC=ubyg!!J1wF)+iH`>2b&{!UQa6ZBN6)@^a^_+6y4ahS;$UK9BF1JZ*`FSr)BX zTx|$@j>Ixll@(1Tzt%TOdbGJlz4fJ_4YL*boFaYeuH_U-R^fdtJo>;wR>-D9>V_2 zcz&O>V#V^lHxs2jq;oZfKT1RzZpG((m?*>oHW^U*HsxaO z=jY#8pFrGR@@c?Sz*SGwnzI*hKQ2#pYh0mQR1%V%)t+jDf3ssmO!3@D!o)>+&UA}f z;*nxVL&p5vyDF1)MJ$}|px-wJQ=c~X(f$J-)h<#dl%$A|bZayd>=;B(%_illEG65Bp?-`Kj*XAHz@{Piu}hz74`}+u{d|~KEg$Ax>OG^ z<*&EyseJiKshRWzB(a~wpqjT4wkduNtcGIe@aAnFgyy+w#G@ia|3c~q8O{^ApM=@bu~6e*cqL2_#G1{9wt3C zqcf5$f8a3u&gd#R{>u%g*A@MSZ&&>`nIi73zfzLdXB&nEVEnT#4dZohX$M(7xkz!A zE2{vrnQBuD>Cp(c&^h6fLAGOEIWEIWw{m97;<>cOvUblgmz*y{y8B%qX%0@=R@xJZ zA_8&rW4e_m*pfY2yJO#s*7&-;qgR5Tk-yFO0Clw4DZU8WxuMU0auYf_7WEzF0!J%H zf}Ra)jAvr9QEs(cHrD%DYWQ9paLYrI=;L9P#!+6svj`pA)*kNR)}K`w)G%#aj;kzp zuVo^EiD9|YhySGBiDziKTvEW&!7S#;J#sT^LfDhUd+k;Ouf>-1z`2(4P(R01`JSf zI@jb`DztiAxu<1)=EutejWa#U>{6oHN$p+ypBD#8m)_$uT((lHdv)m8SQ}p_@}+x7 zEWLf!v8t&G)eoKdnA{N?>oK3+X)X^nYwl(yMOG{}dsJ zo;?_;ljQv&zS~G>+;2}a>eE-QLmoxn7WkAz-y2|!OsL(T8``2jL7-)%SoA+|0Sz7J z`;zw~uWmI4t@oFaSyzAVjEUpCxODPjBbE`g0h_-0bipy{dfHqPP-CtlU7f(9TDe{? zEnG)h<+ajU$G*d9gbd(2N#MUCBTJ4^nt?R3amCd@k2&xa1Ja|llB5uDP6`3ZE7G60 zu^QtvC`mJN)K~a>=|d6jUQ+&nA@!47pW90~xgmreh=eHm{Je+Yxy*HzzW&OmPHw*YR%G15e~~WZo&T7H2Zy@#L=5HAY)C>Ep3on6O_ zW+sk7ZvcaK`ZcC2Kg^k)>thIBF3$C_J(WW|HUX;BP7xf-v$Ykf`Y6`=N70PB>i_JhlFRzZi z7&;skcW;;NpnpjuzS^KJ*5Kw+OT;NOPlGsofu^5Xi0!WQH2Tmwz!Or(#9IUp=U@HZ8XG*2eU8-H$` zuJtI5Z#lD$Zq{&&oAjM#7tSKU*bENK?c&TgjMS30N6(jg@0(kwhToo%!x}&8up|b2 zcT>T+(rR&|)=2cb=Zk!x(Y+>K`Uv;m8&YyEd7IXb)6--Q`4+yC#$KfpY?-m+s)6)m9rRtcT$rw#x#6Yfs0bAY`S_NfglTYo6 zRH@5^p$*00Cy_&vfI;m{xHGs;h}TRG%ZFP{JiqeU35fcU0aijfcyA5(t;s;u317?v zGrZ(4NcTz`KDn})pON)N{3agxd?ZOP#FACcJ=^6D8n{RcWOvnGYTU|d{*~9<%$sDH zvF|~VyD3(&H{O=%2KS>}@=k!jwcF3yPC3y|J}Z;EayR))9nu`LM# zj*}c-w|(%WpJ^GDMrDL$wj?fxD)!x3p5aZ$-JS^*3C^S3!od5jnzN@V zs7rmAbGHl;LjoXmpf!KwXSq3zYx)sE78Lf5UUyDdZKG?j65pNKw)wEWC9%kM!t{rW zdKBseO{=#vind0-KK60Uc=E@0wV}=f3)i~$o==NBABkKg^I7(f8F8fla+;5ebtOSv zr7|*OeJwkc@J2V0w2wEpP?T5gH}uvgV%a?ySK0wST8!Cp+6KOqE=?W;{Hu`@uW;(s z#t+aqjaD)<9ww>^!Zduur|S|=9M_ciHHT1nl5ivV`%<5{L4MECS7;uYC@9f< zSL6-KlllC)kp|l0mQbG{4W`Nrox^cUKAM3aP)$n;#bmy&%{8&2XZDQw%R+@-;= z`*G`vGFLcHz={X`3L}|`g8caJ zu1kT_Q#;yQipIJ?R%2zal`?+?iMgef!>KDsv?vtvDjUE}OYidCjQ+xHqB$7e-?Yfd zjdAt>oveQ@)f50`Jx+LWG`zU0h%w9!*s-EbPE*GpqX=g~I>yWMeUoDlNvxnombu`RaU3{&Az^L<>JT%9j-(yu%$~xo)YTOtI}?kK2U_Y8#9L#QN8>Wc8Zqai63h;0O9~`T7w=B5m)WvL%5A3zJ-oNJ?u-N>z|xkjsUUO%iO= zMJ2x4(nscJu~=S}1)H9ACnns<_e_$G%u2<191c8Y35|K!et_T7Gp%dv!4^a5i)5|2W*ze#?>l$p`kge0aZ1m zp*aenp*doA@;GouBr%%|{5kT-5TZg;-ov&Ce4%wz)={RRsf<0nZ*vUzPWKRM{)mR= z+(+vFBkk@*b~H4Q0S#4UO;msPCB18b3P8s4Yn;FChS{^ws^!sM~^Zv0-wO zjv%sg44F{m8MPVBCi&cr3B#bS*8FZgF6oA4d=BkEK$vR$kiIdOPRiU-F0nV3jIZ11 zEh{=R!!TFJzSBfy9=oOgnEPE>Vvz3EVo=W`y)6=MOLwM4p|y}`=D*kEGlSdO-}LMy zbw+_&7Le0h%u{xf9!fiHa?7!lpHj<(g6@x$D08uO%}Nx#$zI*%Bfx+zpKaZS9Y*i{ zfbA6Rbx{roU;L3%nQ}gpr$J3$KSj1s4p!tqkX|K9QzN+-GdZ)(8T@gIGJmLC#SOgu zq;hqQ(y!zhyFYjv1pR)SM}x^+g7I({zp|^cB9RP~p!wWWc-VNp5m)=bUpXy8IFJ+w z)3`(nJh%Z0I9v&w2wY2_@k}f{{Myh+>77cyN`3T@BTm#25vkMUME0lrov26I;?!_B3f#$%Ra`>_x9My@3Ix7AVcU_SXo!3y`%t2?JqIbocILUlD!-M~ z{bsv#;5p6hykxe65flf)EURgxgK6eaHf!d9xY8JYsMh0tID(o|%K9OIqc(q;rwqS9 zCR8+h!7Np>iFtZC*)SxCp_98irhmV%y?1e!zr+> z9@t)0<9s3v5lndxYH$zULu*H5JR+*^%3c!A2D zcW+0mf3=-?m`%y8=uYNM-KQLI?&IvHeQCkKN-R82d2Rm*>ckI$iQyOEoZESO*cD*x zC7io)d)Z36TFY?GU|2Qcf_TZn=5R$_c*|a6nRtnYW$*$_aLvv{SoMxULa@_m(Rt6DKAq&&y zcGNO>SGL!3t-6_8uH%WdaJ+OgJ0Ew%t-5w7HXiQ`rZw0*NU z;1I{r&OC#EEmzy8{Ie z%szcqk9(y*J%YNa9H-(Z#Jl&(Hj8bX%=lWtO1$PQb%UI|&qpDSqN~>KK#|j%y<3wr zyI&5LgDI1}4cYr;K8>}(JDmRDo6Uh*R;G=lpr6lBlTo0iH*eTF^EmQuf0#SHh8OBc zZo@d%{>5|sX`ksB#y*VewvF7-TQcLO7Wso~Ca7ZLrDwyGV~jXz0vOk2IbjwH^|*=7 z{#=-kS;dqm=XT}{qIJ8EdGbW?%A@7ml$nagDA-P^|Nd%;HS&Xf%^nX)bz8du)8b1L+xbhm{%5(OH>EJ2hBnzJFd$J2}d^XSN()p>!#qG64$YNz83cbYlXx@I7j9btY(NPJtkxmTey3)4Tc;|K-8|r`rC@OFgk{?4EA_lD1)M3aD2vB}R*)4Na`@e^4bMFBJmUy6_A>-fL^QoMKkiL!?} z!Y=Jv*5y3EU&l}u+WJLLf<3yi1(r}TVKnKDRgYAfR zu`PZ3D3!zf_*UH-UTCUz3O*~un05DbTfoyV)%jSN8k13VD+McSUYLuaIv*OIZR6ih zwqd>ZRXPxLbyFb`TA`rCICI?~XtiN6<1J-ErXf#u!x0+}r*Ue#h8Kcr-&ti?N8Ot3 zENiKA=n?^I*7OgB>$r-PtSt=?iPw2%^+;w3 z^1L>r4Xt6icdL7Q2qr8~j=a4D?mEoeDW}`o`Xbi~#~HA1G&7Uo3Nz)qv9?J);Bya6 z&!{6q7Yf>=$-QF)TC+uq$2nD)j9o@e%Pay$G|6=G_2;X>e+R5CFkr#(P; zq5-4V-0bB0fMG2>Eo(N+rHu*k$0d@Ynxsv3vT6_0#^$N4{nx?CQSZ zf~2>t^|pR}ke$_F^yCOvw%U$%0U%xPI}HGy@cAr4TGvj+k5n@{$?D#84V`wQRw6Q3XdY}L)6N;pEUsvh zH`2ymdp8LVbIjzPZT@QAl0;RCRRdaIPO2tON(~5i3 z)mw!9=RmjnN~rK8#=m{nAF_xL&RZVIlG7x-Ka+`ff)A|M`TuM=-=HP?cJ%fI**j?- zIWnzpN#v2{LP z_U}{3>Uw7Igf00s8BfNAMWu?5JG~4&Amsd1`qCFJ=8j<{{#dsD>D>C}t|gKjU&JYy zzT0X{FhL!e4eAmPSO{{!#ffJ(2yM8f-~4h13#|?`5ciqDycllbQTS2Na^~i0NgLL|_H3N90;RA^1e4cKAhu$BF%x zNO6Ut0lc9ppH6{pO7Pmi2`p*vgJx{f2Er)08C1=XKiUcK`@ogpcY(Kv%(k8FcZOmn zj1E42RYtyu)2PjO$x09kcu&BuJ&@=OKV^QjW?M6`bRDrEMA@2IrV%zwe=M}S>dk6(r z?_wAV{VU0YC`4Kn(huIgfHMloUwTLF?!_0?d}Bqap}8jMN0lmZWS2%byRcbAXa3Yx zE7OPjn1(ZB`IJ<7b{)bTJlrX9y3e#yPijSr+Y#(V!gGJXEtsjUP{nUu7{Y&Ia9>I= z!5FH)0lx4>GoBu^)U~wvIw3bnbxc}}Zx?1In6y?Ra|~VO1>bI{pIo?Dbl$pwCAP4# zg^2r@U#Ige18b1J7wwrR_$`2^Vo48WhAUeNm2gjLx+$4RC#To4=jiw~V!E*5yMe1YLqod61HlpY-=bcf4=PMf;7!OA&+f+K zNNMEPXHdiP`E0{hrdpAZRK->#>|hm#s2AndDXA+h*iEkwIKkRhpOX7VN0w}^O09zbhi|Fx*QY1s(ehNhwSN5Vm=eoW{i752NF9y_ ziizmn;$u{_MZcM^oNHbQtPo5tR0cEwlnX+ueD&g{{0KnW-bDV_Z*I@o(46Np4%!w} zx5;9`I`x%qtGVHXUYqbYN}i9^88-drPMl|6nxQ=T{zZ}glM8_4SE#Ba{u?%)lDxnL zTFZO2U1Luv8D7H|>5MnEp5FD~R-EYSlw{=hTH>f`xYdZ*{{qru`Xuu4?^oYr4R+3y z^Zf9>smftd&UQUTj4v<1B`5-esCx40i>lGoCh7U?@Nyl)r|&kswr2qnntl>7bO)0_ zUoUO4;fntx|Cbny6fbF#79>8gTrN-1*_9?G;XJ0s4{A1uDhh<@3a(Xo$@?3>-u7$( zgTWr79?j1Q19QjbQX0x9CiBHk)5` z>M~-Wi)Z8yxnnc7cIO#ern{Y_-wpvaHPz^IWCR&-i6E7_TWawf1TpV$F=%;_P|@(X zKEl+sbTUG|s9LQ{-G)z9Ri}(*$8*Np5@woOJj^S`7v+WnaKhIl&vdFzBK?lnpPKQ( zOK6Q#_pCY`B{$4#+(OwFi$3KFt8x%S+trQCMY(snhL`!174Lu(B$ZEIhsT~)t_H-! zH7<7zn4H_T82~WLmMy0XRwRjJV?Xkp z#ly4-!HCU2Qv>K#r)VhBjuGZLe4CgN&t@MFn=@MMw{NU#qQr1(Y^f(W^`15m;#Cr* zCl3bBbNi}ghU}BgO$1w>HoYJYSQzcm_A3H{sf_HA?O-?)186!1T8TP91Rbgop4Y@E zwvq}^Hyjz_|G?F?mwkNQ#AAfyLko6!|4nyid>pI|JZs;3;O{@fj`MW-wJAVA-@3U7 zP}Q850ik8hxgyQU_t@$d?XV>FVYpzCt6a3bAALTj_Sjb`& zFXZWr{V}+C=#e~KTz0!wYTSFT-3v!&THiZQkB{Jrum<~ z5o0t|LmDi6(2)NbKZv=MnAxy7`X!oeQKNZ@FW40E=3ygUW2Q+_iF?peo4U6D6u#aH=LK-T!!#BJe6F^XhIN3 z9c#Ki(L>vYHH3;1uXpul$#v|W61`L9B0Xh9$lWYW3R{ovt6he`A$v?U3Xa3BbXN#a zaZcw{p{;yI^-To%Mr7WsaE^fUwm(+npnZ`C zA{?Iz7oCW$5<|n1)9q&W?)H0E!t5;!dRI;(_?mW!CCgd<%N}OsmZ_bI(w+WGw;Jf@W))b{7Wwo5%AA_Lz`fHKFT_cA&*Nvp%8oOADY-#$d(+MUX-2((VOE2 zndr_h-sIp{FlT8FdF!>(Z0{E*1CoCoPz)NhmVVx_j%X`k%u{r-1P>$zddQ2Yp8C-B zf{_-7eDJYAEFs%3lg%gv_2IOcX@Q2H=UucS$$RRu8+!yTS|ghF?@6$_6UF8`q#1kZ zv~QW2v75EwEk<9oq}L*O0@*fl+TL){i?HF{n=m88*xoO&a(jZ$2UzgV-s4m6AyaEbR7AnW@7=5A^R7!_PW?K)LVm#+6~q z7a>d=v8tFu8?BH9&~Fk2oq23E<&?=J4)5&lCp2qRDP)>5+J*_#SYfgm^3+iM_Vf7= z68a+N22Rs20Kf`Kh0@Cx7kB4Rk9hYQ(8sH0W$%A{VW7L?NM`wFoxT11hstF`poiF{ z=&Gqy)+#CE`i$+*OGJ+1aZHzgqMg5EA%=qY9uInsAD4VXKO3L>x!q%|gEKasGO#Wm ze(-nM&VyzgiWkGh^%k}<^KXj^(GoJ0pnJ4`n{YH_3*j|_dqpwqA*8t|nl~7gF zn1tSh+sKG-|FSWTBZf?n4JS3$-L7wNjqA_f_j0~^#orio-5Tu<;-7PV-MkNWmi#eZ z-A4h`%IR{_Dpy3?AKQhFinP**3o}efuv4rlY=kPq-3aC^eeqsVd;SULLF*LMbj5da z_HwTGRmPj3mEglwo`~f?W(R(v#`TeV+_8nzJ6F4Yf&3X5vMO#kC%}Xcc5F28ANF^>c>E`Y z%`w~McnHUl|ERX{li&e^NQhCyg=z+lNQ}L!s;w~CMeehF6W{K0okoMZRRb5uE}ymO zMX7v+YBF+Hnj?(;JF(B9$`G(cr+rOdR#|TCnqLg6N!-fVYlB~Hu$<%V3T!L>ij;Dx z_%AYZlIHoR;~fU#3_FAzrtEYG5_|ttB%G+MW^!-wcf4zN0&}Vs4SdpK$G7TEobIGQ<^^q$5r#FAz zj@)<8mOq?!X!MPT-6hwK;og&rS&TSc|8hnt~lQN!`#`T z44L!U!9m>WVvxh|^NC7HbzPh{jg^WPf{iP?K?tT#J7@opxDJL&Vq9->1ah+1As#H3 zEFNu!#E<6X!h+9Ls(?j;DsPf2u99*#Btp2l-pqS2%jhD{6%c}76`Y8K-#v8dg~Lhp zyy=RFf7->>xa3VlC8bo53PEt%OMZjHV(GHgiPKG%BBA{kqMgs#_9$=Z8 z_=!FRKOF2#!O0clbf2)HJ6hi6Ui95LS1}zsUh)^MK^S$MfF`IBpLCs&b{?A z#B&D(jFI;wu%rO92ejMFS(`SYzUl_vtqa3t*VJ%4dJZPSCz%jQ;Q`D&9R_#IA<+?Y z)K%AI&RdSa3&CDThPE^5@`ysN3y#WzMRa?1_mAO=BHo0`gJQU3z`sGah;LGLicjIN zvEUXqMvOT!ws^MFu5$z|=y?}xIP;d)Mn%y~+4GW30D{lZXstEc^4fZ5Qq<57Vsu_q zM`*As55N9+WCo=sO~HAsrK{@teFw8#2RHKe>tzN0!~h=}ZV1?)}Q;r!T) zHUhNe`A#pd0VlbhvA@67<^si$4>Er`H<1@?V8~-&Wg#{2onMVTZ%XfR{3-ah-&w&q z?Srb)Xe(Ko zo`#|HtR}Bh7Nvo)yavX?b92)tx_IaM!ClDgT`C6j{Q>n|Kr^T>Wjs_$n2fWUPUpi7 zI$xEoK^VKCNRChr1fF-&l)i%!Ttb=cC}!wqqxL+$H$_cR3GI0Vs3&LicV~DP&Zj1w zyq`?nuIzhq>T9>CHq`Y$W*8UbQ}JzTC(ufx?nnQ5EiTt1gpHx(+>T}Bf~PW(hr0I} z|F!qycQeF3NcGAmYS;d4M*YGCyk3#g{J+|4(bogBkF^U1p?G8LhB?fKLdV zz#003@U85yoxOKAyn_rI8);qXCz-w7h%M-J4Z4@GAo~x_&UM67Oopv$Sry`g6F(j@ zx^Zir#&RN5OaD{kbBDoWj`7D?S`GxE)2;VwiY9?U`T<54;CTa#K(`3C&Sws%k6h~| z2ot_0M5qP%YF>h^_YOfM3CH*>yH4@M*H$))dhvC?&^mmD>$AQyxr*9Rxl4GVXRvjD zBXQd~^=%+@;RJD{COd2*{4_JD*gH=LAMvkFI&>KDscI$#M9|yi(37L)*OOGf*V@vF zEIIi4);Gmy-uJAxyfYBOq=Eu{Hex{%Vc{%Mv3HQgFX2i_o&5R)weL`RM=F+3p%#B) ztK6z_uH3r$6xE%0qnPt)HEN)VKIym(f3A(3#dVRNq%S2#Z?7;ZT$32emd&C^d-4XM zH+c<``bc&gKJmOcGgL_cmXs&49}8;zu{_;;`ahn@sv`)##9(?_oo*NXLGQ8!&myLL zS9B^+Po(FRgVkPwwJ3zPKlqPIh^Lel3J5>(;{NT`3;-t#C#Gtt?UD!OS^9D0n_iKn zXx-^iZFU85kYqz_1I9D23$5i6Lz1;L)#~C@&anDNw*Us0Iuc)iA39JR&aX+blXmm( z*B8|*Ro0Ga>D)g$fphIaT7XFMc zgzbIbFX;w61b~yFi1)-G;-ar#Tn9|5qyPD%rWH)>)|3Zs{VY}2=Aiy;)-n}=O9?M> z6>4j0=8=ZJR&kp-9@ zmcH!ckeEB(O1n4DS?!`rq~f-g|L1nptazyol9pct;(wPIOtCRhH+3sEd0OVbl6mp! z_$Ebns#-`+p@`VsflB6WJ(U!x8{Z{q{?*Um?{ZK1v}lcH6P(lRls&j|Rk+Jaha ziPTN-Ap7#7@l2&^fn12$oCg9N(#9dB$Nh zbW0c8_1$VrZ+F=3#uB!NTl#{h z>WIWJNwqY{C@jtl7C1chs8?aRD_K;^H$lL+qKT}Tg#3qXxF+Xv2^d&BFP+mqPaX}E z-}c+BnI8G@b>*001w6apEa;X!>se8AFWag;`WTJc(JD)qFE_hUD_js8m37*F{Q;(~ zi%fGiGW98mF2Y8JEt*@24SCEYLC~;<)ph4y?YV;P8wv`TDpJmjORJ;`45KqV@=bU3 z=CA|TkGEn>7$Z1lu^oj5n}#f#q29I|)Cw=G8#hnPTDtjs{hyj)*Dixa_+Dq=tIyVIEx-e57VsH)jgCBx}EYlnO7?Qv#p50`ZW#5it|2KObzmybYjW# zrSGKkM`OBEhMhatEziu2)CNBH7?TW=8+M2aa`WddgST$ZH0)y*Z5nwk;?h1r|B>sk znl1zSBce1G^h9giF1%|rKYzf!5&QBedh?Pv3T9Da)$Q`i0QsR+(#IaG}Xq$&1AIws&sB!rA{=cTYG|i%Rp0M^0*OgoETr{rY-Jn-VA0(rg`~B zIEnvc$Pl0U2O&a-Qhv)m-Qw%e1BrIu^JqAejuKm{Cd}k2?(j!t$4vQK4%x~c>V6s# zgzPlS5dx(}Dw>A)aS6<2;wGqq9Ig8e0YopZBabT z1=2ekm6!`Z?G$P+#J*fll>vdK^=w<}c} zU{vatgCSiEd6V>M7(fqx+wslO>&r3{p%T$jRQ_R2fcEqzJPuLPm*sQUDU81s+5~uB zEHAr(#V&hQ{DrPgTc3~^ud1?2T7z0!&8&4L^W<|VJ1=#TQg35fdLVcceOkqOv{&aENCF?!-^_KNIK>8L6>$vZ( zK&oK=+8;5RddQOUtfq3g9tDY;V87zWlEK@x{-u5i+ST)%2322dE?pG7!8-CV10n$N(#sz`|4K|n;mt7Y`Dt(B=}ubbK|?}7akjRP z58-`z>H;6<6VdoI&+fXM`TAqdlBB(AdM~DW4fo*(MoQ*IGT-N`+k69K_P*zf=pnN^ z9$NQUmbewQDhqdNf;$!DqlAUNe z{nOeK?`1T@RB?z-XtI>3l|AlQ&<`EbC;`1bLXuf$(kN6#ZF&t6yqs%o8jV;5Dllj7 z_y5r$2+~ANy==$P^{~G^n=-eMc7{@kFAE#aFye(1gp_u|B>ifHuWMk0%vd844c$e7 z?Ek5p7cSk^ANRT0?Y~uvKz!%${~@u}!5d3c%Kk6w*UrRo4w2I0#-A?PiG@|6J9S6b zm~6WF#`7&Npy|wE6WCfu(D<0&O(NOT59d9|RY`4YEN#ak_Y_ zAgC(tK!hke6K6Hra<0>eAVuDKaB77o+|?{ax!fN=Z9cgn!Da30 zuJgx+-C;IuEI7z7ZVW@{ef))$2cmhiDKxY_be$W*Jb7$mX~N{f_fUFr$DKJ-ecZ^Uj~N`{mHhSgo9p2a^pM#UHi#65hTIT`+v<7i6KHmA};GdKbcz z6d%07G@0KVSB{H-MH%Y^C^Tk<`Iegvd*xhJK6B&GNJ@2)E&`fLk^Ii=(nMy)9Qxgd9FVO;MkvKW-JFV?a!yohXi~Cok)|AAlj4Rpa@UQ`HH_eeUGweL zQ0&VfyU2s0z+kH7I-FH1kiR9&fwglbPG5mD?->mNy)@?nS zKaRO=%`FyxYa7A7#mjqeXg8F!6&jc@im|Mo`l%QmoM;Qe$W?J8E_~vSVu{!HCDN^} z#tfh@zHC|!%exV#PeQ5iO%0VP4o71j2!2j_kjVA?Zz^xf58;eVZ3|?ETNm>Q_f+_c*2##b) zH;XadyA!wy#~nDr#K~6JSFPN7CDk@KnCG(!!YllPo?BV7>8DL z+lbZ!9M(lS#mzkAviBd(Zyw3kX;%muO-h6*euJRGa{?Nzj1+mqnShvQO}#d(xZE;@ z9Q@5>u$&VTuP8<_)Dw@juUgf=DON3&=wtlITxX;9XAs{^_IEG9w(bhl#JAxdP0xRB zm>Q=CpVD$x$@IiC;;~fUT7ULTJN!#|-vux1#n<$v#(5dmtNlA(75qQEFIh)?csZBE zA8{o7mkk=jv@@0Ct3<;RH*;H)(qo{Q8osi!P;f8xBz8U8H_^U0K&TLlvL9gbueU;R zSj%o)9PU1LZ)w^eTTNEv(8$(}T5qyUU2YSMH^pR%D9K?S-u5m$aPkY5{1614Sq$vz zQftp~XmnG$O;$fFMUrpuK5!kN*cNb60tK zwf)=WroVwwhq-rP+aH({Hja$^s>AUA+FSU)LuFPj2st-`*q$xvlD)~GwiYXFJptdl z9c8sA7_s&ncK|5Ae;W^t)c#&?x9F6Rd)0kz^vzO^D}~lW@wZcvYGh}zUDu37K1ZZ} z{n6w8Dx&-Va0XI6N56fnz6iea!Na39IgUw-TGna`zAu8}Q9*U3egb!ml~j!=I^UC8 zr>C0ZYVMXwqbsfJUecwNvyveD^b^G~zP9`khkquodvOun7C^Y!;pv^o9c3_uON8U$wBBHF7T&r1*c4Bny3q5LKp zt2`7})Cp{XfcTS^8({Ei2@2io^rAa4_xnJC&k4|JH~~7WrP`8^*yZlhKDP)@lr=+s zKj`>~$%Y*KyB5@S>)?Y8eCV-0W-?3CaPE+!blY5KJTsjbmnI`mXUAHXNs7f0v`U_> zw@CpynAZ|9E_x-s*eqHwDYLmW#e$&!S%Ct)9Ob*i$HYt-hSJA!FE>i`;5@{s>>27f97)_ovjQp z@LyFUk7OKC>M+_Tni7tpcH-#zB#VsUE4`qvSViY0|QiTQWCCwA|D${l~YGAU* zcWTjPz8fDmS)NgC?LTQ`k6AEmVO8oPoq}yp=q=epF z`Wy!&AmSeUw=O7S4K`PhlScSKf@DI2S%GC>ry6fVlih?6MQrK9)}izUJ0HL0@{0l1 z-4dMm05F4^`kun2=HSZVK5Yi3Ta2W$KMTj*HM z)aW3a{WBjAi{@Sh$=H}=H{1LPTcM`$(Cifs{^K=?2t{=G&BM8+oG&&WgFkduV222*04Ws@owbx28ko9&o<+6NDm1e7M@GTA;hVcd3xHd&-pz=lkn zT|x35$C!*IjzFqX@~tdg9%7?iBO5mB#G5%?&$NP8J?g6E^0IYbZYi3?=Eu)`EaqT( z8`ArO2+O9$}H3P03Bxp~qruvq~^kcN{FWhLTNk4fcfy=FOmz z=<=1pV#U3ZKM@g7hk=5Pmk6Y6p_CC{WH$-;;Oeq=UJR%IJW^6c_S<;)}j7qDfqU;)hzv9=6n}S*_rh-mNk-XF7*IHaTgZwo(8;heU`71ol zey|rerS=NR9~?e<=#9pLY)$v9KTU4&yEh&_?%}+B*!R`t%udi~jA*iTb*6m5+;$R< z>^_WKPvwAII=h#+e-*^?n_^nyH5TNPIY(jwjzfeXE=MC;#@3n;Jmf@`mv_ibj|b3Q zJ&G4$I?&W7gbg=Vk-Un}$tgRwqJmPv6|t#F*yp6nb2TMP;#Y=Mtue55vvLu&gT=l; zX0N&BY?cb}a_26&;mV}jOCY2l@z`y3)$-vPduVEb2ciAZ=9*D8`)8IK_-7_Ku8H1- z4HG?{JzjbBZ=@=RwJz)G>LxHQ$H!>zTY<5ez|JZ<=9_*YQc1RD@uei&(HGZ=u zG2r|5&?czg{*0PxJNNZ+{2jiu32Q!3?9h$5m0seX=ouE!KQM)a&d7^=3#>a|A~Ud# z`ifD1gL&}m;hv(z&d`gHBzRJ;OybLP*OA6oB{5Eo!l0H+gCR^V`aR_QJV6C7D?3oXBp>fz<$AK!HK#|?|E`i4JNDsSQdlb-xHj-YKD zpB=LOlj+Cn{gA0-QL{^oyf$n2j^Lf8P8FTF{!jM=pVhf3Mz)diK*7@5LT$ww(hvvR z4oa76f8}E35JBVW!17TKVOAe=J=nRfJ<-^hWxY)$-}*9V&L2xMK!@hJSH8JT$tzg1 zlq|t=u$S>Q50sEDmb*8IPA)iTEhTy}2(BP^PQ!I@{?DxZ_OUNwY3EK)>vf@U_I{q7X`wBI$+FILQNTRDYZ`K;{S6S!K8I$VI1?DY{*c!_%go^-Q@vj6nsB$tU96k>KNwjorh-#drWa#P2QG<6(Z@Jjk}mNz>)XB+tMWK(2cL1 zJIsRg_u^2y@q~?GDduguaiquZJqyHHZy_VVfYsXlFY0t(mkXl$=$EqiZ^2cdjx&bH z^8H282Z)*LG?#AxssGXO%3bjRi`Kp)N8Y`orjTLExyl-AQ@3s@IRJU-bVr32L7#n&ib@<^x@9aHGni48AlBi-q z;O*~!pWdy%L9z;JR7Q^U#I+xPBmb-2vvIirZ9C-EomSpkh+QwNf+f`*+&1MdWK zw_i9)t`G1+H+kl-X@Vm`-N(M|{?s#ePRy<|PU;piEhWoNOPY>Vsr8+U_w zg6|a1^cIl{0#Dollr=E2?zmj?Va^2}=2X>C$?U{%@8hrg7!#rl_;rO#tBnWN3S`HiQd~1h_@Ts)97<8`)QNvv}4z}sL>$akEqu>_>`m~Cfqalj5FBC5>p}C^ zU%%$#2qMX#^oJ@rUPE=aVwm_Tcdja}nw_sBDzg0U2AEQ1q3QHv%y1E-JuQP8xW`0| z@I(Wte00{ghVly#CW8FT-QO7|Itc1hc?Y(Q%VjnMXjcgq-Ysjlq$rJ{82jZ!Pnn6A zyubB(*^q2y=@Y};Pu5NYOKA&aXGKyTQ5HD(Xns}91*s{*!Z}Q1Xs-Sdg#Kiuz_!zj zG-p_;>)*je00Q&fbbs^QUzywBAK=N0z|-fSxCkd{dYFMXfdKwVnvrwAy&^}S)I6gm z08m>XQCAfR{nOl$`(G8iT*jvG^W9&|K=c;mcA4CgKdpBeNI3gd=ka3*PsLxEj?tz- zZI6D%yQsO2-hbk5fai(Y6I!5US86sr;J-QYZsOM)|F6X)ZUBYXG>CHVL)ebo;yj}X z9E3>&YXAS0S09J)^z$Q9fgFf!5wilw8kZSlA58vSaVG}qdF*YX_m+Js8iA8^#d{;Q zS)mLb0RbOdThUw1=YTQn-g}7PV=9F913}2y>BqBYeYTdZF4K{NrU7Np+6t%D5YUa! zNlbK{T&ieY4qDIUd=HJ=S{rwC-`VnEP*$P#{kR~cs_G};KH?WNyL>`WP%y~;+Qy-) zoxQT1$o2XUH@2s@boaaN5fAi$X#z&Ai^LB^?N>-%M}N2xw`FD;Yq-*?F5bNVGh9hq zxA@vQ{6R9n!S}L(lR-}uFXyr43m9K!CMylOn>q85dLoduL1A zG56JZAvHXY7Q6-Imaq8-)=7EDh0C-a!53YkP7e*Z_#^f+5;r~#B>vqbmC~vT7!9M` zR@jR7JtTs_7DxY6jyIz38#0|%qaVRnfi=leaVeGW7Uk^neUbcJsO;72iUW2HEKzJ| zbeE->O~Y@811}0q2nq~tgkpu3I{i7jbSHb-H~jDBg(VjjP5o$Yw$2+)o}r;i+?5Uk zc1;;Fmgi=7gf?_Egt5lJNYRZMsuY9(+%s2p$%(Ht+{Z^7SZaYRA|~3l(*~PabN7uR z2fiVjT9!9!W4>HCYFrTcYESXSmMJQ6eS+M!`3rKLxFB9m=sp$3!;6uT9RgyaJZoLy zh)b!HdC|}T^Q{^IAR|Sl8yf}H5!dnKM|%a1Z<6??aSdb}|F6HNJojdF0GlIaZ)O}c zQ2LTKEtKD+CH{W$D^$zwd_b3hP%dzcCvaRhd3eVQ_ZM}mynf0yEWyjt7CJXb9wqRL zSwRGv?%xe9u53~#60y$ql(T`0tKFhX*MO$aqK3UUE12!^h_p#5s@tO0{6RaZ2UO8@ z<}!8v9Y+|4C!c>^hx%Bk5hP=fjFNrf2~t&?*zD0AR@d1n?zL6YNZHv{CmLJHDK;zA zR%}R#fCt)dmeQ*&+6)V!()GRNA`+Z=0ZN<1j?2zl3b-5e7j?4(^YZ2rAQx|>e@oEG z5y16&gxciiuXt$INRQD@drVU+{9m7`1!LZK_HQhWPIY!_APmDi9xjcD`OJQf!S7Y< zP`BVp;0hlYkom`(zI2ff$^2n1EcM<-hX ziDt1`ft!~^p}e=J^%!8j!Gu8FW(trAL$mPgeP=uu)BL{)%w@V*#oeMt1VK|&;#9{c zUqTm?`9b)US*st5`Ul=vdE((hZ246_b*d*h@ER02VF&%SC#k3<0bKe{0ON2w16bX~ivi{v4NVEx20O1b;Rwyu4>>b}nZV?I1(JGc?wL>p=M)v2 zF9OX(9|azmQqKkEQYXWN3Zz2RCHOxr7OjayZek`!rVS+B?b-f5-^WVR%=XO7<7Y#< zc7`JmxDJ0r&H63@4t9BdEhr1kt)C2#=pZ^uE#*)`_w-jYs}fUn%Gin-K}(b_j)Pyq@!uu1OY1W z2qf|-G*_oy9Z2hO*eG=P{)JG8K{CH~7O>OIkugM;-m$|CqD&5tf8`hARy5VL^Kx!W z_}(Vuvc_L4dp(c~@=KeT^hdG5)eLz`WYrBPPPJzsHV}EH8BKf_MImYv@1>hVE=TlGn!V5PO6$xt1kzp!PU|H4FHYg~E4MO-tFv#gqC$`lV!d_frD|6~kw?(Yi z05V#1pLO7({fzHa-}GErR}A&!m#G>E!G|!=y~%T7@L8SqM>|f5Ld!BX`@p$_sF(bz zLashHjr3FtghXx69XyxlXq6H*IA+5c$TIvqD03pVDC?%f>t6ky2ckrz@`$IRFGN(m z-ZM0)=?%2yBJzz@hdMvKsSqu{V8esd>|3V(X}dKQ*9Dp_2+m`&&FB5{g`;n`mM@t( zNhvOz{nHs^-QDpeyok>4o@8ETBk8Oux-K~m3<}$SZcRx`RMQT`o!#z@a%;gZK9 zwf&yHAP=94&s&toiLX<6M@bGRm|Sv=hlxl|DX^)EeQER{Srjs0Dtz@=3+euu6F^3e z=749TO@?0+dK69^Z$QJ_EWzXpjB$)%CLQrQZ=o!K=wh8cFA_!vMXE z7aQvLf;aY2=}YO0EWA*5b>X11-*;V|L$sFZSwpU&sHX{%=541w80B@BI}o;xtD&Ou zvXabfg;vxgXUX^NyVUJJEXi!B_3V}2F(nx)EjF%Ox;)>k*`Cmho1y?Zzywh8E@%pUi z5LwlU5K&4(TU}o@wA(`^=Y4{en1fd@l<1ycY$|SvFuaUQfxb%4NE>~$baCMTghdBf zJ=nbbN%jmxR)bkGu8>tic%ZhR!tsEG<)rGyX%-3R_&XOmPU&=jaJ>VG2b!72Gdhdh z6;7_p-E%fL&XRG~Hyd^PV?VOVj-i3ypa0mP!cy^q9?}np(#zL>q3wK0k%OeS@7vPE zEU;GF%dXr7qF?`ENGKN6hwhuHrz?|*XmrkxY`{RZqSfI|W&k=+3t0{;E)&m{w5 zMl;eiT)0vvOCcI9>nLrp%hs=4v1=kE${u)Vuvc6tEG%5!-B?;C4wo!<3TB5!v&uf$ zo=v!E4J3*Iho<2XG!YjLetfK*%tXyR^jVo!*y`{YqkM*M6=WaiiYhZ6p%?a>OLYX! zJ3c&f41BKk!4$bYKZ=VRfYLWI(`0n|^6i-$I=T<%Gq)^*Mk>79qW8% zVj@#(PRh*(Ism!?zpx2}{4+P!t`y-W-0zKw}baO$l0I8;Vr~Z`f`lAQ*3GW@oGWPvtdaqXY&P1>U$c5Zg>o7i@KPKvNWEV* zV8J_tYf;0(S`RaI=wVXp4jh-Mzp-3*JK4LA$-RP&I@4T*!C*!P&69dBGd5aXFY&$( z2HynSj2nCG6?GR0FKjWbMrG0_qVW31>WhbC&#UXGFhJ9z(2O-8@m)>aM#`=4;GWU* zzV-o=zYidiq2kS*1dNKeCU=5oaI{+W5B|>YKw8GnprIDfp8-lh!Hg0U=b4(sMnSIe zxdjKC?S&R}eF*!b97k_gxtu<6!&#pQiyeY+^th!^6C2Ukh`+ua)ikWE`BfKptu8)rGi>VY7D92l&D@tCgnT1sj3m{lkLEh}}q z>wuGH0uWi#xKsEWP4ncphjksR8tk&v@Bxr3&v5UUFa-JRWG)*sSP?xtLN$+QT-9j5 zinZ?MrSHRFK8Zd-q{+1cpMw_vweXf<&FG^%G{=^BXBj^`i$k!8wXH7p^xSY^P#o{%|pUc_Vn` zJn-7JOPhyp_a-5f^sUC;gCW7Dtq4lQiOBO-&3?lN6q8Ju{x4+j_dr1!4K-cWa+P}_ F{{w=4K@b1{ diff --git a/screenshots/4.png b/screenshots/4.png deleted file mode 100644 index a0612f0e5a3b69b89d7628e6b49e7b53e5f90039..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37916 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 f`z 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 diff --git a/serien_checker.py b/serien_checker.py deleted file mode 100644 index c8f674a..0000000 --- a/serien_checker.py +++ /dev/null @@ -1,1069 +0,0 @@ -import sys -import json -import requests -import re -import logging -from bs4 import BeautifulSoup -from datetime import datetime -from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QPushButton, QLineEdit, QListWidget, - QLabel, QMessageBox, QSpinBox, QComboBox, QTableWidget, - QTableWidgetItem, QHeaderView, QDialog, QDialogButtonBox, - QTextEdit, QGroupBox, QToolBar, QSplitter, QFormLayout, - QListWidgetItem, QCheckBox) -from PyQt5.QtCore import Qt, QTimer - -# Logging Konfiguration -logging.basicConfig(level=logging.DEBUG, - format='%(asctime)s - %(levelname)s - %(message)s') - -class LogWindow(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Debug Log") - self.setGeometry(100, 100, 800, 400) - - layout = QVBoxLayout(self) - - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - layout.addWidget(self.log_text) - - close_button = QPushButton("Schließen") - close_button.clicked.connect(self.close) - layout.addWidget(close_button) - - def append_log(self, message): - self.log_text.append(message) - -class DebugHandler(logging.Handler): - def __init__(self, log_window): - super().__init__() - self.log_window = log_window - - def emit(self, record): - msg = self.format(record) - self.log_window.append_log(msg) - -class NewSeriesDialog(QDialog): - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - self.setWindowTitle("Neue Serie hinzufügen") - self.setMinimumWidth(400) - self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - layout = QVBoxLayout(self) - - # Einstellungen - settings_group = QGroupBox("Serie hinzufügen") - settings_layout = QFormLayout() - - # Serien-Eingabe - self.slug_input = QLineEdit() - self.slug_input.setPlaceholderText("Serien-URL oder Slug") - settings_layout.addRow("Serie:", self.slug_input) - - # Staffel-Einstellungen - self.staffel_mode = QComboBox() - self.staffel_mode.addItems(["Neuste Staffel", "Alle Staffeln", "Bestimmte Staffel"]) - self.staffel_mode.currentIndexChanged.connect(self.on_staffel_mode_changed) - settings_layout.addRow("Staffel-Modus:", self.staffel_mode) - - self.staffel_spin = QSpinBox() - self.staffel_spin.setMinimum(1) - self.staffel_spin.setMaximum(100) - self.staffel_spin.setEnabled(False) - settings_layout.addRow("Staffel:", self.staffel_spin) - - # Datumspräferenz - self.date_pref = QComboBox() - self.date_pref.addItems(["Bevorzuge Erstausstrahlung", "Bevorzuge TV", "Bevorzuge Streaming", "Bevorzuge deutsche Synchro"]) - settings_layout.addRow("Datum Präferenz:", self.date_pref) - - # Checkbox für Serien ohne klassische Staffeln - self.no_seasons_check = QCheckBox("Serie ohne klassische Staffeln") - self.no_seasons_check.setToolTip("Aktivieren für Serien mit fortlaufender Nummerierung oder Jahresformat") - settings_layout.addRow("", self.no_seasons_check) - - settings_group.setLayout(settings_layout) - layout.addWidget(settings_group) - - # Dialog Buttons - buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel, - Qt.Horizontal, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - def on_staffel_mode_changed(self, index): - """Wird aufgerufen, wenn sich der Staffel-Modus ändert""" - self.staffel_spin.setEnabled(self.staffel_mode.currentText() == "Bestimmte Staffel") - - def get_series_data(self): - """Extrahiert die Seriendaten aus den Eingabefeldern""" - input_text = self.slug_input.text().strip() - if not input_text: - QMessageBox.warning(self, "Fehler", "Bitte geben Sie eine Serie ein.") - return None - - # Extrahiere Slug aus URL wenn nötig - if "/" in input_text: - try: - # Prüfe ob es eine fernsehserien.de URL ist - if "fernsehserien.de" not in input_text: - QMessageBox.warning(self, "Fehler", "Die URL muss von fernsehserien.de sein.") - return None - # Extrahiere den Slug (Teil nach dem letzten /) - slug = input_text.rstrip("/").split("/")[-1] - if slug == "episodenguide": - # Wenn die URL auf /episodenguide endet, nimm den Teil davor - slug = input_text.rstrip("/").split("/")[-2] - except Exception as e: - QMessageBox.warning(self, "Fehler", f"Ungültige URL: {str(e)}") - return None - else: - slug = input_text - - # Hole den Seriennamen von fernsehserien.de - try: - url = f"https://www.fernsehserien.de/{slug}/episodenguide" - response = requests.get(url) - soup = BeautifulSoup(response.text, 'html.parser') - title_elem = soup.find('h1') - if title_elem: - series_name = title_elem.text.strip() - else: - QMessageBox.warning(self, "Fehler", "Serie nicht gefunden.") - return None - except Exception as e: - QMessageBox.warning(self, "Fehler", f"Fehler beim Abrufen der Serie: {str(e)}") - return None - - return { - 'name': series_name, - 'slug': slug, - 'staffel_setting': { - 'mode': self.staffel_mode.currentText(), - 'staffel': self.staffel_spin.value() - }, - 'date_preference': self.date_pref.currentText(), - 'no_seasons': self.no_seasons_check.isChecked() - } - -class SeriesEditDialog(QDialog): - def __init__(self, series_data, parent=None): - super().__init__(parent) - self.series_data = series_data.copy() - self.parent = parent - self.setup_ui() - - def setup_ui(self): - self.setWindowTitle("Serien verwalten") - self.setMinimumWidth(500) - self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - - layout = QVBoxLayout(self) - - # Serien Liste und Einstellungen nebeneinander - content_layout = QHBoxLayout() - - # Linke Seite - Serien Liste und Info - left_layout = QVBoxLayout() - - list_group = QGroupBox("Gespeicherte Serien") - list_layout = QVBoxLayout() - - self.series_list = QListWidget() - self.series_list.itemClicked.connect(self.on_series_selected) - list_layout.addWidget(self.series_list) - - # Buttons für die Liste - button_layout = QHBoxLayout() - add_button = QPushButton("Neue Serie") - add_button.clicked.connect(self.add_series) - delete_button = QPushButton("Löschen") - delete_button.clicked.connect(self.delete_series) - - button_layout.addWidget(add_button) - button_layout.addWidget(delete_button) - list_layout.addLayout(button_layout) - - list_group.setLayout(list_layout) - left_layout.addWidget(list_group) - - # Info Label - info_label = QLabel('v1.1 | © Akamaru | Source auf PonyGit') - info_label.setOpenExternalLinks(True) # Erlaubt das Öffnen des Links - info_label.setTextFormat(Qt.RichText) # Aktiviert HTML-Formatierung - left_layout.addWidget(info_label) - - content_layout.addLayout(left_layout) - - # Rechte Seite - Einstellungen - self.settings_group = QGroupBox("Einstellungen") - settings_layout = QFormLayout() - - # Serien-Eingabe - self.slug_input = QLineEdit() - self.slug_input.setPlaceholderText("Serien-URL oder Slug") - self.slug_input.setEnabled(False) # Deaktiviert, da nur zum Anzeigen - settings_layout.addRow("Serie:", self.slug_input) - - # Staffel-Einstellungen - self.staffel_mode = QComboBox() - self.staffel_mode.addItems(["Neuste Staffel", "Alle Staffeln", "Bestimmte Staffel"]) - self.staffel_mode.currentIndexChanged.connect(self.on_staffel_mode_changed) - settings_layout.addRow("Staffel-Modus:", self.staffel_mode) - - self.staffel_spin = QSpinBox() - self.staffel_spin.setMinimum(1) - self.staffel_spin.setMaximum(100) - self.staffel_spin.setEnabled(False) - settings_layout.addRow("Staffel:", self.staffel_spin) - - # Datumspräferenz - self.date_pref = QComboBox() - self.date_pref.addItems(["Bevorzuge Erstausstrahlung", "Bevorzuge TV", "Bevorzuge Streaming", "Bevorzuge deutsche Synchro"]) - settings_layout.addRow("Datum Präferenz:", self.date_pref) - - # Checkbox für Serien ohne klassische Staffeln - self.no_seasons_check = QCheckBox("Serie ohne klassische Staffeln") - self.no_seasons_check.setToolTip("Aktivieren für Serien mit fortlaufender Nummerierung oder Jahresformat") - settings_layout.addRow("", self.no_seasons_check) - - # Speichern Button für Einstellungen - save_button = QPushButton("Einstellungen speichern") - save_button.clicked.connect(self.save_settings) - settings_layout.addRow("", save_button) - - self.settings_group.setLayout(settings_layout) - content_layout.addWidget(self.settings_group) - - # Füge das Content-Layout zum Hauptlayout hinzu - layout.addLayout(content_layout) - - # Dialog Buttons - buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel, - Qt.Horizontal, self) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) - - # Initialisiere die Liste und deaktiviere die Einstellungen - self.update_series_list() - self.settings_group.setEnabled(False) - - def accept(self): - """Schließt den Dialog""" - super().accept() # Schließe den Dialog einfach - - def save_settings(self): - """Speichert die Einstellungen für die aktuelle Serie""" - current_item = self.series_list.currentItem() - if not current_item: - QMessageBox.warning(self, "Fehler", "Bitte wählen Sie eine Serie zum Bearbeiten aus!") - return - - slug = current_item.data(Qt.UserRole) - if slug not in self.series_data: - QMessageBox.warning(self, "Fehler", "Serie nicht gefunden!") - return - - # Aktualisiere die Einstellungen - self.series_data[slug].update({ - 'staffel_setting': { - 'mode': self.staffel_mode.currentText(), - 'staffel': self.staffel_spin.value() - }, - 'date_preference': self.date_pref.currentText(), - 'no_seasons': self.no_seasons_check.isChecked() - }) - - QMessageBox.information(self, "Erfolg", "Einstellungen wurden gespeichert!") - - def add_series(self): - """Öffnet den Dialog zum Hinzufügen einer neuen Serie""" - dialog = NewSeriesDialog(self) - if dialog.exec_() == QDialog.Accepted: - series_data = dialog.get_series_data() - if series_data: - slug = series_data['slug'] - self.series_data[slug] = series_data - self.update_series_list() - # Wähle die neue Serie aus - for i in range(self.series_list.count()): - if self.series_list.item(i).data(Qt.UserRole) == slug: - self.series_list.setCurrentRow(i) - break - QMessageBox.information(self, "Erfolg", "Serie wurde hinzugefügt!") - - def on_staffel_mode_changed(self, index): - """Wird aufgerufen, wenn sich der Staffel-Modus ändert""" - self.staffel_spin.setEnabled(self.staffel_mode.currentText() == "Bestimmte Staffel") - - def update_series_list(self): - """Aktualisiert die Liste der Serien""" - self.series_list.clear() - # Sortiere nach Namen - sorted_series = sorted(self.series_data.items(), key=lambda x: x[1]['name'].lower()) - for slug, data in sorted_series: - item = QListWidgetItem(data['name']) - item.setData(Qt.UserRole, slug) # Speichere den Slug als Zusatzdaten - self.series_list.addItem(item) - - def on_series_selected(self, item): - """Wird aufgerufen, wenn eine Serie ausgewählt wird""" - self.settings_group.setEnabled(bool(item)) - if item: - slug = item.data(Qt.UserRole) # Hole den Slug aus den Zusatzdaten - data = self.series_data.get(slug, {}) - self.slug_input.setText(data.get('slug', slug)) - - # Staffel-Einstellungen laden - staffel_setting = data.get('staffel_setting', {}) - mode = staffel_setting.get('mode', "Neuste Staffel") - staffel = staffel_setting.get('staffel', 1) - - index = self.staffel_mode.findText(mode) - if index >= 0: - self.staffel_mode.setCurrentIndex(index) - self.staffel_spin.setValue(staffel) - - # Datumspräferenz laden - date_pref = data.get('date_preference', "Bevorzuge Erstausstrahlung") - index = self.date_pref.findText(date_pref) - if index >= 0: - self.date_pref.setCurrentIndex(index) - - # Keine Staffeln Checkbox laden - self.no_seasons_check.setChecked(data.get('no_seasons', False)) - - def delete_series(self): - """Löscht die ausgewählte Serie""" - current_item = self.series_list.currentItem() - if not current_item: - QMessageBox.warning(self, "Fehler", "Bitte wählen Sie eine Serie zum Löschen aus!") - return - - slug = current_item.data(Qt.UserRole) - name = self.series_data[slug]['name'] - - reply = QMessageBox.question( - self, - "Serie löschen", - f"Möchten Sie die Serie '{name}' wirklich löschen?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No - ) - - if reply == QMessageBox.Yes: - del self.series_data[slug] - self.update_series_list() - self.slug_input.clear() - QMessageBox.information(self, "Erfolg", f"Serie '{name}' wurde gelöscht!") - -class SerienChecker(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("Serien Checker") - self.setGeometry(100, 100, 1000, 800) - - # Initialisiere series als leeres Dictionary - self.series = {} - - # Log-Fenster erstellen - self.log_window = LogWindow(self) - - # Zentral-Widget und Layout - central_widget = QWidget() - self.setCentralWidget(central_widget) - layout = QVBoxLayout(central_widget) - - # Toolbar - toolbar = QToolBar() - self.addToolBar(toolbar) - - # Serien verwalten Button - manage_button = QPushButton("Serien verwalten") - manage_button.clicked.connect(self.manage_series) - toolbar.addWidget(manage_button) - - # Debug Log Button - debug_button = QPushButton("Debug Log") - debug_button.clicked.connect(self.show_debug_log) - toolbar.addWidget(debug_button) - - # Serien und Episoden Layout - content_layout = QHBoxLayout() - - # Linke Seite - Serien Liste und Staffelauswahl - left_widget = QWidget() - left_layout = QVBoxLayout(left_widget) - - # Serien Liste - list_group = QGroupBox("Gespeicherte Serien") - list_layout = QVBoxLayout() - self.series_list = QListWidget() - self.series_list.currentItemChanged.connect(self.on_series_selected) - list_layout.addWidget(self.series_list) - list_group.setLayout(list_layout) - left_layout.addWidget(list_group) - - # Staffelauswahl - season_group = QGroupBox("Staffelauswahl") - season_layout = QVBoxLayout() - self.season_combo = QComboBox() - self.season_combo.addItem("Alle Staffeln") - self.season_combo.currentIndexChanged.connect(self.on_season_selected) - season_layout.addWidget(self.season_combo) - season_group.setLayout(season_layout) - left_layout.addWidget(season_group) - - content_layout.addWidget(left_widget) - - # Rechte Seite - Episoden - episodes_group = QGroupBox("Episoden") - episodes_layout = QVBoxLayout() - - self.episodes_table = QTableWidget() - self.episodes_table.setColumnCount(4) - self.episodes_table.setHorizontalHeaderLabels(["Datum", "Staffel", "Folge", "Titel"]) - header = self.episodes_table.horizontalHeader() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.ResizeToContents) - header.setSectionResizeMode(2, QHeaderView.ResizeToContents) - header.setSectionResizeMode(3, QHeaderView.Stretch) - episodes_layout.addWidget(self.episodes_table) - - # Aktualisieren Button - refresh_button = QPushButton("Aktualisieren") - refresh_button.clicked.connect(self.refresh_selected_series) - episodes_layout.addWidget(refresh_button) - - episodes_group.setLayout(episodes_layout) - content_layout.addWidget(episodes_group) - - # Füge das Content-Layout zum Hauptlayout hinzu - layout.addLayout(content_layout) - - # Timer für automatische Aktualisierung (alle 30 Minuten) - self.timer = QTimer() - self.timer.timeout.connect(self.refresh_selected_series) - self.timer.start(30 * 60 * 1000) - - # Lade die Konfiguration und aktualisiere die Liste - self.load_config() - self.update_series_list() - - self.show() - - def manage_series(self): - dialog = SeriesEditDialog(self.series, self) - if dialog.exec_() == QDialog.Accepted: - self.series = dialog.series_data - self.save_config() - self.update_series_list() - self.refresh_all() - - def load_config(self): - """Lädt die Konfiguration""" - try: - with open('series_config.json', 'r', encoding='utf-8') as f: - config = json.load(f) - if isinstance(config, dict) and 'series' in config: - self.series = config['series'] - else: - # Alte Konfiguration kompatibel machen - self.series = {} - for slug, data in config.items(): - self.series[slug] = { - 'name': data.get('name', slug), - 'staffel_setting': { - 'mode': data.get('settings', {}).get('mode', "Neuste Staffel"), - 'staffel': data.get('settings', {}).get('staffel', 1) - }, - 'date_preference': "Bevorzuge Erstausstrahlung" - } - except FileNotFoundError: - logging.debug("Keine Konfigurationsdatei gefunden, verwende leeres Dictionary") - self.series = {} - - def save_config(self): - """Speichert die Konfiguration""" - with open('series_config.json', 'w', encoding='utf-8') as f: - json.dump({'series': self.series}, f, indent=4, ensure_ascii=False) - - def update_series_list(self): - """Aktualisiert die Liste der Serien""" - self.series_list.clear() - # Sortiere nach Namen - sorted_series = sorted(self.series.items(), key=lambda x: x[1]['name'].lower()) - for slug, data in sorted_series: - item = QListWidgetItem(data['name']) - item.setData(Qt.UserRole, slug) # Speichere den Slug als Zusatzdaten - self.series_list.addItem(item) - - def parse_date(self, date_str): - try: - return datetime.strptime(date_str.split()[0], "%d.%m.%Y") - except: - return None - - def get_premiere_date(self, episode): - """Extrahiert das erste deutsche Ausstrahlungsdatum (TV oder Streaming) basierend auf der Präferenz""" - logging.debug("Suche nach Premierendaten") - try: - tv_date = None - streaming_date = None - synchro_date = None - - # Suche nach allen deutschen Premieren - for ea_angabe in episode.find_all('ea-angabe'): - titel_elem = ea_angabe.find('ea-angabe-titel') - if not titel_elem: - continue - - titel = titel_elem.text.strip() - logging.debug(f"Gefundene Premiere: {titel}") - - # Prüfe auf deutsche Daten - datum_elem = ea_angabe.find('ea-angabe-datum') - if datum_elem: - date_str = datum_elem.text.strip() - if '. ' in date_str: - date_str = date_str.split('. ', 1)[1] - - parsed_date = self.parse_date(date_str) - if parsed_date: - if "TV-Premiere" in titel and "Deutsche" in titel: - tv_date = (parsed_date, date_str) - logging.debug(f"Deutsche TV-Premiere gefunden: {date_str}") - elif "Streaming-Premiere" in titel and "Deutsche" in titel: - streaming_date = (parsed_date, date_str) - logging.debug(f"Deutsche Streaming-Premiere gefunden: {date_str}") - elif "deutschen Synchronfassung" in titel: - synchro_date = (parsed_date, date_str) - logging.debug(f"Deutsche Synchro-Premiere gefunden: {date_str}") - - # Hole die Datumspräferenz für diese Serie - current_series = self.series_list.currentItem() - if current_series: - slug = current_series.data(Qt.UserRole) # Hole den Slug aus den Zusatzdaten - pref = self.series[slug].get('date_preference', "Bevorzuge Erstausstrahlung") - else: - pref = "Bevorzuge Erstausstrahlung" - - # Prüfe zuerst auf Synchro-Präferenz - if pref == "Bevorzuge deutsche Synchro": - if synchro_date: - result = synchro_date[1] - logging.debug(f"Synchro-Premiere gewählt: {result}") - return result - else: - logging.debug("Keine Synchro-Premiere gefunden, zeige TBA") - return "TBA" - - # Andere Präferenzen - if pref == "Bevorzuge TV" and tv_date: - result = tv_date[1] - logging.debug(f"TV-Premiere gewählt: {result}") - elif pref == "Bevorzuge Streaming" and streaming_date: - result = streaming_date[1] - logging.debug(f"Streaming-Premiere gewählt: {result}") - else: # Bevorzuge Erstausstrahlung - dates = [] - if tv_date: - dates.append(tv_date) - if streaming_date: - dates.append(streaming_date) - if synchro_date: - dates.append(synchro_date) - - if dates: - result = min(dates, key=lambda x: x[0])[1] - logging.debug(f"Frühestes Datum gewählt: {result}") - else: - logging.warning("Keine deutschen Premierendaten gefunden!") - return "TBA" - - return result - - except Exception as e: - logging.error(f"Fehler beim Extrahieren des Premierendatums: {str(e)}") - return "TBA" - - def on_date_preference_changed(self, series_slug): - """Wird aufgerufen, wenn sich die Datumspräferenz einer Serie ändert""" - logging.debug(f"Datumspräferenz für Serie {series_slug} wurde geändert") - self.refresh_selected_series() - - def get_episode_info(self, episode): - """Extrahiert Folgeninformationen aus einem Episode-Element""" - try: - logging.debug("Versuche Episodeninformationen zu extrahieren") - - # Suche nach Staffel und Folge in der Episodennummer (z.B. "7.01") - header = episode.find('h3', class_='episode-output-titel') - if header: - episode_link = header.find('a') - if episode_link: - # URL enthält die Episodennummer (z.B. "7x01") - href = episode_link.get('href', '') - match = re.search(r'(\d+)x(\d+)-', href) - if match: - staffel = int(match.group(1)) - folge = int(match.group(2)) - logging.debug(f"Gefundene Staffel/Folge aus URL: {staffel}/{folge}") - else: - # Alternative: Suche in der Episoden-Zeile - episode_info = episode.find('div', {'itemprop': 'episodeNumber'}) - if episode_info and episode_info.text: - # Prüfe auf "Folge X" Format - folge_match = re.search(r'Folge (\d+)', episode_info.text) - if folge_match: - folge = int(folge_match.group(1)) - # Bei Serien ohne Staffeln setzen wir Staffel auf 1 - staffel = 1 - logging.debug(f"Gefundene Folge aus 'Folge X' Format: {folge}") - else: - try: - folge = int(episode_info.text) - # Staffel aus übergeordnetem Element - staffel_info = episode.find_previous('h2', class_='header-2015') - if staffel_info: - staffel_match = re.search(r'Staffel (\d+)', staffel_info.text) - if staffel_match: - staffel = int(staffel_match.group(1)) - logging.debug(f"Gefundene Staffel/Folge aus Text: {staffel}/{folge}") - except ValueError: - logging.debug(f"Konnte Folge nicht aus Text extrahieren: {episode_info.text}") - - # Suche nach deutschem Titel - if episode_link: - # Prüfe zuerst, ob ein deutscher Titel existiert - title_spans = episode_link.find_all('span') - title = "Noch kein Titel" - - for span in title_spans: - # Überspringe Folgen-Nummer - if span.text.isdigit(): - continue - # Überspringe englischen Titel - if span.get('class') and 'episode-output-originaltitel' in span.get('class'): - continue - # Überspringe Platzhalter für fehlenden Titel - if span.get('title') == "Titel unbekannt": - continue - # Wenn wir hier sind und der span itemprop="name" hat, ist es der deutsche Titel - if span.get('itemprop') == 'name' and span.text.strip() not in ['–', '-']: - title = span.text.strip() - logging.debug(f"Gefundener deutscher Titel: {title}") - break - - logging.debug(f"Finaler Titel: {title}") - else: - title = "Noch kein Titel" - logging.debug("Kein Link gefunden, verwende Standardtitel") - - if not all([staffel, folge]): - logging.warning(f"Unvollständige Daten: Staffel={staffel}, Folge={folge}") - return None, None, None - - return staffel, folge, title - - except Exception as e: - logging.error(f"Fehler beim Extrahieren der Episodeninformationen: {str(e)}") - return None, None, None - - def get_available_seasons(self, slug): - """Holt alle verfügbaren Staffeln einer Serie""" - logging.debug(f"Hole verfügbare Staffeln für Slug: {slug}") - try: - url = f"https://www.fernsehserien.de/{slug}/episodenguide" - logging.debug(f"Hole Übersichtsseite: {url}") - response = requests.get(url) - soup = BeautifulSoup(response.text, 'html.parser') - - staffel_links = [] - - # Suche nach dem Episodenguide-Menü - menu_items = soup.find_all('li') - for item in menu_items: - link = item.find('a') - if not link or not link.get('href'): - continue - - href = link['href'] - title = link.text.strip() - - # Überspringe irrelevante Links - if any(x in href for x in ['/news', '/cast-crew', '/sendetermine']): - continue - - # Prüfe auf normale Staffel-Links - match = re.search(r'/staffel-(\d+)/(\d+)$', href) - if match: - s_nr = int(match.group(1)) - serie_id = match.group(2) - staffel_links.append(('staffel', s_nr, serie_id, title)) - logging.debug(f"Gefundene Staffel: {title} mit ID: {serie_id}") - continue - - # Prüfe auf andere Formate (Jahre, Specials, etc.) - if href.startswith('#'): - # Überspringe "zurück nach oben" Link - if title.lower() in ['zurück nach oben', 'nach oben']: - continue - - # Lokaler Anker, extrahiere den Namen - section_name = href[1:] # Entferne das # - staffel_links.append(('section', section_name, '0', title)) - logging.debug(f"Gefundene Sektion: {title}") - else: - # Prüfe auf andere Episodenguide-Links - match = re.search(r'episodenguide/([^/]+)/(\d+)$', href) - if match: - section_id = match.group(1) - serie_id = match.group(2) - staffel_links.append(('other', section_id, serie_id, title)) - logging.debug(f"Gefundene andere Staffel: {title} mit ID: {serie_id}") - - # Entferne Duplikate (basierend auf Titel) - unique_links = [] - seen_titles = set() - for link in staffel_links: - if link[3] not in seen_titles: - unique_links.append(link) - seen_titles.add(link[3]) - - return unique_links - - except Exception as e: - logging.error(f"Fehler beim Abrufen der Staffeln: {str(e)}") - return [] - - def get_staffel_url(self, slug, staffel_nr=None): - """Generiert die URL für eine bestimmte Staffel""" - logging.debug(f"Generiere Staffel-URL für Slug: {slug}, Staffel: {staffel_nr}") - try: - staffel_links = self.get_available_seasons(slug) - - if not staffel_links: - logging.warning("Keine Staffeln oder Episodengruppen gefunden!") - return None - - if staffel_nr: - # Suche nach der gewünschten Staffel (nur für normale Staffeln) - for typ, nr, serie_id, title in staffel_links: - if typ == 'staffel' and nr == staffel_nr: - url = f"https://www.fernsehserien.de/{slug}/episodenguide/staffel-{staffel_nr}/{serie_id}" - logging.debug(f"Generierte URL für spezifische Staffel: {url}") - return url - logging.warning(f"Staffel {staffel_nr} nicht gefunden!") - return None - else: - # Nehme den neuesten Eintrag - if not staffel_links: - return None - - # Sortiere nach Typ und dann nach Nummer/ID - def sort_key(x): - typ, nr, serie_id, title = x - type_priority = {'staffel': 3, 'other': 2, 'section': 1} - - # Versuche nr als Zahl zu interpretieren, wenn möglich - try: - num_nr = int(nr) - except (ValueError, TypeError): - num_nr = 0 - - return (type_priority.get(typ, 0), num_nr) - - # Finde den neuesten Eintrag - newest = max(staffel_links, key=sort_key) - typ, nr, serie_id, title = newest - - # Hole die Serie-ID aus einem beliebigen Link - serie_id = None - for t, n, sid, _ in staffel_links: - if t == 'other' and sid != '0': - serie_id = sid - break - - if not serie_id: - logging.warning("Keine gültige Serie-ID gefunden!") - return None - - if typ == 'staffel': - url = f"https://www.fernsehserien.de/{slug}/episodenguide/staffel-{nr}/{serie_id}" - logging.debug(f"Generierte URL für neueste Staffel ({title}): {url}") - elif typ == 'section': - # Bei Sektionen (Jahren) verwende die erste Sektion mit der Serie-ID - url = f"https://www.fernsehserien.de/{slug}/episodenguide/1/{serie_id}" - logging.debug(f"Generierte URL für Sektion ({title}): {url}") - else: - url = f"https://www.fernsehserien.de/{slug}/episodenguide/{nr}/{serie_id}" - logging.debug(f"Generierte URL für Episodengruppe ({title}): {url}") - return url - - except Exception as e: - logging.error(f"Fehler beim Generieren der Staffel-URL: {str(e)}") - return None - - def on_series_selected(self, current, previous): - """Wird aufgerufen, wenn eine Serie in der Liste ausgewählt wird""" - if current: - slug = current.data(Qt.UserRole) # Hole den Slug aus den Zusatzdaten - if slug in self.series: - # Aktualisiere das Staffel-Dropdown - self.season_combo.clear() - self.season_combo.addItem("Alle Staffeln") - - # Hole verfügbare Staffeln - staffel_links = self.get_available_seasons(slug) - # Sortiere: Erst normale Staffeln (nach Nummer), dann andere (alphabetisch) - sorted_links = sorted(staffel_links, - key=lambda x: (x[0] != 'staffel', # Staffeln zuerst - int(x[1]) if x[0] == 'staffel' else 0, # Nach Staffelnummer - x[3].lower())) # Dann alphabetisch nach Titel - - for typ, nr, serie_id, title in sorted_links: - self.season_combo.addItem(title, (typ, nr, serie_id)) - - self.refresh_selected_series() - else: - logging.warning(f"Serie {slug} nicht gefunden!") - else: - self.episodes_table.setRowCount(0) - self.season_combo.clear() - self.season_combo.addItem("Alle Staffeln") - - def get_section_episodes(self, soup, section_id): - """Extrahiert Episoden aus einer bestimmten Sektion""" - episodes = [] - - # Finde die Sektion anhand der ID - section = soup.find(id=section_id) - if section: - logging.debug(f"Sektion {section_id} gefunden") - # Suche nach allen Episoden in dieser Sektion - # Suche zuerst in der Tabelle - episode_table = section.find('table', class_='episode-output') - if episode_table: - logging.debug("Episodentabelle gefunden") - for row in episode_table.find_all('tr'): - # Überspringe Header-Zeilen - if row.find('th'): - continue - - cells = row.find_all('td') - if len(cells) >= 4: # Datum, Folge, Titel, ... - try: - # Extrahiere das Datum - datum_cell = cells[0] - datum = datum_cell.text.strip() - - # Extrahiere die Folgennummer - folge_cell = cells[1] - folge_text = folge_cell.text.strip() - folge_match = re.search(r'(\d+)', folge_text) - if folge_match: - folge = int(folge_match.group(1)) - else: - continue - - # Extrahiere den Titel - titel_cell = cells[2] - titel = titel_cell.text.strip() - if not titel: - titel = "Noch kein Titel" - - # Verwende die Jahreszahl als Staffelnummer - staffel = int(section_id) if section_id.isdigit() else 1 - - episodes.append({ - 'date': datum, - 'staffel': staffel, - 'folge': folge, - 'titel': titel - }) - logging.debug(f"Episode gefunden: Staffel {staffel}, Folge {folge}, Titel: {titel}") - - except (ValueError, AttributeError) as e: - logging.debug(f"Fehler beim Parsen einer Zeile: {str(e)}") - continue - else: - logging.debug("Keine Episodentabelle gefunden") - - # Wenn keine Tabelle gefunden wurde, suche nach einzelnen Episoden-Sektionen - if not episodes: - episode_sections = section.find_all('section', {'itemprop': 'episode'}) - for episode in episode_sections: - staffel, folge, titel = self.get_episode_info(episode) - if all([staffel, folge, titel]): - datum = self.get_premiere_date(episode) - episodes.append({ - 'date': datum, - 'staffel': staffel, - 'folge': folge, - 'titel': titel - }) - else: - logging.debug(f"Sektion {section_id} nicht gefunden") - - return episodes - - def refresh_selected_series(self): - """Aktualisiert die Episodenliste für die ausgewählte Serie""" - current_item = self.series_list.currentItem() - if not current_item: - logging.warning("Keine Serie ausgewählt!") - return - - slug = current_item.data(Qt.UserRole) # Hole den Slug aus den Zusatzdaten - if slug not in self.series: - logging.warning(f"Serie {slug} nicht gefunden!") - return - - selected_data = self.series[slug] - logging.debug(f"Aktualisiere Serie: {selected_data['name']}") - - try: - # Hole die ausgewählte Staffel aus dem Dropdown - current_index = self.season_combo.currentIndex() - if current_index <= 0: # "Alle Staffeln" oder keine Auswahl - # Verwende die Einstellungen aus der Konfiguration - settings = selected_data.get('staffel_setting', {}) - mode = settings.get('mode', "Neuste Staffel") - staffel_nr = settings.get('staffel') if mode == "Bestimmte Staffel" else None - url = self.get_staffel_url(slug, staffel_nr) - else: - # Verwende die ausgewählte Staffel - dropdown_data = self.season_combo.itemData(current_index) - if dropdown_data: - typ, nr, serie_id = dropdown_data - if typ == 'staffel': - url = f"https://www.fernsehserien.de/{slug}/episodenguide/staffel-{nr}/{serie_id}" - elif typ == 'section': - # Bei lokalen Ankern die Hauptseite verwenden und nach der Sektion suchen - url = f"https://www.fernsehserien.de/{slug}/episodenguide" - else: - url = f"https://www.fernsehserien.de/{slug}/episodenguide/{nr}/{serie_id}" - else: - url = None - - if not url: - self.episodes_table.setRowCount(1) - error_msg = "Keine Staffeln gefunden!" - self.episodes_table.setItem(0, 0, QTableWidgetItem(error_msg)) - for i in range(1, 4): - self.episodes_table.setItem(0, i, QTableWidgetItem("")) - logging.error(error_msg) - return - - episodes = [] - - try: - response = requests.get(url) - response.raise_for_status() - soup = BeautifulSoup(response.text, 'html.parser') - - # Wenn es eine spezifische Sektion ist, suche nur in dieser - if current_index > 0: - dropdown_data = self.season_combo.itemData(current_index) - if dropdown_data and dropdown_data[0] == 'section': - section_id = dropdown_data[1] - episodes = self.get_section_episodes(soup, section_id) - logging.debug(f"Suche Episoden in Sektion: {section_id}") - else: - # Normale Episodensuche für Staffeln - page_episodes = soup.find_all('section', {'itemprop': 'episode'}) - for episode in page_episodes: - staffel, folge, titel = self.get_episode_info(episode) - if all([staffel, folge, titel]): - datum = self.get_premiere_date(episode) - episodes.append({ - 'date': datum, - 'staffel': staffel, - 'folge': folge, - 'titel': titel - }) - else: - # Bei "Alle Staffeln" alle Episoden laden - page_episodes = soup.find_all('section', {'itemprop': 'episode'}) - for episode in page_episodes: - staffel, folge, titel = self.get_episode_info(episode) - if all([staffel, folge, titel]): - datum = self.get_premiere_date(episode) - episodes.append({ - 'date': datum, - 'staffel': staffel, - 'folge': folge, - 'titel': titel - }) - - except requests.RequestException as e: - logging.error(f"Fehler beim Abrufen der Episoden: {str(e)}") - self.episodes_table.setRowCount(1) - self.episodes_table.setItem(0, 0, QTableWidgetItem(f"Fehler: {str(e)}")) - for i in range(1, 4): - self.episodes_table.setItem(0, i, QTableWidgetItem("")) - return - - if not episodes: - self.episodes_table.setRowCount(1) - error_msg = "Keine Episoden gefunden!" - self.episodes_table.setItem(0, 0, QTableWidgetItem(error_msg)) - for i in range(1, 4): - self.episodes_table.setItem(0, i, QTableWidgetItem("")) - logging.warning(error_msg) - return - - # Sortiere nach Datum (wenn verfügbar) und Staffel/Folge - episodes.sort(key=lambda x: ( - datetime.strptime(x['date'], '%d.%m.%Y') if x['date'] != 'TBA' else datetime.max, - x['staffel'], - x['folge'] - )) - - # Zeige alle gefundenen Episoden an - # episodes = episodes[:20] # Alte Begrenzung entfernt - - # Aktualisiere die Tabelle - self.episodes_table.setRowCount(len(episodes)) - for row, episode in enumerate(episodes): - self.episodes_table.setItem(row, 0, QTableWidgetItem(episode['date'])) - self.episodes_table.setItem(row, 1, QTableWidgetItem(str(episode['staffel']))) - self.episodes_table.setItem(row, 2, QTableWidgetItem(str(episode['folge']))) - self.episodes_table.setItem(row, 3, QTableWidgetItem(episode['titel'])) - - except Exception as e: - logging.error(f"Fehler beim Aktualisieren der Serie: {str(e)}") - self.episodes_table.setRowCount(1) - self.episodes_table.setItem(0, 0, QTableWidgetItem(f"Fehler: {str(e)}")) - for i in range(1, 4): - self.episodes_table.setItem(0, i, QTableWidgetItem("")) - - def refresh_all(self): - self.update_series_list() - if self.series_list.selectedItems(): - self.refresh_selected_series() - - def show_debug_log(self): - self.log_window.show() - - def on_season_selected(self, index): - """Wird aufgerufen, wenn eine andere Staffel im Dropdown ausgewählt wird""" - if self.series_list.currentItem(): - self.refresh_selected_series() - -if __name__ == '__main__': - app = QApplication(sys.argv) - window = SerienChecker() - window.show() - sys.exit(app.exec_()) \ No newline at end of file diff --git a/serien_checker/__init__.py b/serien_checker/__init__.py new file mode 100644 index 0000000..e4df554 --- /dev/null +++ b/serien_checker/__init__.py @@ -0,0 +1,6 @@ +""" +Serien-Checker - TV Series Episode Tracker +""" + +__version__ = "2.0.0" +__author__ = "Serien-Checker Project" diff --git a/serien_checker/database/__init__.py b/serien_checker/database/__init__.py new file mode 100644 index 0000000..f62d5f7 --- /dev/null +++ b/serien_checker/database/__init__.py @@ -0,0 +1 @@ +"""Database module for Serien-Checker""" diff --git a/serien_checker/database/db_manager.py b/serien_checker/database/db_manager.py new file mode 100644 index 0000000..311eea1 --- /dev/null +++ b/serien_checker/database/db_manager.py @@ -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)) diff --git a/serien_checker/database/models.py b/serien_checker/database/models.py new file mode 100644 index 0000000..e6e8cc5 --- /dev/null +++ b/serien_checker/database/models.py @@ -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] + ) diff --git a/serien_checker/scraper/__init__.py b/serien_checker/scraper/__init__.py new file mode 100644 index 0000000..dd9dd9f --- /dev/null +++ b/serien_checker/scraper/__init__.py @@ -0,0 +1 @@ +"""Web scraping module for fernsehserien.de""" diff --git a/serien_checker/scraper/browser_scraper.py b/serien_checker/scraper/browser_scraper.py new file mode 100644 index 0000000..f637968 --- /dev/null +++ b/serien_checker/scraper/browser_scraper.py @@ -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 diff --git a/serien_checker/scraper/fernsehserien_scraper.py b/serien_checker/scraper/fernsehserien_scraper.py new file mode 100644 index 0000000..6725b5e --- /dev/null +++ b/serien_checker/scraper/fernsehserien_scraper.py @@ -0,0 +1,1095 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "requests", +# "beautifulsoup4", +# "lxml", +# ] +# /// + +""" +Scraper for fernsehserien.de using BeautifulSoup +This is a standalone scraper that can be used independently +""" + +import re +import requests +from bs4 import BeautifulSoup +from typing import List, Dict, Optional, Tuple +from datetime import datetime +from dataclasses import dataclass +import sys +from pathlib import Path + +# Add parent to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from serien_checker.database.models import SeasonType +from serien_checker.scraper.browser_scraper import ScrapedEpisode, ScrapedSeason +from serien_checker.utils.logger import setup_logger + +logger = setup_logger() + + +class FernsehserienScraper: + """ + Scraper for fernsehserien.de using requests + BeautifulSoup + """ + + BASE_URL = "https://www.fernsehserien.de" + HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + + def __init__(self): + self.session = requests.Session() + self.session.headers.update(self.HEADERS) + + @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})', '%d.%m.%Y'), + (r'(\d{1,2})\.(\d{1,2})\.(\d{2})', '%d.%m.%y'), + ] + + for pattern, fmt in patterns: + match = re.search(pattern, date_str) + if match: + try: + return datetime.strptime(match.group(0), fmt) + 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""" + name_lower = season_name.lower() + + if any(keyword in name_lower for keyword in ['special', 'specials']): + return SeasonType.SPECIALS + + if any(keyword in name_lower for keyword in ['extra', 'extras', 'bonus']): + return SeasonType.EXTRAS + + if any(keyword in name_lower for keyword in ['best', 'best-of', 'best of']): + return SeasonType.BEST_OF + + if re.match(r'^(19|20)\d{2}$', season_name.strip()): + return SeasonType.YEAR_BASED + + return SeasonType.NORMAL + + @staticmethod + def extract_episode_code(episode_text: str) -> str: + """ + Extract episode code from text + Examples: "1. Folge" -> "01", "12a. Teil A" -> "12a" + """ + match = re.search(r'^(\d+[a-z]?)\.', episode_text.strip()) + if match: + code = match.group(1) + if code.isdigit(): + return code.zfill(2) + 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 from fernsehserien.de + + This scraper works in two steps: + 1. Scrape overview page to get season links + 2. Scrape each season page to get episodes + + Args: + url: URL to episode guide (overview page) + + Returns: + Tuple of (series_title, list of ScrapedSeason) + """ + print(f"Scraping overview {url}...") + + response = self.session.get(url, timeout=15) + response.raise_for_status() + + soup = BeautifulSoup(response.content, 'lxml') + + # Extract series title + series_title = self._extract_series_title(soup) + print(f"Serie: {series_title}") + + # Find season links from the series menu + season_links = self._extract_season_links(soup, url) + print(f"Gefunden: {len(season_links)} Staffeln mit eigenen Seiten") + + # Check if most season names contain "bisher X Folgen" pattern + # If so, we need to group episodes by code prefix instead + bisher_pattern = re.compile(r'bisher.*\d+.*Folgen', re.IGNORECASE) + has_bisher = [bool(bisher_pattern.search(name)) for name, _ in season_links] + bisher_count = sum(has_bisher) + mostly_bisher = bisher_count > len(season_links) // 2 and season_links + + if mostly_bisher: + # Special case: All seasons have same name (like "bisher 1369 Folgen") + # Extract and group episodes from overview page by episode code prefix + print(f"Alle Staffeln heißen '{season_links[0][0]}' - gruppiere nach Episode-Code") + seasons = self._extract_grouped_from_overview(soup) + print(f"Gefunden: {len(seasons)} gruppierte Staffeln mit insgesamt {sum(len(s.episodes) for s in seasons)} Episoden") + return series_title, seasons + + # Scrape each season from dedicated pages + # First, check if any season names have duplicates (same base number) + season_names = [name for name, _ in season_links] + has_duplicate_seasons = False + for name in season_names: + # Extract base season number (e.g., "Staffel 6" from "Staffel 6: Video-Podcast") + base_match = re.search(r'Staffel\s+(\d+)', name) + if base_match: + base_num = base_match.group(1) + # Count how many seasons have this base number + count = sum(1 for n in season_names if f'Staffel {base_num}' in n) + if count > 1: + has_duplicate_seasons = True + break + + seasons = [] + for i, (season_name, season_url) in enumerate(season_links): + print(f" Lade {season_name}...") + season = self._scrape_season_page(season_name, season_url, i) + + # If season has no episodes or episodes have no episode_number, + # try to extract from overview page instead + if season and season.episodes: + # Check if any episode has episode_number + has_episode_numbers = any(ep.episode_number is not None for ep in season.episodes) + + # Don't use overview page if there are duplicate season numbers + # (e.g., "Staffel 6" and "Staffel 6: Video-Podcast") + # because the overview page can't distinguish between variants with same base number + skip_overview = has_duplicate_seasons + + # Also check if episode_numbers look wrong (e.g., starting at 1 for each season) + # This happens when scraping from

which only has + # season-relative numbers, not overall series numbers + needs_overview = False + if has_episode_numbers and not skip_overview: + # If all episodes have numbers 1, 2, 3... but this isn't Staffel 1, + # the numbers are probably wrong (season-relative instead of series-wide) + first_ep_num = next((ep.episode_number for ep in season.episodes if ep.episode_number), None) + if first_ep_num == 1 and i > 0: # i > 0 means not the first season + needs_overview = True + + if (not has_episode_numbers or needs_overview) and not skip_overview: + # Try extracting from overview page (only when safe) + overview_season = self._extract_season_from_overview(soup, season_name, i) + if overview_season and overview_season.episodes: + season = overview_season + print(f" {len(season.episodes)} Episoden (von Übersichtsseite)") + else: + print(f" {len(season.episodes)} Episoden") + else: + print(f" {len(season.episodes)} Episoden") + seasons.append(season) + elif season: + # Empty season, try overview page (only when safe) + if not skip_overview: + overview_season = self._extract_season_from_overview(soup, season_name, i) + if overview_season and overview_season.episodes: + seasons.append(overview_season) + print(f" {len(overview_season.episodes)} Episoden (von Übersichtsseite)") + + # Also check for seasons directly on overview page (e.g., Specials) + overview_seasons = self._extract_seasons_from_overview(soup, len(seasons)) + if overview_seasons: + # Track existing season names to avoid duplicates + existing_names = {s.name for s in seasons} + new_seasons = [s for s in overview_seasons if s.name not in existing_names] + + if new_seasons: + print(f"Gefunden: {len(new_seasons)} zusätzliche Staffeln auf Übersichtsseite") + for season in new_seasons: + seasons.append(season) + print(f" {season.name}: {len(season.episodes)} Episoden") + + return series_title, seasons + + def _extract_series_title(self, soup: BeautifulSoup) -> str: + """Extract series title from page""" + # Try meta tags first + og_title = soup.find('meta', property='og:title') + if og_title and og_title.get('content'): + title = og_title['content'] + # Remove ": Episodenguide" suffix + title = re.sub(r':\s*Episodenguide.*$', '', title, flags=re.IGNORECASE) + return title.strip() + + # Fallback to h1 + h1 = soup.find('h1') + if h1: + return h1.get_text(strip=True) + + return "Unbekannte Serie" + + def _extract_season_links(self, soup: BeautifulSoup, base_url: str) -> List[Tuple[str, str]]: + """ + Extract season links from the series menu + + Returns: + List of (season_name, season_url) tuples + """ + season_links = [] + seen_urls = set() + + # Collect links from multiple sources + links = [] + + # 1. Try to find the series menu navigation (newer layout) + series_menu = soup.find('nav', class_='series-menu') + if series_menu: + # Find the episodenguide submenu + episode_menu = series_menu.find('li', {'data-menu-item': 'episodenguide'}) + if episode_menu: + # Same pattern as global search - no trailing slash required + links.extend(episode_menu.find_all('a', href=re.compile(r'episodenguide/(staffel-[^/]+|\d+)'))) + + # 2. Search globally for season links (works for pages without series-menu) + # Pattern matches: /episodenguide/staffel-1/, /episodenguide/staffel-1/18522, /episodenguide/0/, etc. + # Note: No trailing slash required - URLs can end with /staffel-1 or /staffel-1/12345 + global_links = soup.find_all('a', href=re.compile(r'episodenguide/(staffel-[^/]+|\d+)')) + links.extend(global_links) + + for link in links: + # Extract season name more robustly + # First, try to get text from strong/b tags only (ignoring image alt text) + strong_tag = link.find(['strong', 'b']) + if strong_tag: + season_name = strong_tag.get_text(strip=True) + else: + # Fallback: get direct text children only (exclude nested elements like img) + season_name = ''.join(link.find_all(string=True, recursive=False)).strip() + # If still empty, use full text + if not season_name: + season_name = link.get_text(strip=True) + + # Clean up image captions that might leak through + season_name = re.sub(r'Bild:\s*[^A-Z]*(?=[A-Z])', '', season_name) + season_name = re.sub(r'Foto:\s*[^A-Z]*(?=[A-Z])', '', season_name) + + # Normalize whitespace (convert "Staffel6" or "Staffel 6" to "Staffel 6") + season_name = ' '.join(season_name.split()) + + season_url = link.get('href', '') + + # If season name is just "Staffel" without number, try to extract from URL + if season_name.lower() in ['staffel', 'season']: + # Try to extract season number from URL like "staffel-6/47453" + url_match = re.search(r'/staffel-(\d+)', season_url) + if url_match: + season_num = url_match.group(1) + season_name = f"Staffel {season_num}" + logger.debug(f"Added season number from URL: '{season_name}'") + + logger.debug(f"Extracted season name: '{season_name}' from link {season_url}") + + # Skip navigation/anchor links + if not season_url or season_url.startswith('#'): + continue + + if season_url: + # Make absolute URL + if season_url.startswith('/'): + season_url = self.BASE_URL + season_url + elif not season_url.startswith('http'): + # Relative URL like "episodenguide/0/28673" + # Need to combine with base URL path + from urllib.parse import urljoin + season_url = urljoin(base_url, season_url) + + # Skip duplicates (extract staffel identifier for robust comparison) + # This ignores different series slugs (e.g., nachtstreife-2020 vs nachtstreife-2-0) + staffel_match = re.search(r'/(staffel-[^/]+/\d+)', season_url) + if staffel_match: + staffel_identifier = staffel_match.group(1).lower() + else: + # Fallback to full URL normalization for non-standard URLs + staffel_identifier = season_url.lower().rstrip('/') + + logger.debug(f"Season identifier: '{staffel_identifier}' from {season_url}") + + if staffel_identifier in seen_urls: + logger.debug(f"Skipping duplicate season URL: {season_url}") + continue + + # Skip "Übersicht" link + if season_name.lower() in ['übersicht', 'episoden']: + continue + + # Clean up season name: extract year if it's embedded in text + # e.g., "Bild: NDR2026" -> "2026" + # Look for 4-digit year anywhere in the string (without word boundaries) + year_match = re.search(r'(20\d{2}|19\d{2})', season_name) + if year_match: + year = year_match.group(1) + # Check if name starts with image caption (e.g., "Bild: NDR2026") + if re.match(r'^(Bild:|Foto:)', season_name, re.IGNORECASE): + season_name = year + + # Handle duplicate season names by adding Teil 2, Teil 3, etc. + # This happens when there are multiple seasons with the same name but different URLs + # (e.g., "2020" regular episodes vs "2020" specials) + existing_names = [name for name, _ in season_links] + if season_name in existing_names: + # Count how many times this base name already exists (including "Teil X" variants) + base_name = season_name + count = 1 + # Count both the base name and all "Teil X" variants + for name in existing_names: + if name == base_name or name.startswith(f"{base_name} Teil "): + count += 1 + original_name = season_name + season_name = f"{season_name} Teil {count}" + logger.debug(f"Duplicate season name detected: '{original_name}' -> '{season_name}' (URL: {season_url})") + + seen_urls.add(staffel_identifier) + season_links.append((season_name, season_url)) + logger.debug(f"Added season: '{season_name}' -> {season_url}") + + return season_links + + def _extract_seasons_from_overview(self, soup: BeautifulSoup, start_sort_order: int) -> List[ScrapedSeason]: + """ + Extract seasons that are shown directly on the overview page (e.g., Specials) + + Args: + soup: BeautifulSoup of overview page + start_sort_order: Starting sort order number + + Returns: + List of ScrapedSeason objects + """ + seasons = [] + sort_order = start_sort_order + + # Find sections with season headers (but no corresponding menu link) + # These are typically Specials or other special categories + sections = soup.find_all('section') + + for section in sections: + # Look for headers like "Specials", "Extras", etc. + header = section.find(['h2', 'h3'], id=re.compile(r'Special|Extra')) + + if not header: + continue + + season_name = header.get_text(strip=True) + + # Skip if this is just a navigation element + if 'karussell' in season_name.lower(): + continue + + # Extract episodes from this section + episodes = self._extract_episodes_from_page(section) + + if episodes: + season_type = self.classify_season_type(season_name) + + season = ScrapedSeason( + name=season_name, + season_type=season_type, + sort_order=sort_order, + episodes=episodes + ) + seasons.append(season) + sort_order += 1 + + return seasons + + def _extract_grouped_from_overview(self, soup: BeautifulSoup) -> List[ScrapedSeason]: + """ + Extract all episodes from overview page and group by episode code prefix. + Used for series like "Wer weiß denn sowas?" where all seasons have the same name. + + Args: + soup: BeautifulSoup of overview page + + Returns: + List of ScrapedSeason objects grouped by episode code prefix + """ + # First, try to find season links to get proper season numbers + # Pattern: /episodenguide/11/30583 -> Season 11 + season_links = soup.find_all('a', href=re.compile(r'episodenguide/(\d+)/')) + season_numbers = {} + for link in season_links: + href = link.get('href', '') + match = re.search(r'episodenguide/(\d+)/', href) + if match: + season_num = match.group(1) + season_numbers[season_num] = True + + # Find all episode rows + all_rows = soup.find_all('a', {'role': 'row', 'itemprop': 'episode'}) + + # Group episodes by their code prefix (e.g., "1.01" → "1", "2.001" → "2") + groups = {} + + for i, row in enumerate(all_rows, 1): + episode = self._parse_episode_row(row, i) + if not episode: + continue + + # Extract group from row title attribute (e.g., "1.01 Title" → "1") + # This is more reliable than episode_code which might be just "01" + row_title = row.get('title', '') + title_code_match = re.match(r'^(\d+)\.', row_title) + + if title_code_match: + group_key = title_code_match.group(1) + else: + # Fallback: try from episode code + code_match = re.match(r'^(\d+)[x.]', episode.episode_code) + if not code_match: + code_match = re.match(r'^(\d+)', episode.episode_code) + + if code_match: + group_key = code_match.group(1) + else: + group_key = episode.episode_code + + # Check title for special season indicators (XXL, Quizmarathon, etc.) + title_lower = episode.title.lower() + if 'xxl' in title_lower: + group_key = 'XXL' + elif 'quizmarathon' in title_lower: + group_key = 'Quizmarathon' + + if group_key not in groups: + groups[group_key] = [] + + groups[group_key].append(episode) + + # Convert groups to ScrapedSeason objects + seasons = [] + + # Sort by numeric key (treating group_key as integer when possible) + def sort_key(item): + group_key, _ = item + # Try to convert to int for proper numeric sorting + try: + if group_key.isdigit(): + return (0, int(group_key)) # Numeric groups first + else: + return (1, group_key) # Non-numeric groups (XXL, etc.) after + except: + return (1, group_key) + + for sort_order, (group_key, episode_list) in enumerate(sorted(groups.items(), key=sort_key)): + # Fix duplicate episode codes within this group + episode_list = self._fix_duplicate_episode_codes(episode_list) + + # Determine season name and type + if group_key == 'XXL': + season_name = "Wer weiß denn sowas XXL" + season_type = SeasonType.EXTRAS + elif group_key == 'Quizmarathon': + season_name = "Quizmarathon" + season_type = SeasonType.SPECIALS + elif group_key == '0': + season_name = "Specials" + season_type = SeasonType.SPECIALS + elif group_key.isdigit() and group_key in season_numbers: + # This is a proper season number from the URLs + season_name = f"Staffel {int(group_key)}" + season_type = SeasonType.NORMAL + else: + # Fallback: use year or group number + first_ep_date = next((ep.date_de_tv for ep in episode_list if ep.date_de_tv), None) + if first_ep_date: + season_name = str(first_ep_date.year) + season_type = SeasonType.YEAR_BASED + else: + season_name = f"Gruppe {group_key}" + season_type = SeasonType.NORMAL + + season = ScrapedSeason( + name=season_name, + season_type=season_type, + sort_order=sort_order, + episodes=episode_list + ) + seasons.append(season) + + return seasons + + def _extract_season_from_overview(self, soup: BeautifulSoup, season_name: str, sort_order: int) -> Optional[ScrapedSeason]: + """ + Extract episodes for a specific season from the overview page + This is used when individual season pages don't have episode_number data + + Args: + soup: BeautifulSoup of overview page + season_name: Name of season to extract (e.g., "Staffel 1") + sort_order: Sort order number + + Returns: + ScrapedSeason or None + """ + # Find all episode rows on overview page + all_episode_rows = soup.find_all('a', {'role': 'row', 'itemprop': 'episode'}) + + if not all_episode_rows: + return None + + # Extract episodes and filter by season + episodes = [] + seen_codes = {} + + for i, row in enumerate(all_episode_rows, 1): + # Parse the episode + episode = self._parse_episode_row(row, i) + + if not episode: + continue + + # Check if episode belongs to this season by looking at the URL + href = row.get('href', '') + + # Skip XXL or special versions if we're looking for plain season + # (These should be handled by their own dedicated pages) + if 'xxl' in href.lower() and ':' not in season_name.lower(): + continue + + # Extract season number from href like "/comedystreet/folgen/1x01-..." + season_match = re.search(r'/folgen/(\d+)x\d+', href) + + if not season_match: + continue + + # Convert season_name like "Staffel 1" to number + # Don't match "Staffel 1: Something" - only plain "Staffel 1" + season_num_match = re.search(r'^Staffel\s+(\d+)$', season_name.strip()) + if not season_num_match: + # Try without "Staffel" prefix - might be just "1", "2", etc. + season_num_match = re.search(r'^(\d+)$', season_name.strip()) + + if not season_num_match: + # This is a special season like "Staffel 1: XXL", skip it + # Those will be handled by their own dedicated pages + continue + + expected_season_num = int(season_num_match.group(1)) + actual_season_num = int(season_match.group(1)) + + if expected_season_num != actual_season_num: + continue + + # This episode belongs to the requested season + episodes.append(episode) + + if not episodes: + return None + + # Fix duplicate episode codes + episodes = self._fix_duplicate_episode_codes(episodes) + + season_type = self.classify_season_type(season_name) + + logger.debug(f"Scraped season '{season_name}': {len(episodes)} episodes found") + + return ScrapedSeason( + name=season_name, + season_type=season_type, + sort_order=sort_order, + episodes=episodes + ) + + def _scrape_season_page(self, season_name: str, season_url: str, sort_order: int) -> Optional[ScrapedSeason]: + """ + Scrape a single season page + + Args: + season_name: Name of the season + season_url: URL to season page + sort_order: Sort order number + + Returns: + ScrapedSeason or None + """ + try: + response = self.session.get(season_url, timeout=15) + response.raise_for_status() + except Exception as e: + print(f" Fehler beim Laden: {e}") + return None + + soup = BeautifulSoup(response.content, 'lxml') + + # Extract episodes from this page + episodes = self._extract_episodes_from_page(soup) + + if not episodes: + return None + + season_type = self.classify_season_type(season_name) + + logger.debug(f"Scraped season '{season_name}': {len(episodes)} episodes found") + + return ScrapedSeason( + name=season_name, + season_type=season_type, + sort_order=sort_order, + episodes=episodes + ) + + def _fix_duplicate_episode_codes(self, episodes: List[ScrapedEpisode]) -> List[ScrapedEpisode]: + """ + Fix duplicate episode codes by adding letter suffixes (a, b, c, etc.) + to ALL episodes that share the same code (including the first occurrence). + + For example, if three episodes have code "01", they become "01a", "01b", "01c". + + Args: + episodes: List of episodes that may contain duplicates + + Returns: + List of episodes with unique codes + """ + from collections import Counter + + # Count how many times each code appears + code_counts = Counter(ep.episode_code for ep in episodes) + + # Track which suffix to use for each code + code_suffixes = {} + + # Process each episode + for episode in episodes: + original_code = episode.episode_code + + # If this code appears more than once, add suffix to ALL occurrences + if code_counts[original_code] > 1: + # Get next suffix for this code (a, b, c, ...) + if original_code not in code_suffixes: + code_suffixes[original_code] = ord('a') + + suffix_char = chr(code_suffixes[original_code]) + episode.episode_code = f"{original_code}{suffix_char}" + code_suffixes[original_code] += 1 + + return episodes + + def _extract_episodes_from_page(self, soup: BeautifulSoup) -> List[ScrapedEpisode]: + """ + Extract episodes from a season page + + fernsehserien.de uses H3 headers for episode titles within section elements + OR section elements with itemprop="episode" (alternative layout) + """ + episodes = [] + + # Try method 1: Find all H3 elements that start with a number or "Folge X" + all_h3 = soup.find_all('h3') + # Accept both "41. Title" and "Folge 42" formats + episode_h3s = [h3 for h3 in all_h3 if re.match(r'^(\d+[a-z]?\.|Folge\s+\d+)', h3.get_text(strip=True))] + + if episode_h3s: + # Method 1: H3-based extraction (main overview page with detailed episode info) + for h3 in episode_h3s: + episode = self._parse_episode_h3(h3) + if episode: + episodes.append(episode) + else: + # Method 2: Try overview page table format with + # This format has episode_number in the cells + episode_rows = soup.find_all('a', {'role': 'row', 'itemprop': 'episode'}) + if episode_rows: + for i, row in enumerate(episode_rows, 1): + episode = self._parse_episode_row(row, i) + if episode: + episodes.append(episode) + else: + # Method 3: Try alternative layout with section[itemprop="episode"] + # This is used on some dedicated season pages (e.g., ComedyStreet Staffel 6+) + episode_sections = soup.find_all('section', itemprop='episode') + for i, section in enumerate(episode_sections, 1): + episode = self._parse_episode_section(section, i) + if episode: + episodes.append(episode) + + # After collecting all episodes, handle duplicate episode_codes + # This ensures ALL episodes with the same code get suffixes (a, b, c, etc.) + episodes = self._fix_duplicate_episode_codes(episodes) + + return episodes + + def _parse_episode_h3(self, h3) -> Optional[ScrapedEpisode]: + """ + Parse an episode from an H3 header + + Format: "1. Episode Title (Original Title)" + The parent section contains date information in text format + """ + title_text = h3.get_text(strip=True) + + # Extract episode_number (overall series number) from H3 text + # Example: "111. Episode Title" -> episode_number = 111 + # Example: "Folge 42" -> episode_number = 42 + episode_number = None + number_match = re.match(r'^(\d+)[a-z]?\.', title_text) + if not number_match: + # Try "Folge X" format + number_match = re.match(r'^Folge\s+(\d+)', title_text) + if number_match: + try: + episode_number = int(number_match.group(1)) + except ValueError: + pass + + # Extract title (everything after the number or "Folge X") + title_match = re.match(r'^\d+[a-z]?\.?\s*(.+)', title_text) + if not title_match: + # Try "Folge X Title" format + title_match = re.match(r'^Folge\s+\d+\s*(.+)?', title_text) + if title_match: + title = title_match.group(1).strip() if title_match.group(1) else title_text + else: + title = title_text + + # Remove English title in parentheses if present + # Example: "Gewöhnliche Leute(Nosedive)" -> "Gewöhnliche Leute" + # Example: "White Christmas" -> "White Christmas" (no change if no German title) + title = re.sub(r'\s*\([^)]+\)\s*$', '', title).strip() + + # Get the parent section that contains date information + section = h3.find_parent('section') + if not section: + # Fallback: just return episode without dates + return ScrapedEpisode( + episode_code="00", + title=title, + episode_number=episode_number + ) + + # Extract episode_code (season-specific episode number) and episode_id from episode link + # Format: /folgen/12x01-title-1828679 or /folgen/01-title-1828679 (for specials) + episode_code = None + episode_id = None + episode_link = section.find('a', href=re.compile(r'/folgen/')) + if episode_link: + href = episode_link.get('href', '') + # Extract episode_id (last number in URL) + episode_id_match = re.search(r'-(\d+)$', href) + if episode_id_match: + episode_id = episode_id_match.group(1) + + # Try format: /folgen/12x01-... (regular episodes) + season_episode_match = re.search(r'/folgen/(\d+)x(\d+)', href) + if season_episode_match: + episode_code = season_episode_match.group(2).zfill(2) + else: + # Try format: /folgen/01-... (specials without season prefix) + special_match = re.search(r'/folgen/(\d+)-', href) + if special_match: + episode_code = special_match.group(1).zfill(2) + + # Fallback: extract from H3 text if link extraction failed + # Examples: "0.01 Title" -> "01", "1. Title" -> "01", "12a. Title" -> "12a" + if not episode_code: + # Try to find pattern like "X.YY" in title text (e.g., "0.01") + decimal_match = re.match(r'^\d+\.(\d+[a-z]?)', title_text) + if decimal_match: + ep_num = decimal_match.group(1) + if ep_num.isdigit(): + episode_code = ep_num.zfill(2) + else: + # Handle cases like "12a" + episode_code = ep_num[:-1].zfill(2) + ep_num[-1] if len(ep_num) >= 2 else ep_num.zfill(2) + else: + # Last resort: use the episode number from start of H3 + episode_code = self.extract_episode_code(title_text) + + # Extract dates from the section text + section_text = section.get_text() + dates = self._extract_dates_from_text(section_text) + + return ScrapedEpisode( + episode_code=episode_code, + title=title, + episode_number=episode_number, + episode_id=episode_id, + date_de_tv=dates.get('de_tv'), + date_de_streaming=dates.get('de_streaming'), + date_de_home_media=dates.get('de_home_media'), + date_de_sync=dates.get('de_sync'), + date_original=dates.get('original') + ) + + def _parse_episode_section(self, section, fallback_number: int) -> Optional[ScrapedEpisode]: + """ + Parse an episode from a section element with itemprop="episode" + Used on dedicated season pages with alternative layout (e.g., ComedyStreet Staffel 1-5) + + Args: + section: BeautifulSoup section element + fallback_number: Episode number to use if can't extract from URL + """ + # Extract title from itemprop="name" + title_elem = section.find(itemprop='name') + if not title_elem: + return None + + title = title_elem.get_text(strip=True) + + # Remove English title in parentheses if present + title = re.sub(r'\s*\([^)]+\)\s*$', '', title).strip() + + # Try to extract episode info from URL: href="/series/folgen/1x01-title-1828679" + url_elem = section.find('a', itemprop='url') + episode_code = None + episode_number = None + episode_id = None + + if url_elem: + href = url_elem.get('href', '') + # Extract episode_id (last number in URL) + episode_id_match = re.search(r'-(\d+)$', href) + if episode_id_match: + episode_id = episode_id_match.group(1) + + # Pattern: /folgen/1x01-... or /folgen/SxE-... + match = re.search(r'/folgen/(\d+)x(\d+)', href) + if match: + episode_code = match.group(2).zfill(2) + + # Try to extract episode_number from
+ # Structure on overview page:
01
+ # The content attribute contains the overall episode number + cell_div = section.find('div', itemprop='episodeNumber') + if cell_div: + # Extract from content attribute + content_attr = cell_div.get('content') + if content_attr: + try: + episode_number = int(content_attr) + except (ValueError, TypeError): + pass + + # Fallback: use sequential numbering + if not episode_code: + episode_code = str(fallback_number).zfill(2) + + # Extract dates + section_text = section.get_text() + dates = self._extract_dates_from_text(section_text) + + return ScrapedEpisode( + episode_code=episode_code, + title=title, + episode_number=episode_number, + episode_id=episode_id, + date_de_tv=dates.get('de_tv'), + date_de_streaming=dates.get('de_streaming'), + date_de_home_media=dates.get('de_home_media'), + date_de_sync=dates.get('de_sync'), + date_original=dates.get('original') + ) + + def _parse_episode_row(self, row, fallback_number: int) -> Optional[ScrapedEpisode]: + """ + Parse an episode from a row element (
) + Used on overview pages with table format + + Args: + row: BeautifulSoup element with role="row" + fallback_number: Episode number to use if can't extract + """ + # Extract episode_id from row href (e.g., /series/folgen/1x01-title-1828679) + episode_id = None + href = row.get('href', '') + if href: + episode_id_match = re.search(r'-(\d+)$', href) + if episode_id_match: + episode_id = episode_id_match.group(1) + + # Get all cells in the row + cells = row.find_all('div', role='cell') + + if len(cells) < 7: + return None + + # Cell structure based on test output: + # Cell 2: Contains overall episode number (before span) + season.episode (in span) + # Cell 5: Contains episode_code with itemprop="episodeNumber" + # Cell 7: Contains title with itemprop="name" + # Cell 8: Contains date + + # Extract overall episode_number from cell 2 + episode_number = None + if len(cells) >= 2: + cell2 = cells[1] # Index 1 = cell 2 + # Structure:
11.01
+ # Extract the number before the span + cell2_text = '' + for child in cell2.children: + if isinstance(child, str): + cell2_text += child.strip() + else: + # Stop at first tag (the span) + break + + if cell2_text: + try: + episode_number = int(cell2_text) + except ValueError: + pass + + # Extract episode_code from cell 5 (itemprop="episodeNumber") + episode_code = None + ep_code_cell = row.find('div', itemprop='episodeNumber') + if ep_code_cell: + code_text = ep_code_cell.get_text(strip=True) + if code_text: + episode_code = code_text.zfill(2) + + # Fallback for episode_code + if not episode_code: + episode_code = str(fallback_number).zfill(2) + + # Extract title from cell 7 + title = "" + title_cell = row.find('div', class_='episodenliste-2019-episodentitel') + if title_cell: + title_elem = title_cell.find(itemprop='name') + if title_elem: + title = title_elem.get_text(strip=True) + # Remove English title in parentheses if present + title = re.sub(r'\s*\([^)]+\)\s*$', '', title).strip() + + # Extract date from cell 8 (simple date text) + date_de_tv = None + if len(cells) >= 8: + date_cell = cells[7] # Index 7 = cell 8 + date_text = date_cell.get_text(strip=True) + if date_text: + date_de_tv = self.parse_german_date(date_text) + + return ScrapedEpisode( + episode_code=episode_code, + title=title, + episode_number=episode_number, + episode_id=episode_id, + date_de_tv=date_de_tv, + date_de_streaming=None, + date_de_home_media=None, + date_de_sync=None, + date_original=None + ) + + def _extract_dates_from_text(self, text: str) -> Dict[str, Optional[datetime]]: + """ + Extract dates from plain text containing German date labels + + Expected format: + - "Deutsche TV-Premiere Mi. 11.12.2013 RTL Crime" + - "Deutsche Streaming-Premiere Fr. 21.10.2016 Netflix" + - "Deutsche Home-Media-Premiere Do. 21.11.2024" + - "Original-TV-Premiere So. 04.12.2011 Channel 4" + - "Premiere der deutschen Synchronfassung ..." + """ + dates = { + 'de_tv': None, + 'de_streaming': None, + 'de_home_media': None, + 'de_sync': None, + 'original': None + } + + # Search for "Deutsche TV-Premiere" followed by a date + match = re.search(r'Deutsche\s+TV-Premiere\s+\w+\.\s+(\d{1,2}\.\d{1,2}\.\d{2,4})', text) + if match: + dates['de_tv'] = self.parse_german_date(match.group(1)) + + # Search for "Deutsche Streaming-Premiere" + match = re.search(r'Deutsche\s+Streaming-Premiere\s+\w+\.\s+(\d{1,2}\.\d{1,2}\.\d{2,4})', text) + if match: + dates['de_streaming'] = self.parse_german_date(match.group(1)) + + # Search for "Deutsche Home-Media-Premiere" + match = re.search(r'Deutsche\s+Home-Media-Premiere\s+\w+\.\s+(\d{1,2}\.\d{1,2}\.\d{2,4})', text) + if match: + dates['de_home_media'] = self.parse_german_date(match.group(1)) + + # Search for "Premiere der deutschen Synchronfassung" + match = re.search(r'Premiere\s+der\s+deutschen\s+Synchronfassung\s+\w+\.\s+(\d{1,2}\.\d{1,2}\.\d{2,4})', text) + if match: + dates['de_sync'] = self.parse_german_date(match.group(1)) + + # Search for "Original-TV-Premiere" or "Original-Streaming-Premiere" + match = re.search(r'Original-(?:TV|Streaming)-Premiere\s+\w+\.\s+(\d{1,2}\.\d{1,2}\.\d{2,4})', text) + if match: + dates['original'] = self.parse_german_date(match.group(1)) + + return dates + + + + +def main(): + """Test the scraper""" + if len(sys.argv) < 2: + print("Usage: python fernsehserien_scraper.py ") + print("Example: python fernsehserien_scraper.py https://www.fernsehserien.de/black-mirror/episodenguide") + sys.exit(1) + + url = sys.argv[1] + + scraper = FernsehserienScraper() + title, seasons = scraper.scrape_series(url) + + print(f"\n=== {title} ===") + print(f"Staffeln gesamt: {len(seasons)}\n") + + for season in seasons: + print(f"{season.name} ({season.season_type.value}): {len(season.episodes)} Episoden") + for ep in season.episodes[:3]: # Show first 3 + dates = [] + if ep.date_original: + dates.append(f"Orig: {ep.date_original.strftime('%d.%m.%Y')}") + if ep.date_de_tv: + dates.append(f"DE: {ep.date_de_tv.strftime('%d.%m.%Y')}") + + date_str = ", ".join(dates) if dates else "Keine Daten" + print(f" {ep.episode_code}. {ep.title} ({date_str})") + + if len(season.episodes) > 3: + print(f" ... und {len(season.episodes) - 3} weitere") + print() + + +if __name__ == "__main__": + main() diff --git a/serien_checker/ui/__init__.py b/serien_checker/ui/__init__.py new file mode 100644 index 0000000..4bf4c60 --- /dev/null +++ b/serien_checker/ui/__init__.py @@ -0,0 +1 @@ +"""PyQt5 user interface components""" diff --git a/serien_checker/ui/main_window.py b/serien_checker/ui/main_window.py new file mode 100644 index 0000000..aa80535 --- /dev/null +++ b/serien_checker/ui/main_window.py @@ -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'
fernsehserien.de öffnen') + + # 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() diff --git a/serien_checker/ui/options_dialog.py b/serien_checker/ui/options_dialog.py new file mode 100644 index 0000000..31c8660 --- /dev/null +++ b/serien_checker/ui/options_dialog.py @@ -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 diff --git a/serien_checker/ui/widgets.py b/serien_checker/ui/widgets.py new file mode 100644 index 0000000..d829d03 --- /dev/null +++ b/serien_checker/ui/widgets.py @@ -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) diff --git a/serien_checker/utils/__init__.py b/serien_checker/utils/__init__.py new file mode 100644 index 0000000..c8b8b5f --- /dev/null +++ b/serien_checker/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions and helpers""" diff --git a/serien_checker/utils/logger.py b/serien_checker/utils/logger.py new file mode 100644 index 0000000..76fd88e --- /dev/null +++ b/serien_checker/utils/logger.py @@ -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 diff --git a/serien_checker/utils/threading.py b/serien_checker/utils/threading.py new file mode 100644 index 0000000..656348c --- /dev/null +++ b/serien_checker/utils/threading.py @@ -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}") diff --git a/start.bat b/start.bat deleted file mode 100644 index 03fa647..0000000 --- a/start.bat +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -start "" /b pythonw.exe serien_checker.py \ No newline at end of file