diff --git a/README.md b/README.md index ad6132e..773afbd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # BingeWatcher Automated binge-watching helper for **s.to** and **aniworld.to** with progress -tracking, intro/end skipping, and a modern sidebar UI for quick navigation. + tracking, end-screen skipping, and a modern sidebar UI for quick navigation. > **Strict disclaimer**: I do **not** support, endorse, or encourage the use of > this script. It is published **for educational review only**. Do **not** use @@ -15,7 +15,7 @@ tracking, intro/end skipping, and a modern sidebar UI for quick navigation. - **Multi-provider support**: s.to and aniworld.to with automatic provider detection. - **Progress tracking**: resume by series/season/episode with saved timestamps. -- **Smart intro skip**: per-series intro start/end times. +- **Optional intro skip**: fingerprint-configured per season. - **End screen skip**: jump past credits/outro if configured. - **Auto fullscreen**: multiple fallback strategies for stubborn players. - **Sidebar UI**: search, sort, quick actions, and settings panel. @@ -66,7 +66,6 @@ The script will: | --- | --- | --- | | `BW_HEADLESS` | `false` | Run Firefox headless (`true/false`). | | `BW_START_URL` | `https://s.to/` | Start URL (provider homepage). | -| `BW_INTRO_SKIP` | `80` | Default intro skip (seconds). | | `BW_MAX_RETRIES` | `3` | Navigation retry count. | | `BW_WAIT_TIMEOUT` | `25` | Page load wait timeout. | | `BW_PROGRESS_INTERVAL` | `5` | Progress save interval (seconds). | @@ -81,28 +80,56 @@ Important keys: - `useTorProxy` (boolean) - `autoFullscreen` (boolean) -- `autoSkipIntro` (boolean) - `autoSkipEndScreen` (boolean) - `autoNext` (boolean) - `playbackRate` (number) - `volume` (number, `0.0`–`1.0`) +### Intro fingerprints (optional) + +To enable intro skipping per season, create `intro_fingerprints.json` with keys +like `_s`, for example `one_piece_s07`: + +```json +{ + "one_piece_s07": { + "fingerprint": "A_LONG_FP_STRING", + "fingerprintDuration": 10, + "fullIntroDurationSeconds": 145 + } +} +``` + +If `fingerprint` is omitted but `fullIntroDurationSeconds` is present, the +player will skip the first N seconds at the start of the episode. If a +fingerprint is present, an external matcher can signal a match by writing the +matched key into `localStorage` as `bw_intro_fp_match`. You can also edit these +values per series/season from the in-app “Skip Settings” panel. + +To generate a fingerprint without editing JSON, drop your MP3 into +`SerienJunkie/intro_uploads/`, open the per-series “Skip Settings” panel, and +select the file in the “Select MP3 (intro_uploads)” picker. The app will +generate the fingerprint, save it into `intro_fingerprints.json`, and delete the +MP3 after processing. + ## Data Files - `progress.json`: persisted progress by series. -- `intro_times.json`: optional default intro windows by season. +- `intro_fingerprints.json`: optional intro fingerprint configuration. - `settings.json`: app settings. ## Sidebar Highlights - **Series list** with last watched time. - **Provider tabs** to filter s.to vs. aniworld.to. -- **Per-series controls** for intro and end skip windows. +- **Per-series controls** for intro duration/fingerprint and end skip windows. - **Quick actions**: skip episode, open settings, quit. ## Troubleshooting - **GeckoDriver not found**: Ensure `geckodriver.exe` sits next to `s.toBot.py`. +- **Chromaprint (fpcalc) missing**: Install Chromaprint so the MP3 fingerprint + generator can run (`fpcalc` must be on PATH). - **Video not playing**: Refresh the page or press Space to play. - **Sidebar missing**: Reload; some pages block injection until fully loaded. @@ -115,7 +142,8 @@ SerienJunkie/ ├── README.md # This file ├── geckodriver.exe # Firefox WebDriver ├── progress.json # Progress database (auto-created) -├── intro_times.json # Optional intro presets +├── intro_fingerprints.json # Optional intro fingerprint data +├── intro_uploads/ # Optional MP3 drop folder └── user.BingeWatcher/ # Firefox profile (auto-created) ``` diff --git a/SerienJunkie/README_INTRO_DETECTION.md b/SerienJunkie/README_INTRO_DETECTION.md deleted file mode 100644 index 9405e3d..0000000 --- a/SerienJunkie/README_INTRO_DETECTION.md +++ /dev/null @@ -1,179 +0,0 @@ -# Smart Intro Detection System - -## Overview - -The BingeWatcher now includes a smart intro detection system that automatically detects when an anime intro is playing and only skips it when appropriate. This prevents skipping during recaps, episode previews, or episodes without intros. - -## Features - -### 1. Smart Intro Detection -- **Time-based detection**: Uses predefined intro time windows for each anime series -- **Pattern-based detection**: Looks for intro-related keywords in the page content -- **Season-specific data**: Different intro times for different seasons of the same anime -- **Fallback protection**: Only skips when confident an intro is playing - -### 2. Dual Input System -- **Start time**: When the intro typically begins (e.g., 85 seconds) -- **End time**: When the intro typically ends (e.g., 145 seconds) -- **Individual control**: Users can adjust both start and end times per series - -### 3. Comprehensive Database -The system includes intro times for popular anime series: - -#### One Piece (All Seasons) -- **Start**: 85 seconds -- **End**: 145 seconds -- **Detection patterns**: "opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat" - -#### Naruto (All Seasons) -- **Start**: 80 seconds -- **End**: 140 seconds -- **Detection patterns**: "opening", "intro", "naruto", "believe it", "ninja", "hidden leaf" - -#### One Punch Man (Seasons 1-2) -- **Start**: 85 seconds -- **End**: 145 seconds -- **Detection patterns**: "opening", "intro", "one punch", "hero", "saitama", "bald" - -#### Jujutsu Kaisen (Seasons 1-2) -- **Start**: 85 seconds -- **End**: 145 seconds -- **Detection patterns**: "opening", "intro", "jujutsu", "kaisen", "curse", "sorcerer", "gojo" - -#### Attack on Titan (Seasons 1-4) -- **Start**: 80 seconds -- **End**: 140 seconds -- **Detection patterns**: "opening", "intro", "attack", "titan", "shingeki", "eren", "mikasa", "levi" - -#### Dandadan (Season 1) -- **Start**: 85 seconds -- **End**: 145 seconds -- **Detection patterns**: "opening", "intro", "dandadan", "supernatural", "alien", "ghost", "momo" - -#### Kaiju No. 8 (Season 1) -- **Start**: 85 seconds -- **End**: 145 seconds -- **Detection patterns**: "opening", "intro", "kaiju", "monster", "defense force", "kafka" - -## How It Works - -### 1. Detection Process -1. **Time Check**: Verifies if current video time is within the intro window -2. **Pattern Analysis**: Searches page content for intro-related keywords -3. **Confidence Assessment**: Only skips if both time and pattern conditions are met -4. **Safe Skip**: Jumps to the end time of the intro - -### 2. User Interface -- **Dual Input Fields**: Separate "Start" and "End" time inputs for each series -- **Real-time Updates**: Changes are applied immediately -- **Visual Feedback**: Clear indication of current intro skip settings - -### 3. Configuration Files - -#### `intro_times.json` -Contains the master database of intro times and detection patterns: -```json -{ - "series-name": { - "name": "Display Name", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "keywords"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - } -} -``` - -#### `progress.json` -Stores user-specific intro skip times: -```json -{ - "series-name": { - "intro_skip_start": 85, - "intro_skip_end": 145 - } -} -``` - -## Usage - -### Automatic Detection -The system automatically detects and skips intros when: -- Auto-skip intro is enabled in settings -- Current video time is within the intro window -- Intro-related patterns are detected in the page - -### Manual Adjustment -1. Open the BingeWatcher sidebar -2. Find your series in the list -3. Adjust the "Start" and "End" time inputs -4. Changes are applied automatically - -### Adding New Series -To add intro times for a new series: - -1. **Edit `intro_times.json`**: - ```json - { - "new-series": { - "name": "New Series Name", - "intros": [ - { - "season": 1, - "start_time": 90, - "end_time": 150, - "detection_patterns": ["opening", "intro", "series-specific-keywords"] - } - ], - "default_skip_start": 90, - "default_skip_end": 150 - } - } - ``` - -2. **Add to `progress.json`** (optional, will use defaults if not present): - ```json - { - "new-series": { - "intro_skip_start": 90, - "intro_skip_end": 150 - } - } - ``` - -## Benefits - -1. **Prevents False Skips**: Won't skip during recaps or episode previews -2. **Season Awareness**: Handles different intro times per season -3. **User Customization**: Individual control over skip times -4. **Smart Fallback**: Uses pattern detection as backup to time-based detection -5. **Easy Maintenance**: Centralized database for intro times - -## Technical Details - -### Detection Algorithm -```python -def detect_intro_start(driver, series: str, season: int = 1) -> bool: - # 1. Get current video time - # 2. Check if within intro time window - # 3. Search page for detection patterns - # 4. Return True if both conditions met -``` - -### Smart Skip Function -```python -def smart_skip_intro(driver, series: str, season: int = 1): - # 1. Wait for video to be ready - # 2. Get intro times from database - # 3. Detect if intro is playing - # 4. Skip to end time if detected - # 5. Fall back to simple skip if detection fails -``` - -This system provides a much more intelligent and user-friendly approach to intro skipping, ensuring that users only skip actual intros and not other content. diff --git a/SerienJunkie/intro_fingerprints.json b/SerienJunkie/intro_fingerprints.json new file mode 100644 index 0000000..02afacf --- /dev/null +++ b/SerienJunkie/intro_fingerprints.json @@ -0,0 +1,7 @@ +{ + "one_piece_s07": { + "fingerprint": "A_LONG_FP_STRING", + "fingerprintDuration": 10, + "fullIntroDurationSeconds": 145 + } +} diff --git a/SerienJunkie/intro_times.json b/SerienJunkie/intro_times.json deleted file mode 100644 index dddaec8..0000000 --- a/SerienJunkie/intro_times.json +++ /dev/null @@ -1,286 +0,0 @@ -{ - "one-piece": { - "name": "One Piece", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 2, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 3, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 4, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 5, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 6, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 7, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 8, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 9, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 10, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 11, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 12, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 13, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 14, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 15, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 16, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 17, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 18, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 19, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - }, - { - "season": 20, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "we are", "yo ho ho", "mugiwara", "straw hat", "one piece", "pirate", "luffy", "nakama", "treasure", "grand line", "devil fruit", "gomu gomu", "rubber", "crew", "ship", "going merry", "thousand sunny"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - }, - "naruto": { - "name": "Naruto", - "intros": [ - { - "season": 1, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "naruto", "believe it", "ninja", "hidden leaf", "konoha", "uzumaki", "sasuke", "sakura", "kakashi", "team 7", "chakra", "jutsu", "ninja way", "dattebayo", "hokage", "village", "shinobi", "ninjutsu"] - }, - { - "season": 2, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "naruto", "believe it", "ninja", "hidden leaf", "konoha", "uzumaki", "sasuke", "sakura", "kakashi", "team 7", "chakra", "jutsu", "ninja way", "dattebayo", "hokage", "village", "shinobi", "ninjutsu"] - }, - { - "season": 3, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "naruto", "believe it", "ninja", "hidden leaf", "konoha", "uzumaki", "sasuke", "sakura", "kakashi", "team 7", "chakra", "jutsu", "ninja way", "dattebayo", "hokage", "village", "shinobi", "ninjutsu"] - }, - { - "season": 4, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "naruto", "believe it", "ninja", "hidden leaf", "konoha", "uzumaki", "sasuke", "sakura", "kakashi", "team 7", "chakra", "jutsu", "ninja way", "dattebayo", "hokage", "village", "shinobi", "ninjutsu"] - }, - { - "season": 5, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "naruto", "believe it", "ninja", "hidden leaf", "konoha", "uzumaki", "sasuke", "sakura", "kakashi", "team 7", "chakra", "jutsu", "ninja way", "dattebayo", "hokage", "village", "shinobi", "ninjutsu"] - } - ], - "default_skip_start": 80, - "default_skip_end": 140 - }, - "one-punch-man": { - "name": "One Punch Man", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "one punch", "hero", "saitama", "bald", "genos", "hero association", "monster", "cyborg", "superhero", "punch", "serious", "hero hunter", "garou", "class s", "class a", "class b", "class c"] - }, - { - "season": 2, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "one punch", "hero", "saitama", "bald", "genos", "hero association", "monster", "cyborg", "superhero", "punch", "serious", "hero hunter", "garou", "class s", "class a", "class b", "class c"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - }, - "jujutsu-kaisen": { - "name": "Jujutsu Kaisen", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "jujutsu", "kaisen", "curse", "sorcerer", "gojo", "itadori", "megumi", "nobara", "sukuna", "finger", "curse technique", "domain expansion", "shibuya", "kyoto", "tokyo", "jujutsu high", "curse energy", "reverse cursed technique"] - }, - { - "season": 2, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "jujutsu", "kaisen", "curse", "sorcerer", "gojo", "itadori", "megumi", "nobara", "sukuna", "finger", "curse technique", "domain expansion", "shibuya", "kyoto", "tokyo", "jujutsu high", "curse energy", "reverse cursed technique"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - }, - "attack-on-titan": { - "name": "Attack on Titan", - "intros": [ - { - "season": 1, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "attack", "titan", "shingeki", "eren", "mikasa", "levi", "survey corps", "scout regiment", "wall maria", "wall rose", "wall sina", "trost", "shiganshina", "paradis", "marley", "yeager", "ackerman", "titan shifter", "colossal", "armored", "beast", "cart", "war hammer", "jaw", "female", "attack titan", "founding titan"] - }, - { - "season": 2, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "attack", "titan", "shingeki", "eren", "mikasa", "levi", "survey corps", "scout regiment", "wall maria", "wall rose", "wall sina", "trost", "shiganshina", "paradis", "marley", "yeager", "ackerman", "titan shifter", "colossal", "armored", "beast", "cart", "war hammer", "jaw", "female", "attack titan", "founding titan"] - }, - { - "season": 3, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "attack", "titan", "shingeki", "eren", "mikasa", "levi", "survey corps", "scout regiment", "wall maria", "wall rose", "wall sina", "trost", "shiganshina", "paradis", "marley", "yeager", "ackerman", "titan shifter", "colossal", "armored", "beast", "cart", "war hammer", "jaw", "female", "attack titan", "founding titan"] - }, - { - "season": 4, - "start_time": 80, - "end_time": 140, - "detection_patterns": ["opening", "intro", "attack", "titan", "shingeki", "eren", "mikasa", "levi", "survey corps", "scout regiment", "wall maria", "wall rose", "wall sina", "trost", "shiganshina", "paradis", "marley", "yeager", "ackerman", "titan shifter", "colossal", "armored", "beast", "cart", "war hammer", "jaw", "female", "attack titan", "founding titan"] - } - ], - "default_skip_start": 80, - "default_skip_end": 140 - }, - "demon-slayer-kimetsu-no-yaiba": { - "name": "Demon Slayer: Kimetsu no Yaiba", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "demon slayer", "kimetsu", "yaiba", "tanjiro", "nezuko", "zenitsu", "inosuke", "demon", "slayer", "hashira", "muzan", "kibutsuji", "breathing", "water breathing", "thunder breathing", "wind breathing", "flame breathing", "mist breathing", "love breathing", "sound breathing", "serpent breathing", "stone breathing", "insect breathing", "nichirin", "sword", "corps", "butterfly mansion", "final selection", "mount natagumo", "entertainment district", "swordsmith village", "hashira training", "infinity castle", "sunrise countdown"] - }, - { - "season": 2, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "demon slayer", "kimetsu", "yaiba", "tanjiro", "nezuko", "zenitsu", "inosuke", "demon", "slayer", "hashira", "muzan", "kibutsuji", "breathing", "water breathing", "thunder breathing", "wind breathing", "flame breathing", "mist breathing", "love breathing", "sound breathing", "serpent breathing", "stone breathing", "insect breathing", "nichirin", "sword", "corps", "butterfly mansion", "final selection", "mount natagumo", "entertainment district", "swordsmith village", "hashira training", "infinity castle", "sunrise countdown"] - }, - { - "season": 3, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "demon slayer", "kimetsu", "yaiba", "tanjiro", "nezuko", "zenitsu", "inosuke", "demon", "slayer", "hashira", "muzan", "kibutsuji", "breathing", "water breathing", "thunder breathing", "wind breathing", "flame breathing", "mist breathing", "love breathing", "sound breathing", "serpent breathing", "stone breathing", "insect breathing", "nichirin", "sword", "corps", "butterfly mansion", "final selection", "mount natagumo", "entertainment district", "swordsmith village", "hashira training", "infinity castle", "sunrise countdown"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - }, - "dan-da-dan": { - "name": "Dandadan", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "dandadan", "supernatural", "alien", "ghost", "momo", "okarun", "turbo granny", "serpo", "yokai", "spirit", "ufo", "extraterrestrial", "paranormal", "occult", "psychic", "medium", "exorcist", "supernatural power", "alien invasion", "ghost hunting", "spirit world", "otherworldly", "mysterious", "supernatural phenomenon"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - }, - "kaiju-no-8": { - "name": "Kaiju No. 8", - "intros": [ - { - "season": 1, - "start_time": 85, - "end_time": 145, - "detection_patterns": ["opening", "intro", "kaiju", "monster", "defense force", "kafka", "hibino", "ichikawa", "mina", "ashiro", "japan", "tokyo", "monster attack", "kaiju attack", "defense corps", "monster hunter", "kaiju hunter", "superhuman", "power suit", "monster weapon", "kaiju weapon", "defense technology", "monster technology", "kaiju technology", "superhuman strength", "monster strength", "kaiju strength", "defense mission", "monster mission", "kaiju mission"] - } - ], - "default_skip_start": 85, - "default_skip_end": 145 - } -} diff --git a/SerienJunkie/intro_uploads/.gitkeep b/SerienJunkie/intro_uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/SerienJunkie/intro_uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/SerienJunkie/progress.json b/SerienJunkie/progress.json index b03828f..48942a9 100644 --- a/SerienJunkie/progress.json +++ b/SerienJunkie/progress.json @@ -5,8 +5,6 @@ "position": 0, "timestamp": 0, "provider": "s.to", - "intro_skip_start": 80, - "intro_skip_end": 140, "end_skip": 0 }, "naruto": { @@ -15,8 +13,6 @@ "position": 0, "timestamp": 0, "provider": "s.to", - "intro_skip_start": 80, - "intro_skip_end": 140, "end_skip": 0 }, "one-punch-man": { @@ -25,8 +21,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 85, - "intro_skip_end": 145, "end_skip": 0 }, "jujutsu-kaisen": { @@ -35,8 +29,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 85, - "intro_skip_end": 145, "end_skip": 0 }, "attack-on-titan": { @@ -45,8 +37,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 80, - "intro_skip_end": 140, "end_skip": 0 }, "demon-slayer-kimetsu-no-yaiba": { @@ -55,8 +45,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 85, - "intro_skip_end": 145, "end_skip": 0 }, "dan-da-dan": { @@ -65,8 +53,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 85, - "intro_skip_end": 145, "end_skip": 0 }, "kaiju-no-8": { @@ -75,8 +61,6 @@ "position": 0, "timestamp": 0, "provider": "aniworld.to", - "intro_skip_start": 85, - "intro_skip_end": 145, "end_skip": 0 } -} \ No newline at end of file +} diff --git a/SerienJunkie/s.toBot.py b/SerienJunkie/s.toBot.py index f258545..4116589 100644 --- a/SerienJunkie/s.toBot.py +++ b/SerienJunkie/s.toBot.py @@ -3,8 +3,11 @@ import logging import os import re +import shutil +import subprocess +import tempfile import time -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Set from urllib.parse import unquote from selenium import webdriver @@ -21,7 +24,6 @@ # === CONFIGURATION === HEADLESS: bool = os.getenv("BW_HEADLESS", "false").lower() in {"1", "true", "yes"} START_URL: str = os.getenv("BW_START_URL", "https://s.to/") -INTRO_SKIP_SECONDS: int = int(os.getenv("BW_INTRO_SKIP", "80")) MAX_RETRIES: int = int(os.getenv("BW_MAX_RETRIES", "3")) WAIT_TIMEOUT: int = int(os.getenv("BW_WAIT_TIMEOUT", "25")) PROGRESS_SAVE_INTERVAL: int = int(os.getenv("BW_PROGRESS_INTERVAL", "5")) @@ -31,6 +33,14 @@ PROGRESS_DB_FILE = os.path.join(SCRIPT_DIR, "progress.json") SETTINGS_DB_FILE = os.path.join(SCRIPT_DIR, "settings.json") +INTRO_FINGERPRINTS_FILE = os.path.join(SCRIPT_DIR, "intro_fingerprints.json") +INTRO_UPLOAD_DIR = os.path.join(SCRIPT_DIR, "intro_uploads") + + +INTRO_AUTO_MATCH_CACHE: Dict[str, bool] = {} +INTRO_AUTO_LISTEN_LOGGED: Dict[str, bool] = {} +INTRO_AUTO_REASON_LOGGED: Dict[str, str] = {} +INTRO_VIDEO_SRC_LOGGED: Dict[str, bool] = {} # === STREAMING PROVIDERS === STREAMING_PROVIDERS = { @@ -144,186 +154,597 @@ def handle_list_item_deletion(name: str) -> bool: return False -def get_intro_skip_seconds(series: str) -> int: - try: - data = load_progress().get(series, {}) - val = int(data.get("intro_skip_start", INTRO_SKIP_SECONDS)) - return max(0, val) - except Exception: - return INTRO_SKIP_SECONDS - -def get_intro_skip_end_seconds(series: str) -> int: +def get_end_skip_seconds(series: str) -> int: try: data = load_progress().get(series, {}) - val = int(data.get("intro_skip_end", INTRO_SKIP_SECONDS + 60)) + val = int(data.get("end_skip", 0)) return max(0, val) except Exception: - return INTRO_SKIP_SECONDS + 60 + return 0 -def set_intro_skip_seconds(series: str, start_seconds: int, end_seconds: int = None) -> bool: +def set_end_skip_seconds(series: str, seconds: int) -> bool: try: - start_seconds = max(0, int(start_seconds)) - if end_seconds is None: - end_seconds = start_seconds + 60 - end_seconds = max(0, int(end_seconds)) - + seconds = max(0, int(seconds)) db = load_progress() entry = db.get(series, {}) if isinstance(db.get(series, {}), dict) else {} - entry["intro_skip_start"] = start_seconds - entry["intro_skip_end"] = end_seconds + entry["end_skip"] = seconds db[series] = entry with open(PROGRESS_DB_FILE, "w", encoding="utf-8") as f: json.dump(db, f, indent=2, ensure_ascii=False) return True except Exception as e: - logging.error(f"Intro time could not be saved: {e}") + logging.error(f"End skip time could not be saved: {e}") return False -def load_intro_times() -> Dict[str, Any]: - """Load intro times from intro_times.json""" +def norm_series_key(s: str) -> str: + try: + return _html.unescape(str(s or "")).strip() + except Exception: + return str(s or "").strip() + + +def build_intro_fingerprint_key(series: str, season: int) -> str: + safe_series: str = re.sub(r"[^a-z0-9_]+", "_", (series or "").lower()) + safe_series = safe_series.strip("_") + season_value: int = max(0, int(season)) + return f"{safe_series}_s{season_value:02d}" + + +def load_intro_fingerprints() -> Dict[str, Dict[str, Any]]: try: - intro_times_file = os.path.join(SCRIPT_DIR, "intro_times.json") - if os.path.exists(intro_times_file): - with open(intro_times_file, "r", encoding="utf-8") as f: - return json.load(f) + if os.path.exists(INTRO_FINGERPRINTS_FILE): + with open(INTRO_FINGERPRINTS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data return {} except Exception as e: - logging.error(f"Could not load intro times: {e}") + logging.error(f"Intro fingerprints could not be loaded: {e}") return {} -def get_default_intro_times(series: str, season: int = 1) -> tuple[int, int]: - """Get default intro times for a series and season""" +def get_intro_fingerprint_entry(series: str, season: int) -> Optional[Dict[str, Any]]: + intro_fingerprint_key: str = build_intro_fingerprint_key(series, season) + intro_fingerprints: Dict[str, Dict[str, Any]] = load_intro_fingerprints() + entry_raw: Optional[Dict[str, Any]] = intro_fingerprints.get(intro_fingerprint_key) + if not isinstance(entry_raw, dict): + return None + return entry_raw + + +def set_intro_fingerprint_entry( + series: str, + season: int, + full_intro_duration_seconds: int, + fingerprint: str, + fingerprint_duration: int, +) -> bool: try: - intro_times = load_intro_times() - series_data = intro_times.get(series, {}) - - # Try to find specific season data - for intro in series_data.get("intros", []): - if intro.get("season") == season: - return intro.get("start_time", 90), intro.get("end_time", 150) - - # Fall back to default times - return series_data.get("default_skip_start", 90), series_data.get("default_skip_end", 150) + intro_fingerprints: Dict[str, Dict[str, Any]] = load_intro_fingerprints() + intro_fingerprint_key: str = build_intro_fingerprint_key(series, season) + entry: Dict[str, Any] = intro_fingerprints.get(intro_fingerprint_key, {}) + + intro_duration_value: int = max(0, int(full_intro_duration_seconds)) + fingerprint_value: str = str(fingerprint or "").strip() + fingerprint_duration_value: int = max(0, int(fingerprint_duration)) + + if intro_duration_value > 0: + entry["fullIntroDurationSeconds"] = intro_duration_value + else: + entry.pop("fullIntroDurationSeconds", None) + + if fingerprint_value: + entry["fingerprint"] = fingerprint_value + if fingerprint_duration_value > 0: + entry["fingerprintDuration"] = fingerprint_duration_value + else: + entry.pop("fingerprintDuration", None) + else: + entry.pop("fingerprint", None) + entry.pop("fingerprintDuration", None) + + if not entry: + intro_fingerprints.pop(intro_fingerprint_key, None) + else: + intro_fingerprints[intro_fingerprint_key] = entry + + with open(INTRO_FINGERPRINTS_FILE, "w", encoding="utf-8") as f: + json.dump(intro_fingerprints, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logging.error(f"Intro fingerprint could not be saved: {e}") + return False + + +def merge_intro_fingerprint_entry( + series: str, + season: int, + full_intro_duration_seconds: Optional[int], + fingerprint: Optional[str], + fingerprint_duration: Optional[int], +) -> bool: + entry: Optional[Dict[str, Any]] = get_intro_fingerprint_entry(series, season) + current_duration: int = int((entry or {}).get("fullIntroDurationSeconds", 0) or 0) + current_fingerprint: str = str((entry or {}).get("fingerprint", "") or "") + current_fp_duration: int = int((entry or {}).get("fingerprintDuration", 0) or 0) + + next_duration: int = ( + int(full_intro_duration_seconds) + if full_intro_duration_seconds is not None + else current_duration + ) + next_fingerprint: str = ( + str(fingerprint or "") + if fingerprint is not None + else current_fingerprint + ) + next_fp_duration: int = ( + int(fingerprint_duration) + if fingerprint_duration is not None + else current_fp_duration + ) + + return set_intro_fingerprint_entry( + series=series, + season=season, + full_intro_duration_seconds=next_duration, + fingerprint=next_fingerprint, + fingerprint_duration=next_fp_duration, + ) + + +def _resolve_fpcalc_binary() -> Optional[str]: + fpcalc_binary: Optional[str] = shutil.which("fpcalc") + if fpcalc_binary: + return fpcalc_binary + return shutil.which("fpcalc.exe") + + +def extract_fingerprint_from_mp3(mp3_bytes: bytes) -> Optional[Dict[str, Any]]: + fpcalc_binary: Optional[str] = _resolve_fpcalc_binary() + if not fpcalc_binary: + logging.error("fpcalc binary not found. Please install Chromaprint.") + return None + + temp_file = None + try: + temp_file = tempfile.NamedTemporaryFile( + mode="wb", + suffix=".mp3", + delete=False, + dir=SCRIPT_DIR, + ) + temp_file.write(mp3_bytes) + temp_file.flush() + temp_file.close() + + result = subprocess.run( + [fpcalc_binary, "-json", temp_file.name], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + logging.error( + f"fpcalc failed: {result.stderr.strip() or 'unknown error'}" + ) + return None + + payload = json.loads(result.stdout or "{}") + if not isinstance(payload, dict): + return None + + fingerprint_value: str = str(payload.get("fingerprint", "") or "").strip() + duration_value: int = int(payload.get("duration", 0) or 0) + if not fingerprint_value: + return None + + return { + "fingerprint": fingerprint_value, + "fingerprintDuration": duration_value, + } + except Exception as e: + logging.error(f"Fingerprint extraction failed: {e}") + return None + finally: + try: + if temp_file is not None and os.path.exists(temp_file.name): + os.remove(temp_file.name) + except Exception: + pass + + +def list_intro_upload_files() -> List[str]: + try: + os.makedirs(INTRO_UPLOAD_DIR, exist_ok=True) + files = [ + name + for name in os.listdir(INTRO_UPLOAD_DIR) + if name.lower().endswith(".mp3") + ] + return sorted(files) except Exception: - return 90, 150 + return [] -def detect_intro_start(driver, series: str, season: int = 1) -> bool: - """Detect if an intro is currently playing""" +def resolve_intro_upload_path(filename: str) -> Optional[str]: + safe_name: str = os.path.basename(filename or "") + if not safe_name: + return None + candidate: str = os.path.join(INTRO_UPLOAD_DIR, safe_name) try: - intro_times = load_intro_times() - series_data = intro_times.get(series, {}) - - # Get current video time - current_time = driver.execute_script("return document.querySelector('video')?.currentTime || 0;") - - # Check if we're in the intro time window - for intro in series_data.get("intros", []): - if intro.get("season") == season: - start_time = intro.get("start_time", 90) - end_time = intro.get("end_time", 150) - - if start_time <= current_time <= end_time: - # Additional detection patterns - detection_patterns = intro.get("detection_patterns", []) - - # Check for intro indicators in the page - page_text = driver.execute_script("return document.body.innerText.toLowerCase();") - - for pattern in detection_patterns: - if pattern.lower() in page_text: - return True - - # If we're in the time window and no specific patterns found, assume it's an intro - return True - + if os.path.exists(candidate): + return candidate + except Exception: + return None + return None + + +def _log_intro_reason_once(intro_fingerprint_key: str, reason_key: str, message: str) -> None: + previous_reason: Optional[str] = INTRO_AUTO_REASON_LOGGED.get(intro_fingerprint_key) + if previous_reason == reason_key: + return + INTRO_AUTO_REASON_LOGGED[intro_fingerprint_key] = reason_key + logging.info(message) + + +def _wait_for_video_source(driver: webdriver.Firefox, timeout_seconds: int = 6) -> str: + deadline_value: float = time.time() + max(1, timeout_seconds) + while time.time() < deadline_value: + try: + src_value: str = str( + driver.execute_script( + "return document.querySelector('video')?.currentSrc || document.querySelector('video')?.src || '';" + ) + or "" + ).strip() + ready_state_value: int = int( + driver.execute_script( + "return document.querySelector('video')?.readyState || 0;" + ) + or 0 + ) + if src_value and ready_state_value >= 3: + return src_value + except Exception: + pass + time.sleep(0.25) + return "" + + +def _compare_fingerprint_prefix(stored_fingerprint: str, candidate_fingerprint: str) -> bool: + stored_value: str = str(stored_fingerprint or '').strip() + candidate_value: str = str(candidate_fingerprint or '').strip() + if not stored_value or not candidate_value: return False - except Exception as e: - logging.error(f"Error detecting intro: {e}") + + if stored_value == candidate_value: + return True + + prefix_length: int = min(96, len(stored_value), len(candidate_value)) + if prefix_length >= 48 and candidate_value[:prefix_length] == stored_value[:prefix_length]: + return True + + anchor_length: int = 32 + if len(stored_value) < anchor_length or len(candidate_value) < anchor_length: return False + anchor_offsets: List[int] = [0, 32, 64] + anchor_hits: int = 0 + for anchor_offset in anchor_offsets: + if anchor_offset + anchor_length > len(stored_value): + continue + anchor_value: str = stored_value[anchor_offset : anchor_offset + anchor_length] + if anchor_value and anchor_value in candidate_value: + anchor_hits += 1 + + return anchor_hits >= 2 + + +def try_match_current_video_fingerprint( + driver: webdriver.Firefox, + series: str, + season: int, + fingerprint_value: str, +) -> bool: + intro_fingerprint_key: str = build_intro_fingerprint_key(series, season) + fpcalc_binary: Optional[str] = _resolve_fpcalc_binary() + if not fpcalc_binary: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='missing_fpcalc', + message=f'Intro fingerprint listening inactive for {intro_fingerprint_key}: fpcalc missing.', + ) + return False -def smart_skip_intro(driver, series: str, season: int = 1): - """Smart intro skipping that only skips when an intro is detected""" try: - # Wait for video to be ready - WebDriverWait(driver, 15).until( - lambda d: d.execute_script( - "return document.querySelector('video')?.readyState > 0;" + if not ensure_video_context(driver): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_context_missing", + message=( + f"Intro fingerprint listening waiting for video context for {intro_fingerprint_key}." + ), ) + return False + except Exception: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_context_missing", + message=( + f"Intro fingerprint listening waiting for video context for {intro_fingerprint_key}." + ), ) + return False + + try: + current_src_value: str = _wait_for_video_source(driver) + except Exception: + current_src_value = "" + + if not current_src_value: + if not INTRO_VIDEO_SRC_LOGGED.get(intro_fingerprint_key): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='video_src_wait_timeout', + message=( + f'Intro fingerprint listening waiting for video source for {intro_fingerprint_key}.' + ), + ) + INTRO_VIDEO_SRC_LOGGED[intro_fingerprint_key] = True + return False - progress_entry = load_progress().get(series, {}) - has_custom_intro = ( - "intro_skip_start" in progress_entry - or "intro_skip_end" in progress_entry + lower_src_value: str = current_src_value.lower() + if lower_src_value.startswith("http") and not lower_src_value.endswith( + (".mp3", ".wav", ".flac", ".m4a", ".aac") + ): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_src_unsupported", + message=( + f"Intro fingerprint listening cannot probe non-audio source for {intro_fingerprint_key}." + ), ) - if has_custom_intro: - intro_start = get_intro_skip_seconds(series) - intro_end = get_intro_skip_end_seconds(series) - if intro_end > intro_start: - current_time = driver.execute_script( - "return document.querySelector('video')?.currentTime || 0;" - ) - if intro_start <= current_time <= intro_end: - driver.execute_script( - "document.querySelector('video').currentTime = arguments[0];", - intro_end, - ) - return + return False - # Get intro times - intro_start, intro_end = get_default_intro_times(series, season) + cache_key: str = f"{intro_fingerprint_key}|{current_src_value}|{fingerprint_value[:120]}" + cached_result: Optional[bool] = INTRO_AUTO_MATCH_CACHE.get(cache_key) + if cached_result is not None: + return cached_result - # Check if we should skip intro - if detect_intro_start(driver, series, season): - logging.info(f"Intro detected for {series}, skipping to {intro_end} seconds") - driver.execute_script( - "document.querySelector('video').currentTime = arguments[0];", - intro_end, + logging.info( + 'Start listening to episode intro fingerprint for %s.', + intro_fingerprint_key, + ) + + try: + result = subprocess.run( + [fpcalc_binary, '-json', current_src_value], + capture_output=True, + text=True, + check=False, + timeout=12, + ) + if result.returncode != 0: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='fpcalc_failed', + message=( + f'Intro fingerprint listening fpcalc failed for {intro_fingerprint_key} ' + f'(code {result.returncode}).' + ), ) - else: - logging.info(f"No intro detected for {series}, continuing normally") - + logging.debug( + 'Intro fingerprint auto-match fpcalc stderr: %s', + (result.stderr or '').strip()[:300], + ) + INTRO_AUTO_MATCH_CACHE[cache_key] = False + return False + + payload: Dict[str, Any] = json.loads(result.stdout or '{}') + episode_fingerprint_value: str = str(payload.get('fingerprint', '') or '').strip() + is_match: bool = _compare_fingerprint_prefix( + stored_fingerprint=fingerprint_value, + candidate_fingerprint=episode_fingerprint_value, + ) + INTRO_AUTO_MATCH_CACHE[cache_key] = is_match + if is_match: + INTRO_AUTO_REASON_LOGGED.pop(intro_fingerprint_key, None) + logging.info( + 'Automatic intro fingerprint match succeeded for %s.', + intro_fingerprint_key, + ) + return is_match except Exception as e: - logging.error(f"Error in smart intro skip: {e}") - # Fall back to simple skip - skip_intro(driver, get_intro_skip_seconds(series)) + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='probe_exception', + message=f'Intro fingerprint listening probe error for {intro_fingerprint_key}.', + ) + logging.debug('Automatic intro fingerprint match failed: %s', e) + INTRO_AUTO_MATCH_CACHE[cache_key] = False + return False -def get_end_skip_seconds(series: str) -> int: +def read_intro_fingerprint_match(driver: webdriver.Firefox) -> Optional[str]: try: - data = load_progress().get(series, {}) - val = int(data.get("end_skip", 0)) - return max(0, val) + driver.switch_to.default_content() + value = driver.execute_script( + """ + let r = localStorage.getItem('bw_intro_fp_match'); + if (r) localStorage.removeItem('bw_intro_fp_match'); + return r; + """ + ) + return value if isinstance(value, str) else None except Exception: - return 0 + return None -def set_end_skip_seconds(series: str, seconds: int) -> bool: - try: - seconds = max(0, int(seconds)) - db = load_progress() - entry = db.get(series, {}) if isinstance(db.get(series, {}), dict) else {} - entry["end_skip"] = seconds - db[series] = entry - with open(PROGRESS_DB_FILE, "w", encoding="utf-8") as f: - json.dump(db, f, indent=2, ensure_ascii=False) +def maybe_apply_intro_skip( + driver: webdriver.Firefox, + series: str, + season: int, + intro_skip_applied: bool, +) -> bool: + if intro_skip_applied: return True - except Exception as e: - logging.error(f"End skip time could not be saved: {e}") - return False + entry = get_intro_fingerprint_entry(series, season) + intro_fingerprint_key: str = build_intro_fingerprint_key(series, season) + if not entry: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='missing_entry', + message=f'Intro listening skipped for {intro_fingerprint_key}: no fingerprint entry found.', + ) + return intro_skip_applied + + intro_duration_raw: int = int(entry.get("fullIntroDurationSeconds", 0) or 0) + intro_duration_seconds: int = max(0, intro_duration_raw) + if intro_duration_seconds <= 0: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='missing_intro_duration', + message=f'Intro listening skipped for {intro_fingerprint_key}: intro duration is 0.', + ) + return intro_skip_applied + + fingerprint_value: str = str(entry.get("fingerprint", "") or "").strip() + if not fingerprint_value: + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key='duration_only_mode', + message=f'Intro listening for {intro_fingerprint_key}: fingerprint empty, using duration skip only.', + ) + seek_to_position(driver, intro_duration_seconds) + return True + + matched_key = read_intro_fingerprint_match(driver) + if matched_key == intro_fingerprint_key: + INTRO_AUTO_REASON_LOGGED.pop(intro_fingerprint_key, None) + if not ensure_video_context(driver): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_context_missing", + message=( + f"Intro fingerprint listening waiting for video context for {intro_fingerprint_key}." + ), + ) + return intro_skip_applied + current_time_value: float = float( + driver.execute_script( + "return document.querySelector('video')?.currentTime || 0;" + ) + or 0 + ) + target_time: int = int(current_time_value + intro_duration_seconds) + seek_to_position(driver, target_time) + return True + + if not INTRO_AUTO_LISTEN_LOGGED.get(intro_fingerprint_key): + logging.info( + 'Start listening to episode intro for %s (introDuration=%ss, fingerprintDuration=%ss).', + intro_fingerprint_key, + intro_duration_seconds, + int(entry.get("fingerprintDuration", 0) or 0), + ) + INTRO_AUTO_LISTEN_LOGGED[intro_fingerprint_key] = True + + if try_match_current_video_fingerprint( + driver=driver, + series=series, + season=season, + fingerprint_value=fingerprint_value, + ): + if not ensure_video_context(driver): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_context_missing", + message=( + f"Intro fingerprint listening waiting for video context for {intro_fingerprint_key}." + ), + ) + return intro_skip_applied + current_time_value: float = float( + driver.execute_script( + "return document.querySelector('video')?.currentTime || 0;" + ) + or 0 + ) + target_time: int = int(current_time_value + intro_duration_seconds) + seek_to_position(driver, target_time) + return True + + fingerprint_duration_raw: int = int(entry.get("fingerprintDuration", 0) or 0) + fingerprint_duration_seconds: int = max(0, fingerprint_duration_raw) + fallback_window_seconds: int = ( + fingerprint_duration_seconds if fingerprint_duration_seconds > 0 else 12 + ) + fallback_trigger_seconds: int = min( + intro_duration_seconds, + max(6, fallback_window_seconds + 2), + ) -def norm_series_key(s: str) -> str: try: - return _html.unescape(str(s or "")).strip() + if not ensure_video_context(driver): + _log_intro_reason_once( + intro_fingerprint_key=intro_fingerprint_key, + reason_key="video_context_missing", + message=( + f"Intro fingerprint listening waiting for video context for {intro_fingerprint_key}." + ), + ) + return intro_skip_applied + current_time_value: float = float( + driver.execute_script( + "return document.querySelector('video')?.currentTime || 0;" + ) + or 0 + ) except Exception: - return str(s or "").strip() + return intro_skip_applied + + if current_time_value >= fallback_trigger_seconds: + INTRO_AUTO_REASON_LOGGED.pop(intro_fingerprint_key, None) + logging.info( + "Intro fingerprint match timeout for %s. Falling back to intro duration skip.", + intro_fingerprint_key, + ) + seek_to_position(driver, intro_duration_seconds) + return True + + reason_value: Optional[str] = INTRO_AUTO_REASON_LOGGED.get(intro_fingerprint_key) + early_fallback_reasons: Set[str] = { + "missing_fpcalc", + "video_context_missing", + "video_src_wait_timeout", + "video_src_unsupported", + "fpcalc_failed", + "probe_exception", + } + if reason_value in early_fallback_reasons and current_time_value >= min( + 6, + intro_duration_seconds, + ): + INTRO_AUTO_REASON_LOGGED.pop(intro_fingerprint_key, None) + logging.info( + "Intro fingerprint probe unavailable for %s. Applying duration skip.", + intro_fingerprint_key, + ) + seek_to_position(driver, intro_duration_seconds) + return True + + logging.debug( + 'Intro fingerprint not matched yet for %s (currentTime=%.2f, fallbackAt=%ss).', + intro_fingerprint_key, + current_time_value, + fallback_trigger_seconds, + ) + return intro_skip_applied # === BROWSER HANDLING --------------------------- === @@ -629,7 +1050,6 @@ def get_settings(driver: webdriver.Firefox) -> Dict[str, Any]: merged = {**file_s, **ls_s} merged["autoFullscreen"] = bool(merged.get("autoFullscreen", True)) - merged["autoSkipIntro"] = bool(merged.get("autoSkipIntro", True)) merged["autoSkipEndScreen"] = bool(merged.get("autoSkipEndScreen", True)) merged["autoNext"] = bool(merged.get("autoNext", True)) merged["playbackRate"] = float(merged.get("playbackRate", 1)) @@ -696,7 +1116,6 @@ def sync_settings_to_localstorage(driver: webdriver.Firefox) -> None: def _default_settings() -> Dict[str, Any]: return { "autoFullscreen": True, - "autoSkipIntro": True, "autoSkipEndScreen": False, "autoNext": True, "playbackRate": 1.0, @@ -1024,13 +1443,13 @@ def play_episodes_loop( db = load_progress() settings = get_settings(driver) auto_fs = settings["autoFullscreen"] - auto_skip = settings["autoSkipIntro"] auto_skip_end = settings["autoSkipEndScreen"] auto_next = settings["autoNext"] rate = settings["playbackRate"] vol = settings["volume"] fullscreen_attempted: bool = False end_skip_applied: bool = False + intro_skip_applied: bool = False print( f"\n[▶] Playing {series.capitalize()} – Season {current_season}, Episode {current_episode}" @@ -1068,9 +1487,32 @@ def play_episodes_loop( apply_media_settings(driver, rate, vol) if position and position > 0: - skip_intro(driver, position) - elif auto_skip: - smart_skip_intro(driver, series, current_season) + resume_position_seconds: int = int(position) + if resume_position_seconds > 3: + logging.info( + "Intro listening disabled for this episode because resume position is %ss.", + resume_position_seconds, + ) + seek_to_position(driver, resume_position_seconds) + intro_skip_applied = True + else: + logging.info( + "Resume position %ss is too small, treating as fresh start for intro listening.", + resume_position_seconds, + ) + intro_skip_applied = maybe_apply_intro_skip( + driver=driver, + series=series, + season=current_season, + intro_skip_applied=intro_skip_applied, + ) + else: + intro_skip_applied = maybe_apply_intro_skip( + driver=driver, + series=series, + season=current_season, + intro_skip_applied=intro_skip_applied, + ) position = 0 recovery_tries = 0 @@ -1105,29 +1547,6 @@ def play_episodes_loop( ensure_video_context(driver) play_video(driver) - try: - const_ser = series - const_secs = get_intro_skip_seconds(const_ser) - const_secs_end = get_intro_skip_end_seconds(const_ser) - driver.execute_script( - """ - const v = document.querySelector('video'); - const secs = arguments[0]; - const secsEnd = arguments[1]; - if (!v || !isFinite(v.duration)) return; - if (secsEnd <= secs || secsEnd >= (v.duration - 1)) return; - const currentTime = v.currentTime; - if (currentTime >= secs && currentTime <= secsEnd) { - v.currentTime = secsEnd; - try { v.play().catch(()=>{}); } catch(_) {} - } - """, - const_secs, - const_secs_end, - ) - except Exception: - pass - if auto_fs and not HEADLESS: _hide_sidebar(driver, True) ensure_video_context(driver) @@ -1290,7 +1709,6 @@ def play_episodes_loop( # Lokale Variablen MERGEN auto_fs = bool(upd.get("autoFullscreen", auto_fs)) - auto_skip = bool(upd.get("autoSkipIntro", auto_skip)) auto_skip_end = bool(upd.get("autoSkipEndScreen", auto_skip_end)) auto_next = bool(upd.get("autoNext", auto_next)) rate = float(upd.get("playbackRate", rate)) @@ -1300,7 +1718,6 @@ def play_episodes_loop( settings.update( { "autoFullscreen": auto_fs, - "autoSkipIntro": auto_skip, "autoSkipEndScreen": auto_skip_end, "autoNext": auto_next, "playbackRate": rate, @@ -1360,39 +1777,85 @@ def play_episodes_loop( # --- LIVE SERIES SKIP UPDATES ---------------------------------- try: skip_settings_changed = False - upd = read_localstorage_value(driver, "bw_intro_start_update") + upd = read_localstorage_value(driver, "bw_intro_duration_update") if upd: data = json.loads(upd) ser_raw = data.get("series", "") + season_raw = data.get("season", 0) secs_raw = data.get("seconds", 0) ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 try: secs = max(0, int(float(secs_raw))) except Exception: secs = 0 - if ser: - current_end = get_intro_skip_end_seconds(ser) - if set_intro_skip_seconds(ser, secs, current_end): + if ser and season_value > 0: + if merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=secs, + fingerprint=None, + fingerprint_duration=None, + ): + skip_settings_changed = True + except Exception: + pass + + try: + upd = read_localstorage_value(driver, "bw_intro_fp_update") + if upd: + data = json.loads(upd) + ser_raw = data.get("series", "") + season_raw = data.get("season", 0) + fingerprint_raw = data.get("fingerprint", "") + ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 + fingerprint_value = str(fingerprint_raw or "").strip() + + if ser and season_value > 0: + if merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=None, + fingerprint=fingerprint_value, + fingerprint_duration=None, + ): skip_settings_changed = True except Exception: pass try: - upd = read_localstorage_value(driver, "bw_intro_end_update") + upd = read_localstorage_value(driver, "bw_intro_fp_duration_update") if upd: data = json.loads(upd) ser_raw = data.get("series", "") + season_raw = data.get("season", 0) secs_raw = data.get("seconds", 0) ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 try: secs = max(0, int(float(secs_raw))) except Exception: secs = 0 - if ser: - current_start = get_intro_skip_seconds(ser) - if set_intro_skip_seconds(ser, current_start, secs): + if ser and season_value > 0: + if merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=None, + fingerprint=None, + fingerprint_duration=secs, + ): skip_settings_changed = True except Exception: pass @@ -1532,10 +1995,17 @@ def play_episodes_loop( except Exception: pass + intro_skip_applied = maybe_apply_intro_skip( + driver=driver, + series=series, + season=current_season, + intro_skip_applied=intro_skip_applied, + ) + time.sleep(1.0) if auto_nav: - position = get_intro_skip_seconds(series) if auto_skip else 0 + position = 0 continue exit_fullscreen(driver) @@ -1561,7 +2031,7 @@ def play_episodes_loop( if not next_episode: return current_season, current_episode = next_episode - position = get_intro_skip_seconds(series) if auto_skip else 0 + position = 0 continue # Spezielle Behandlung für One Piece: Verhindere Sprung zu Staffel 11 @@ -1622,7 +2092,7 @@ def play_episodes_loop( # Normale Episode-Inkrementierung für andere Serien current_episode += 1 - position = get_intro_skip_seconds(series) if auto_skip else 0 + position = 0 continue @@ -2288,13 +2758,16 @@ def apply_media_settings(driver, rate, vol): pass -def skip_intro(driver, seconds): +def seek_to_position(driver, seconds: int) -> None: WebDriverWait(driver, 15).until( lambda d: d.execute_script( "return document.querySelector('video')?.readyState > 0;" ) ) - driver.execute_script(f"document.querySelector('video').currentTime = {seconds};") + driver.execute_script( + "document.querySelector('video').currentTime = arguments[0];", + int(seconds), + ) def get_current_position(driver): @@ -2382,9 +2855,9 @@ def build_items_html(db: Dict[str, Dict[str, Any]], settings: Optional[Dict[str, """Erstellt HTML für die Sidebar mit Streaming-Anbieter-Tabs.""" if settings is None: settings = {} - auto_skip_intro = settings.get("autoSkipIntro", True) - auto_skip_end = settings.get("autoSkipEndScreen", False) - + intro_upload_files: List[str] = list_intro_upload_files() + fpcalc_available: bool = _resolve_fpcalc_binary() is not None + intro_fingerprints: Dict[str, Dict[str, Any]] = load_intro_fingerprints() # Gruppiere Serien nach Anbietern provider_series = {} for series_name, data in db.items(): @@ -2423,14 +2896,17 @@ def build_items_html(db: Dict[str, Dict[str, Any]], settings: Optional[Dict[str, episode = int(data.get("episode", 1)) position = int(data.get("position", 0)) ts_val = float(data.get("timestamp", 0)) - intro_val = int(data.get("intro_skip_start", INTRO_SKIP_SECONDS)) - intro_end_val = int(data.get("intro_skip_end", INTRO_SKIP_SECONDS + 60)) end_skip_val = int(data.get("end_skip", 0)) + intro_fingerprint_key: str = build_intro_fingerprint_key(series_name, season) + intro_entry: Dict[str, Any] = intro_fingerprints.get(intro_fingerprint_key, {}) + intro_duration_val = int(intro_entry.get("fullIntroDurationSeconds", 0) or 0) + intro_fp_val = _html.escape(str(intro_entry.get("fingerprint", "") or ""), quote=True) + intro_fp_duration_val = int(intro_entry.get("fingerprintDuration", 0) or 0) safe_name = _html.escape(series_name, quote=True) series_items.append(f"""
@@ -2446,9 +2922,9 @@ def build_items_html(db: Dict[str, Dict[str, Any]], settings: Optional[Dict[str,
X
-
@@ -2461,7 +2937,10 @@ def build_items_html(db: Dict[str, Dict[str, Any]], settings: Optional[Dict[str, ''') # Kombiniere alles + upload_meta = _html.escape(json.dumps(intro_upload_files), quote=True) + fpcalc_meta = "1" if fpcalc_available else "0" tabs_container = f''' +
{"".join(tabs_html)}
@@ -2692,38 +3171,30 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> box-shadow: 0 4px 12px rgba(59,130,246,.15); } - #bingeSidebar .bw-intro-start, - #bingeSidebar .bw-intro-end, + #bingeSidebar .bw-intro-duration, + #bingeSidebar .bw-intro-fingerprint, + #bingeSidebar .bw-intro-fp-duration, #bingeSidebar .bw-end { transition: all .2s ease !important; } - - #bingeSidebar .bw-intro-start:focus, - #bingeSidebar .bw-intro-end:focus, - #bingeSidebar .bw-end:focus { + + #bingeSidebar .bw-intro-duration:focus, + #bingeSidebar .bw-intro-fingerprint:focus, + #bingeSidebar .bw-intro-fp-duration:focus { transform: scale(1.02); box-shadow: 0 0 0 2px rgba(59,130,246,.3); border-color: rgba(59,130,246,.6) !important; } - #bingeSidebar .bw-intro-start:hover, - #bingeSidebar .bw-intro-end:hover, - #bingeSidebar .bw-end:hover { + #bingeSidebar .bw-intro-duration:hover, + #bingeSidebar .bw-intro-fingerprint:hover, + #bingeSidebar .bw-intro-fp-duration:hover { border-color: rgba(59,130,246,.5) !important; background: rgba(59,130,246,.15) !important; } - - #bingeSidebar .bw-intro-end:focus { - box-shadow: 0 0 0 2px rgba(139,92,246,.3); - border-color: rgba(139,92,246,.6) !important; - } - - #bingeSidebar .bw-intro-end:hover { - border-color: rgba(139,92,246,.5) !important; - background: rgba(139,92,246,.15) !important; - } - + #bingeSidebar .bw-end:focus { + transform: scale(1.02); box-shadow: 0 0 0 2px rgba(239,68,68,.3); border-color: rgba(239,68,68,.6) !important; } @@ -2738,7 +3209,7 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> #bingeSidebar .bw-end-section { transition: all .2s ease; } - + #bingeSidebar .bw-intro-section:hover, #bingeSidebar .bw-end-section:hover { transform: translateX(2px); @@ -2964,7 +3435,7 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> d.addEventListener('input', (e)=>{ if (e.target && e.target.id==='bwSearch') onFilter(); }); d.addEventListener('change', (e)=>{ if (e.target && e.target.id==='bwSort') onSort(); }); - const openSeriesSkipPanel = (seriesName, introStart, introEnd, endSkip) => { + const openSeriesSkipPanel = (seriesName, seasonValue, introDuration, introFingerprint, introFingerprintDuration, endSkip) => { const existingPanel = document.getElementById('bwSeriesSkipPanel'); if (existingPanel) { existingPanel.remove(); @@ -2974,7 +3445,6 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> try { settingsData = JSON.parse(localStorage.getItem('bw_settings') || '{}'); } catch(_) {} - const allowIntro = settingsData.autoSkipIntro !== false; const allowEnd = settingsData.autoSkipEndScreen === true; const panel = document.createElement('div'); @@ -2993,28 +3463,51 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> boxShadow: '0 10px 30px rgba(0,0,0,.4)', }); - const introSectionHtml = allowIntro ? ` -
+ const introSectionHtml = ` +
>
- Intro Skip + Intro Skip (Season ${seasonValue})
- - Intro Duration (s) + + placeholder="0" title="Intro duration in seconds"/> +
+
+
+
+ +
+
+
- - Fingerprint Duration (s) + + placeholder="0" title="Fingerprint duration in seconds"/> +
+
+
+
+ + + +
Drop MP3 files into intro_uploads/.
- ` : ''; + `; const endSectionHtml = allowEnd ? `
@@ -3086,25 +3579,39 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> if (!window.__bwDebouncers) window.__bwDebouncers = Object.create(null); panel.addEventListener('input', (ev) => { - const introInput = ev.target.closest && ev.target.closest('input.bw-intro-start'); - if (introInput) { - const series = introInput.dataset.series; if (!series) return; - const key = '__deb_intro_start_' + series; + const introDurationInput = ev.target.closest && ev.target.closest('input.bw-intro-duration'); + if (introDurationInput) { + const series = introDurationInput.dataset.series; if (!series) return; + const season = parseInt(introDurationInput.dataset.season || '0', 10) || 0; + const key = '__deb_intro_duration_' + series + '_' + season; + if (window.__bwDebouncers[key]) clearTimeout(window.__bwDebouncers[key]); + window.__bwDebouncers[key] = setTimeout(() => { + const seconds = parseInt(introDurationInput.value || '0', 10) || 0; + localStorage.setItem('bw_intro_duration_update', JSON.stringify({ series, season, seconds })); + }, 600); + } + + const introFingerprintInput = ev.target.closest && ev.target.closest('input.bw-intro-fingerprint'); + if (introFingerprintInput) { + const series = introFingerprintInput.dataset.series; if (!series) return; + const season = parseInt(introFingerprintInput.dataset.season || '0', 10) || 0; + const key = '__deb_intro_fp_' + series + '_' + season; if (window.__bwDebouncers[key]) clearTimeout(window.__bwDebouncers[key]); window.__bwDebouncers[key] = setTimeout(() => { - const seconds = parseInt(introInput.value || '0', 10) || 0; - localStorage.setItem('bw_intro_start_update', JSON.stringify({ series, seconds })); + const fingerprint = String(introFingerprintInput.value || '').trim(); + localStorage.setItem('bw_intro_fp_update', JSON.stringify({ series, season, fingerprint })); }, 600); } - const introEndInput = ev.target.closest && ev.target.closest('input.bw-intro-end'); - if (introEndInput) { - const series = introEndInput.dataset.series; if (!series) return; - const key = '__deb_intro_end_' + series; + const introFingerprintDurationInput = ev.target.closest && ev.target.closest('input.bw-intro-fp-duration'); + if (introFingerprintDurationInput) { + const series = introFingerprintDurationInput.dataset.series; if (!series) return; + const season = parseInt(introFingerprintDurationInput.dataset.season || '0', 10) || 0; + const key = '__deb_intro_fp_duration_' + series + '_' + season; if (window.__bwDebouncers[key]) clearTimeout(window.__bwDebouncers[key]); window.__bwDebouncers[key] = setTimeout(() => { - const seconds = parseInt(introEndInput.value || '0', 10) || 0; - localStorage.setItem('bw_intro_end_update', JSON.stringify({ series, seconds })); + const seconds = parseInt(introFingerprintDurationInput.value || '0', 10) || 0; + localStorage.setItem('bw_intro_fp_duration_update', JSON.stringify({ series, season, seconds })); }, 600); } @@ -3119,6 +3626,114 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> }, 600); } }); + + const fillUploadFiles = () => { + const selectEl = panel.querySelector('select.bw-intro-file-select'); + if (!selectEl) return; + const filesNode = document.getElementById('bwUploadFiles'); + if (!filesNode) return; + const fpcalcAvailable = filesNode.getAttribute('data-fpcalc') === '1'; + let files = []; + try { + const raw = filesNode.getAttribute('data-files') || '[]'; + files = JSON.parse(raw); + } catch(_) {} + selectEl.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + if (!fpcalcAvailable) { + placeholder.textContent = 'Chromaprint not installed'; + } else { + placeholder.textContent = files.length ? 'Select MP3 file' : 'No MP3 files found'; + } + selectEl.appendChild(placeholder); + if (fpcalcAvailable) { + files.forEach(file => { + const opt = document.createElement('option'); + opt.value = String(file); + opt.textContent = String(file); + selectEl.appendChild(opt); + }); + } + selectEl.disabled = !fpcalcAvailable; + const statusEl = panel.querySelector('.bw-intro-upload-status'); + if (statusEl && !fpcalcAvailable) { + statusEl.textContent = 'Chromaprint (fpcalc) is not installed.'; + } + }; + + fillUploadFiles(); + + const uploadButton = panel.querySelector('button.bw-intro-file-apply'); + if (uploadButton) { + uploadButton.addEventListener('click', (ev) => { + ev.preventDefault(); + const filesNode = document.getElementById('bwUploadFiles'); + const fpcalcAvailable = filesNode && filesNode.getAttribute('data-fpcalc') === '1'; + const series = uploadButton.dataset.series; if (!series) return; + const season = parseInt(uploadButton.dataset.season || '0', 10) || 0; + const selectEl = panel.querySelector('select.bw-intro-file-select'); + const statusEl = panel.querySelector('.bw-intro-upload-status'); + if (!fpcalcAvailable) { + if (statusEl) statusEl.textContent = 'Install Chromaprint (fpcalc) first.'; + return; + } + if (!selectEl) return; + const filename = String(selectEl.value || ''); + if (!filename) { + if (statusEl) statusEl.textContent = 'Select an MP3 first.'; + return; + } + localStorage.setItem('bw_intro_upload', JSON.stringify({ + series, + season, + filename + })); + if (statusEl) statusEl.textContent = 'Processing MP3...'; + }); + } + + const uploadPollInterval = setInterval(() => { + const statusEl = panel.querySelector('.bw-intro-upload-status'); + if (!statusEl) return; + const series = statusEl.getAttribute('data-series'); + const season = parseInt(statusEl.getAttribute('data-season') || '0', 10) || 0; + if (!series || season <= 0) return; + + let resultRaw = null; + let errorRaw = null; + try { resultRaw = localStorage.getItem('bw_intro_upload_result'); } catch(_) {} + try { errorRaw = localStorage.getItem('bw_intro_upload_error'); } catch(_) {} + + if (resultRaw) { + try { + const result = JSON.parse(resultRaw); + if (result && result.series === series && result.season === season) { + localStorage.removeItem('bw_intro_upload_result'); + const fpInput = panel.querySelector('input.bw-intro-fingerprint'); + const fpDurationInput = panel.querySelector('input.bw-intro-fp-duration'); + if (fpInput) fpInput.value = String(result.fingerprint || ''); + if (fpDurationInput) fpDurationInput.value = String(result.fingerprintDuration || 0); + fillUploadFiles(); + statusEl.textContent = 'Fingerprint saved. MP3 removed.'; + } + } catch(_) {} + } + + if (errorRaw) { + try { + const result = JSON.parse(errorRaw); + if (result && result.series === series && result.season === season) { + localStorage.removeItem('bw_intro_upload_error'); + fillUploadFiles(); + statusEl.textContent = 'Upload failed. Please try again.'; + } + } catch(_) {} + } + }, 900); + + const stopUploadPoll = () => clearInterval(uploadPollInterval); + if (closeButton) closeButton.addEventListener('click', stopUploadPoll); }; d.addEventListener('click', (e)=>{ @@ -3138,9 +3753,6 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> - @@ -3271,7 +3883,6 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> e.stopPropagation(); const next = { autoFullscreen: !!document.getElementById('bwOptAutoFullscreen')?.checked, - autoSkipIntro: !!document.getElementById('bwOptAutoSkipIntro')?.checked, autoSkipEndScreen: !!document.getElementById('bwOptAutoSkipEndScreen')?.checked, autoNext: !!document.getElementById('bwOptAutoNext')?.checked, playbackRate: parseFloat(document.getElementById('bwOptPlaybackRate')?.value || '1'), @@ -3292,7 +3903,6 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> const s = JSON.parse(localStorage.getItem('bw_settings')||'{}'); const x = id => document.getElementById(id); if (x('bwOptAutoFullscreen')) x('bwOptAutoFullscreen').checked = (s.autoFullscreen !== false); - if (x('bwOptAutoSkipIntro')) x('bwOptAutoSkipIntro').checked = (s.autoSkipIntro !== false); if (x('bwOptAutoSkipEndScreen')) x('bwOptAutoSkipEndScreen').checked = (s.autoSkipEndScreen !== false); if (x('bwOptAutoNext')) x('bwOptAutoNext').checked = (s.autoNext !== false); if (x('bwOptPlaybackRate')) x('bwOptPlaybackRate').value = String(s.playbackRate ?? 1); @@ -3327,11 +3937,20 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> const skipPanelButton = c('.bw-series-settings'); if (skipPanelButton) { const seriesName = skipPanelButton.getAttribute('data-series'); - const introStart = parseInt(skipPanelButton.getAttribute('data-intro-start') || '0', 10) || 0; - const introEnd = parseInt(skipPanelButton.getAttribute('data-intro-end') || '0', 10) || 0; + const seasonValue = parseInt(skipPanelButton.getAttribute('data-season') || '0', 10) || 0; + const introDuration = parseInt(skipPanelButton.getAttribute('data-intro-duration') || '0', 10) || 0; + const introFingerprint = skipPanelButton.getAttribute('data-intro-fingerprint') || ''; + const introFingerprintDuration = parseInt(skipPanelButton.getAttribute('data-intro-fp-duration') || '0', 10) || 0; const endSkip = parseInt(skipPanelButton.getAttribute('data-end-skip') || '0', 10) || 0; if (seriesName) { - openSeriesSkipPanel(seriesName, introStart, introEnd, endSkip); + openSeriesSkipPanel( + seriesName, + seasonValue, + introDuration, + introFingerprint, + introFingerprintDuration, + endSkip + ); } return; } @@ -3339,11 +3958,17 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> const item = c('.bw-series-item'); if (item) { // Check if click originated from input field or delete button - don't trigger navigation - const clickedInput = e.target.closest && (e.target.closest('input.bw-intro-start') || e.target.closest('input.bw-intro-end')); const clickedEndInput = e.target.closest && e.target.closest('input.bw-end'); + const clickedIntroInput = e.target.closest && ( + e.target.closest('input.bw-intro-duration') || + e.target.closest('input.bw-intro-fingerprint') || + e.target.closest('input.bw-intro-fp-duration') || + e.target.closest('select.bw-intro-file-select') || + e.target.closest('button.bw-intro-file-apply') + ); const clickedDelete = e.target.closest && e.target.closest('.bw-delete'); const clickedSkipSettings = e.target.closest && e.target.closest('.bw-series-settings'); - if (clickedInput || clickedEndInput || clickedDelete || clickedSkipSettings) return; + if (clickedEndInput || clickedIntroInput || clickedDelete || clickedSkipSettings) return; if (localStorage.getItem('bw_nav_inflight') === '1') return; // throttle localStorage.setItem('bw_nav_inflight','1'); @@ -3370,31 +3995,9 @@ def inject_sidebar(driver: webdriver.Firefox, db: Dict[str, Dict[str, Any]]) -> } }); - // Debounce für Intro-Input und End-Input + // Debounce für End-Input if (!window.__bwDebouncers) window.__bwDebouncers = Object.create(null); d.addEventListener('input', (e)=>{ - const inp = e.target.closest && e.target.closest('input.bw-intro-start'); - if (inp) { - const series = inp.dataset.series; if (!series) return; - const key = '__deb_intro_start_' + series; - if (window.__bwDebouncers[key]) clearTimeout(window.__bwDebouncers[key]); - window.__bwDebouncers[key] = setTimeout(()=>{ - const seconds = parseInt(inp.value||'0',10)||0; - localStorage.setItem('bw_intro_start_update', JSON.stringify({series, seconds})); - }, 600); - } - - const inpEnd = e.target.closest && e.target.closest('input.bw-intro-end'); - if (inpEnd) { - const series = inpEnd.dataset.series; if (!series) return; - const key = '__deb_intro_end_' + series; - if (window.__bwDebouncers[key]) clearTimeout(window.__bwDebouncers[key]); - window.__bwDebouncers[key] = setTimeout(()=>{ - const seconds = parseInt(inpEnd.value||'0',10)||0; - localStorage.setItem('bw_intro_end_update', JSON.stringify({series, seconds})); - }, 600); - } - const endInp = e.target.closest && e.target.closest('input.bw-end'); if (endInp) { const series = endInp.dataset.series; if (!series) return; @@ -3595,25 +4198,143 @@ def main() -> None: except Exception: pass - # Handle intro start updates (from sidebar input) – normalisieren + live anwenden + # Handle end screen updates (from sidebar input) – normalisieren + live anwenden + try: + upd = read_localstorage_value(driver, "bw_intro_upload") + if upd: + data = json.loads(upd) + ser_raw = data.get("series", "") + season_raw = data.get("season", 0) + filename_raw = data.get("filename", "") + ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 + filename_value: str = str(filename_raw or "").strip() + file_path: Optional[str] = resolve_intro_upload_path( + filename_value + ) + + if ser and season_value > 0 and file_path: + fingerprint_entry = None + try: + with open(file_path, "rb") as f: + mp3_bytes = f.read() + fingerprint_entry = extract_fingerprint_from_mp3( + mp3_bytes + ) + except Exception: + fingerprint_entry = None + + try: + os.remove(file_path) + except Exception: + pass + + if fingerprint_entry: + merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=None, + fingerprint=fingerprint_entry.get("fingerprint"), + fingerprint_duration=fingerprint_entry.get( + "fingerprintDuration" + ), + ) + driver.execute_script( + "localStorage.setItem('bw_intro_upload_result', arguments[0]);", + json.dumps( + { + "series": ser, + "season": season_value, + "fingerprint": fingerprint_entry.get( + "fingerprint" + ), + "fingerprintDuration": fingerprint_entry.get( + "fingerprintDuration" + ), + "filename": filename_value, + } + ), + ) + else: + driver.execute_script( + "localStorage.setItem('bw_intro_upload_error', arguments[0]);", + json.dumps( + { + "series": ser, + "season": season_value, + "filename": filename_value, + } + ), + ) + + html = build_items_html(load_progress(), get_settings(driver)) + driver.execute_script( + "if (window.__bwSetList){window.__bwSetList(arguments[0]);}", + html, + ) + except Exception: + pass + try: - upd = read_localstorage_value(driver, "bw_intro_start_update") + upd = read_localstorage_value(driver, "bw_intro_duration_update") if upd: data = json.loads(upd) ser_raw = data.get("series", "") + season_raw = data.get("season", 0) secs_raw = data.get("seconds", 0) ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 try: secs = max(0, int(float(secs_raw))) except Exception: secs = 0 - if ser: - # Get current end time to preserve it - current_end = get_intro_skip_end_seconds(ser) - set_intro_skip_seconds(ser, secs, current_end) + if ser and season_value > 0: + merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=secs, + fingerprint=None, + fingerprint_duration=None, + ) + + html = build_items_html(load_progress(), get_settings(driver)) + driver.execute_script( + "if (window.__bwSetList){window.__bwSetList(arguments[0]);}", + html, + ) + except Exception: + pass + + try: + upd = read_localstorage_value(driver, "bw_intro_fp_update") + if upd: + data = json.loads(upd) + ser_raw = data.get("series", "") + season_raw = data.get("season", 0) + fingerprint_raw = data.get("fingerprint", "") + ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 + fingerprint_value = str(fingerprint_raw or "").strip() + + if ser and season_value > 0: + merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=None, + fingerprint=fingerprint_value, + fingerprint_duration=None, + ) - # UI sofort aktualisieren html = build_items_html(load_progress(), get_settings(driver)) driver.execute_script( "if (window.__bwSetList){window.__bwSetList(arguments[0]);}", @@ -3622,25 +4343,32 @@ def main() -> None: except Exception: pass - # Handle intro end updates (from sidebar input) – normalisieren + live anwenden try: - upd = read_localstorage_value(driver, "bw_intro_end_update") + upd = read_localstorage_value(driver, "bw_intro_fp_duration_update") if upd: data = json.loads(upd) ser_raw = data.get("series", "") + season_raw = data.get("season", 0) secs_raw = data.get("seconds", 0) ser = norm_series_key(ser_raw) + try: + season_value = max(0, int(float(season_raw))) + except Exception: + season_value = 0 try: secs = max(0, int(float(secs_raw))) except Exception: secs = 0 - if ser: - # Get current start time to preserve it - current_start = get_intro_skip_seconds(ser) - set_intro_skip_seconds(ser, current_start, secs) + if ser and season_value > 0: + merge_intro_fingerprint_entry( + series=ser, + season=season_value, + full_intro_duration_seconds=None, + fingerprint=None, + fingerprint_duration=secs, + ) - # UI sofort aktualisieren html = build_items_html(load_progress(), get_settings(driver)) driver.execute_script( "if (window.__bwSetList){window.__bwSetList(arguments[0]);}", @@ -3649,7 +4377,6 @@ def main() -> None: except Exception: pass - # Handle end screen updates (from sidebar input) – normalisieren + live anwenden try: upd = read_localstorage_value(driver, "bw_end_update") if upd: diff --git a/SerienJunkie/settings.json b/SerienJunkie/settings.json index 3413039..7aca940 100644 --- a/SerienJunkie/settings.json +++ b/SerienJunkie/settings.json @@ -1,9 +1,8 @@ { "useTorProxy": false, "autoFullscreen": true, - "autoSkipIntro": true, "autoSkipEndScreen": true, "autoNext": true, "playbackRate": 1, "volume": 1 -} \ No newline at end of file +} diff --git a/start_watching.bat b/start_watching.bat index 47b2dab..8a2be6a 100644 --- a/start_watching.bat +++ b/start_watching.bat @@ -1,9 +1,17 @@ @echo off setlocal enabledelayedexpansion +set "BW_LOG=%~dp0SerienJunkie\bw_startup.log" +echo "[BingeWatcher] Starting..." > "%BW_LOG%" REM === Navigate to the script directory === cd /d "%~dp0SerienJunkie" -echo Starting Binge Watching... +if %ERRORLEVEL% NEQ 0 ( + echo "[X] Failed to change directory to %~dp0SerienJunkie." + echo "[X] Failed to change directory to %~dp0SerienJunkie." >> "%BW_LOG%" + goto :handle_error +) +echo "Starting Binge Watching..." +echo "[=] Working directory: %CD%" >> "%BW_LOG%" REM === Required Python modules === set modules=selenium configparser @@ -11,9 +19,9 @@ set modules=selenium configparser REM === Check Python installation === python --version >nul 2>&1 if %ERRORLEVEL% NEQ 0 ( - echo [X] Python is not installed or not added to PATH. - pause - exit /b 1 + echo "[X] Python is missing or absent from PATH." + echo "[X] Python is missing or absent from PATH." >> "%BW_LOG%" + goto :handle_error ) REM === Check and install missing modules === @@ -22,24 +30,45 @@ set "missing_modules=" for %%m in (%modules%) do ( python -c "import %%m" >nul 2>&1 if !ERRORLEVEL! NEQ 0 ( - echo [-] Missing Python module: %%m + echo "[-] Missing Python module: %%m" set "missing_modules=!missing_modules! %%m" ) else ( - echo [+] Python module '%%m' already installed. + echo "[+] Python module '%%m' already installed." ) ) -if not "!missing_modules!"=="" ( - echo Installing missing modules:!missing_modules! +if "%missing_modules%" NEQ "" ( + set "missing_modules=%missing_modules:~1%" +) + +if "%missing_modules%"=="" ( + echo "[=] All dependencies satisfied." + echo "[=] All dependencies satisfied." >> "%BW_LOG%" +) else ( + echo "Installing missing modules:%missing_modules%" python -m pip install --upgrade pip >nul 2>&1 - python -m pip install !missing_modules! - if !ERRORLEVEL! NEQ 0 ( - echo [X] Failed to install modules. Please install manually. - pause - exit /b 1 + python -m pip install %missing_modules% + if %ERRORLEVEL% NEQ 0 ( + echo "[X] Failed to install modules. Please install manually." + echo "[X] Failed to install modules. Please install manually." >> "%BW_LOG%" + goto :handle_error ) +) + +REM === Check for Chromaprint (fpcalc) === +echo "[i] Checking Chromaprint (fpcalc)..." >> "%BW_LOG%" + +if exist "fpcalc.exe" ( + set "PATH=%CD%;%PATH%" +) + +where fpcalc >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo "[^!] Chromaprint (fpcalc) missing. Place fpcalc.exe in the SerienJunkie folder." + echo "[^!] Chromaprint (fpcalc) missing. Place fpcalc.exe in the SerienJunkie folder." >> "%BW_LOG%" ) else ( - echo [=] All dependencies satisfied. + echo "[+] Chromaprint (fpcalc) available." + echo "[+] Chromaprint (fpcalc) available." >> "%BW_LOG%" ) REM === Check Tor setting from settings.json === @@ -51,7 +80,7 @@ for /f "usebackq delims=" %%a in (`powershell -NoProfile -Command "try { $json = :tor_setting_done if /i "%USE_TOR%"=="true" ( - echo [i] Tor DNS enabled in settings.json - starting Tor... + echo "[i] Tor DNS enabled in settings.json - starting Tor..." REM === Start Tor process (hidden window) === set TOR_PATH=%~dp0SerienJunkie\Browser\TorBrowser\Tor\tor.exe @@ -59,7 +88,7 @@ if /i "%USE_TOR%"=="true" ( REM Check if Tor is already running (Port 9050 in use) netstat -an | find "9050" >nul if %ERRORLEVEL% EQU 0 ( - echo [?] Tor seems to be running already. Trying to Kill and restart the process... + echo "[?] Tor seems to be running already. Trying to Kill and restart the process..." taskkill /IM tor.exe /F >nul 2>&1 REM Wait until port 9050 is truly free set /a waitcount=0 @@ -68,10 +97,12 @@ if /i "%USE_TOR%"=="true" ( netstat -an | find "9050" >nul if %ERRORLEVEL% EQU 0 ( set /a waitcount+=1 - if !waitcount! LSS 55 goto waittorclose - echo [X] Port 9050 did not become available after kill. Aborted execution. - pause - exit /b 1 + if !waitcount! LSS 55 ( + goto waittorclose + ) + echo "[X] Port 9050 never became available after kill. Aborted execution." + echo "[X] Port 9050 never became available after kill. Aborted execution." >> "%BW_LOG%" + goto :handle_error ) ) @@ -86,37 +117,47 @@ if /i "%USE_TOR%"=="true" ( if %ERRORLEVEL% NEQ 0 ( set /a waitcount+=1 if !waitcount! LSS 30 goto waittorstart - echo [X] port 9050 was not opened! - pause - exit /b 1 + echo "[X] port 9050 was never opened!" + echo "[X] port 9050 was never opened!" >> "%BW_LOG%" + goto :handle_error ) - echo [+] Tor started successfully. + echo "[+] Tor started successfully." + echo "[+] Tor started successfully." >> "%BW_LOG%" ) REM === Start Python Script === set BW_DEBUG=1 python s.toBot.py set EXITCODE=%ERRORLEVEL% +echo "[i] Python exit code: %EXITCODE%" >> "%BW_LOG%" REM Immer pausieren, damit du die letzte Zeile siehst echo. -echo [i] Python exit code: %EXITCODE% +echo "[i] Python exit code: %EXITCODE%" pause REM === Cleanup and exit depending on Python exit code === if %EXITCODE% EQU 0 ( - echo [=] BingeWatcher exited normally. + echo "[=] BingeWatcher exited normally." if "%USE_TOR%"=="true" ( - echo [i] Cleaning up Tor process... + echo "[i] Cleaning up Tor process..." taskkill /IM tor.exe /F >nul 2>&1 ) endlocal & exit /b 0 ) else ( - echo [X] BingeWatcher exited with code %EXITCODE%. - pause + echo "[X] BingeWatcher exited with code %EXITCODE%." + echo "[X] BingeWatcher exited with code %EXITCODE%." >> "%BW_LOG%" if "%USE_TOR%"=="true" ( - echo [i] Cleaning up Tor process... + echo "[i] Cleaning up Tor process..." taskkill /IM tor.exe /F >nul 2>&1 ) - endlocal & exit /b %EXITCODE% + goto :handle_error ) + +:handle_error +echo. +echo "[^!] Script aborted. Review the messages above." +echo "[^!] Script aborted. Review the messages above." >> "%BW_LOG%" +echo "[i] Log saved to: %BW_LOG%" +pause +endlocal & exit /b 1