From 03b03d676f12ae50865752b908452f7ed7937192 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Thu, 7 Aug 2025 21:15:00 -0500 Subject: [PATCH] Initial Commit --- Prompt.md | 39 + README.md | 144 ++++ config.json | 8 + episode_matcher.py | 227 ++++++ requirements.txt | 3 + setup_config.py | 54 ++ src/.tvdb_cache/game_of_thrones_s01.json | 68 ++ src/.tvdb_cache/game_of_thrones_s02.json | 68 ++ src/Matcher/__init__.py | 5 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 400 bytes .../episode_renamer.cpython-313.pyc | Bin 0 -> 26745 bytes .../file_classifier.cpython-313.pyc | Bin 0 -> 14870 bytes src/Matcher/episode_renamer.py | 666 ++++++++++++++++++ src/Matcher/file_classifier.py | 310 ++++++++ src/TVDBProvider/__init__.py | 4 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 323 bytes .../__pycache__/tvdb_cache.cpython-313.pyc | Bin 0 -> 7368 bytes .../__pycache__/tvdb_client.cpython-313.pyc | Bin 0 -> 8806 bytes src/TVDBProvider/tvdb_cache.py | 126 ++++ src/TVDBProvider/tvdb_client.py | 192 +++++ src/__pycache__/config.cpython-313.pyc | Bin 0 -> 5981 bytes src/config.py | 105 +++ test_components.py | 84 +++ 23 files changed, 2103 insertions(+) create mode 100644 Prompt.md create mode 100644 README.md create mode 100644 config.json create mode 100755 episode_matcher.py create mode 100644 requirements.txt create mode 100755 setup_config.py create mode 100644 src/.tvdb_cache/game_of_thrones_s01.json create mode 100644 src/.tvdb_cache/game_of_thrones_s02.json create mode 100644 src/Matcher/__init__.py create mode 100644 src/Matcher/__pycache__/__init__.cpython-313.pyc create mode 100644 src/Matcher/__pycache__/episode_renamer.cpython-313.pyc create mode 100644 src/Matcher/__pycache__/file_classifier.cpython-313.pyc create mode 100644 src/Matcher/episode_renamer.py create mode 100644 src/Matcher/file_classifier.py create mode 100644 src/TVDBProvider/__init__.py create mode 100644 src/TVDBProvider/__pycache__/__init__.cpython-313.pyc create mode 100644 src/TVDBProvider/__pycache__/tvdb_cache.cpython-313.pyc create mode 100644 src/TVDBProvider/__pycache__/tvdb_client.cpython-313.pyc create mode 100644 src/TVDBProvider/tvdb_cache.py create mode 100644 src/TVDBProvider/tvdb_client.py create mode 100644 src/__pycache__/config.cpython-313.pyc create mode 100644 src/config.py create mode 100644 test_components.py diff --git a/Prompt.md b/Prompt.md new file mode 100644 index 0000000..e5933af --- /dev/null +++ b/Prompt.md @@ -0,0 +1,39 @@ +I would like for you to build out a tool that will help me label all the files in a folder. + +You'll be passed a folder path. Inside that folder path will be a bunch of mkv files. Your job is sort the classify each of the files as either "episodes" or "extras". + +If a file is an extra, place it in a folder called "extras". + +Files that are extras have the following characteristics +- they're much smaller than the average size of all the files. So if most files are 1GB, the extra is 30 MB +- Any files that is not an episode is an extra + +If you think a file is an extra, move the file to "extras" folder. + +Files that are episodes have the following characteristcs +- they tend to be the largest files in the folder +- the length of the video matches what tvdb says the episode length is for the season. + +If you think a file is an episode, label the file in the following format + + se. Refer to Jellyfin documentation for show labeling if you need more info. + +Examples +the show Game of Thrones, season 1, episode 4 will be labeled "Game of Thrones s01e04" +Teen Titans Season 4, episode 3 will be "Teen Titans s04e03" +Dragon Ball Z Season 4, episode 20 will be "Dragon Ball Z s04e20" + +Assume all episodes are present in the folder to compose an entire season of the show. Therefore, do not exit successfully if you do not label +all episodes for the season. + +You have two folders, Matcher and TVDBProvider. + +Inside of Matcher, put all the code for matching episodes +Inside of TVDBProvider code, put all the code for pulling the show season details + +Assume that I'll give you the following information +- Directory of all the mkv files +- Show Name +- Show Season + +Query TVDB to get the duration of each episode to help you with matching and renaming. Verify that you properly renamed all the files. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..05b8c51 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Episode Matcher + +A tool to automatically classify, organize, and rename TV show episode files for use with media servers like Jellyfin. + +## Features + +- **Automatic Classification**: Distinguishes between episode files and extras based on file size and video duration +- **TVDB Integration**: Fetches episode data from TheTVDB for accurate matching and validation +- **Jellyfin Compatible**: Renames files using Jellyfin naming convention (`Show Name s01e01.mkv`) +- **Extras Organization**: Moves extra files (deleted scenes, behind-the-scenes, etc.) to an "extras" subfolder +- **Duration Matching**: Matches files to episodes based on video duration for accurate episode numbering +- **Validation**: Ensures all expected episodes for a season are processed + +## Installation + +1. Clone or download this repository +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +3. (Optional) Set up your TVDB API key: + ```bash + python setup_config.py + ``` + Or manually edit `config.json` and replace `YOUR_TVDB_API_KEY_HERE` with your actual API key. + +## Configuration + +The tool uses a `config.json` file for settings: + +```json +{ + "tvdb_api_key": "YOUR_TVDB_API_KEY_HERE", + "default_episode_duration": 45, + "classification_thresholds": { + "size_threshold_ratio": 0.3, + "duration_threshold_ratio": 0.4 + } +} +``` + +- **tvdb_api_key**: Your TVDB API key (get one free at https://thetvdb.com/api-information) +- **default_episode_duration**: Default episode length in minutes for mock data +- **classification_thresholds**: Ratios used to classify files as extras + +## Usage + +Basic usage (API key from config.json): +```bash +python episode_matcher.py "/path/to/episode/folder" "Show Name" 1 +``` + +With command-line API key (overrides config): +```bash +python episode_matcher.py "/path/to/episode/folder" "Game of Thrones" 1 --api-key YOUR_TVDB_API_KEY +``` + +Dry run (preview changes without modifying files): +```bash +python episode_matcher.py "/path/to/episode/folder" "Breaking Bad" 2 --dry-run --verbose +``` + +Setup configuration interactively: +```bash +python setup_config.py +``` + +### Arguments + +- `folder_path`: Path to the folder containing MKV files +- `show_name`: Name of the TV show (e.g., "Game of Thrones") +- `season_number`: Season number (1, 2, 3, etc.) +- `--api-key`: Optional TVDB API key (overrides config.json setting) +- `--dry-run`: Preview changes without modifying files +- `--verbose`: Show detailed analysis and file information + +## How It Works + +1. **File Analysis**: Scans the folder for MKV files and analyzes their file sizes and video durations +2. **Classification**: Uses statistical analysis to identify which files are likely episodes vs. extras +3. **TVDB Lookup**: Fetches episode information from TheTVDB including episode count and durations +4. **Duration Matching**: Matches video files to episodes based on duration similarity +5. **Organization**: Moves extras to "extras" subfolder and renames episodes using Jellyfin format +6. **Validation**: Ensures all expected episodes were processed successfully + +## File Naming Convention + +Episodes are renamed following the Jellyfin standard: +- Format: `Show Name s##e##.mkv` +- Examples: + - `Game of Thrones s01e04.mkv` + - `Breaking Bad s02e13.mkv` + - `The Office s03e01.mkv` + +## Classification Logic + +Files are classified as extras if they: +- Are significantly smaller than the average file size (< 30% of average) +- Have significantly shorter duration than average (< 40% of average) +- Exceed the expected number of episodes for the season + +## TVDB API + +To get accurate episode data, you can obtain a free API key from [TheTVDB](https://thetvdb.com/api-information). Without an API key, the tool will use mock data with typical episode counts and durations. + +## Example Output + +``` +=== Episode Matcher === +Folder: /Users/user/TV/Game of Thrones/Season 1 +Show: Game of Thrones +Season: 1 + +Fetching episode data for 'Game of Thrones' season 1... +Found 10 episodes in TVDB data + +=== Classification Results === +Episodes: 10 +Extras: 3 + +=== Moving 3 extras to subfolder === +Moved to extras: deleted_scenes.mkv +Moved to extras: making_of.mkv +Moved to extras: cast_commentary.mkv + +=== Renaming 10 episodes === +Renamed: GOT.S01E01.1080p.mkv → Game of Thrones s01e01.mkv +Renamed: GOT.S01E02.1080p.mkv → Game of Thrones s01e02.mkv +... + +✓ Successfully renamed all 10 episodes for season 1 +``` + +## Requirements + +- Python 3.6+ +- pymediainfo (for video duration analysis) +- requests (for TVDB API calls) + +## Limitations + +- Currently supports MKV files only +- Requires MediaInfo for accurate duration detection +- TVDB API has rate limits (should not be an issue for normal usage) diff --git a/config.json b/config.json new file mode 100644 index 0000000..d37c6bf --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "tvdb_api_key": "0a8eff11-dbaf-4057-b005-4d3aef15c3bf", + "default_episode_duration": 45, + "classification_thresholds": { + "size_threshold_ratio": 0.3, + "duration_threshold_ratio": 0.4 + } +} \ No newline at end of file diff --git a/episode_matcher.py b/episode_matcher.py new file mode 100755 index 0000000..fa0a90b --- /dev/null +++ b/episode_matcher.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +""" +Episode Matcher - Main script for classifying and renaming TV show episode files. + +This tool takes a folder of MKV files and: +1. Classifies them as episodes or extras based on file size and duration +2. Moves extras to an "extras" subfolder +3. Renames episodes using Jellyfin naming convention +4. Uses TVDB data to validate episode matching + +Usage: + python episode_matcher.py [--api-key ] + +Examples: + python episode_matcher.py "/path/to/episodes" "Game of Thrones" 1 + python episode_matcher.py "/path/to/episodes" "Breaking Bad" 2 --api-key your_tvdb_api_key +""" + +import argparse +import sys +import os +from pathlib import Path + +# Add src to path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from TVDBProvider import TVDBClient +from TVDBProvider.tvdb_client import MockTVDBClient +from Matcher import FileClassifier, EpisodeRenamer +from config import config_manager + + +def main(): + """Main function to run the episode matcher.""" + parser = argparse.ArgumentParser( + description="Classify and rename TV show episode files", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + + parser.add_argument( + "folder_path", + help="Path to folder containing MKV files" + ) + + parser.add_argument( + "show_name", + help="Name of the TV show (e.g., 'Game of Thrones')" + ) + + parser.add_argument( + "season_number", + type=int, + help="Season number (e.g., 1, 2, 3)" + ) + + parser.add_argument( + "--api-key", + help="TVDB API key (optional, will check config.json first, then use mock data if not found)" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without actually renaming/moving files" + ) + + parser.add_argument( + "--verbose", "-v", + action="store_true", + help="Show detailed analysis information" + ) + + args = parser.parse_args() + + # Validate inputs + folder_path = Path(args.folder_path) + if not folder_path.exists(): + print(f"Error: Folder not found: {folder_path}") + sys.exit(1) + + if not folder_path.is_dir(): + print(f"Error: Path is not a directory: {folder_path}") + sys.exit(1) + + if args.season_number < 1: + print(f"Error: Season number must be positive, got: {args.season_number}") + sys.exit(1) + + print(f"=== Episode Matcher ===") + print(f"Folder: {folder_path}") + print(f"Show: {args.show_name}") + print(f"Season: {args.season_number}") + print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE'}") + + # Show config status + if args.verbose: + print("\n=== Configuration ===") + config_manager.print_config_status() + + print() + + # Get API key from config or command line argument + api_key = args.api_key or config_manager.get_tvdb_api_key() + + # Initialize TVDB client + if api_key: + print("Using TVDB API with configured key...") + tvdb_client = TVDBClient() + if not tvdb_client.authenticate(api_key): + print("TVDB authentication failed, falling back to mock data") + tvdb_client = MockTVDBClient() + tvdb_client.authenticate() + else: + print("No API key found in config.json or command line, using mock TVDB data...") + print("To use real TVDB data, add your API key to config.json") + tvdb_client = MockTVDBClient() + tvdb_client.authenticate() + + # Get episode information from TVDB + print(f"Fetching episode data for '{args.show_name}' season {args.season_number}...") + tvdb_episodes = tvdb_client.get_episode_durations(args.show_name, args.season_number) + + if tvdb_episodes: + print(f"Found {len(tvdb_episodes)} episodes in TVDB data") + if args.verbose: + print("\nTVDB Episodes:") + for ep in tvdb_episodes: + print(f" Episode {ep['episode_number']}: {ep['name']} ({ep['runtime']} min)") + else: + print("Warning: Could not fetch episode data from TVDB") + tvdb_episodes = [] + + # Initialize file classifier + try: + classifier = FileClassifier(str(folder_path)) + except FileNotFoundError as e: + print(f"Error: {e}") + sys.exit(1) + + if args.verbose: + classifier.print_analysis() + + # Classify files + expected_episode_count = len(tvdb_episodes) if tvdb_episodes else None + episodes, extras = classifier.classify_files(expected_episode_count, tvdb_episodes) + + print(f"\n=== Classification Results ===") + print(f"Episodes: {len(episodes)}") + print(f"Extras: {len(extras)}") + + if not episodes: + print("Error: No episodes found. Check your files and try again.") + sys.exit(1) + + if args.verbose: + print("\nEpisodes:") + for ep in episodes: + print(f" - {ep['name']} ({ep['size_gb']:.2f} GB, {ep['duration_minutes']:.1f} min)") + + if extras: + print("\nExtras:") + for ex in extras: + print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)") + + # Initialize renamer + renamer = EpisodeRenamer(str(folder_path), args.show_name, args.season_number) + + if args.dry_run: + print(f"\n=== DRY RUN - No files will be modified ===") + + if extras: + print(f"\nWould move {len(extras)} files to extras folder:") + for extra in extras: + print(f" - {extra['name']}") + + print(f"\nWould rename {len(episodes)} episode files:") + # Simulate the matching process + if tvdb_episodes: + matched_episodes = renamer._match_episodes_by_duration(episodes, tvdb_episodes) + else: + sorted_episodes = sorted(episodes, key=lambda x: x['size_bytes'], reverse=True) + matched_episodes = [] + for i, episode in enumerate(sorted_episodes, 1): + matched_episodes.append({ + 'file_info': episode, + 'episode_number': i, + 'tvdb_info': None + }) + + for match in matched_episodes: + episode_number = match['episode_number'] + file_info = match['file_info'] + original_name = file_info['name'] # This should work now + new_name = renamer._generate_episode_filename(episode_number, Path(original_name).suffix) + print(f" Episode {episode_number:2d}: {original_name} → {new_name}") + + else: + # Move extras to folder + if extras: + print(f"\n=== Moving {len(extras)} extras to subfolder ===") + moved_extras = renamer.move_extras_to_folder(extras) + print(f"Successfully moved {len(moved_extras)} files to extras folder") + + # Rename episodes + print(f"\n=== Renaming {len(episodes)} episodes ===") + renamed_files = renamer.rename_episodes(episodes, tvdb_episodes) + + if renamed_files: + renamer.print_rename_summary(renamed_files) + + # Validate that all episodes were processed + expected_count = len(tvdb_episodes) if tvdb_episodes else len(episodes) + success = renamer.validate_season_complete(renamed_files, expected_count) + + if success: + print(f"\n✓ Episode matching completed successfully!") + else: + print(f"\n⚠ Episode matching completed with warnings") + sys.exit(1) + else: + print("Error: No episodes were renamed") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..327ae30 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.25.1 +pathlib2>=2.3.5 +pymediainfo>=5.1.0 diff --git a/setup_config.py b/setup_config.py new file mode 100755 index 0000000..ba9bcb1 --- /dev/null +++ b/setup_config.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Configuration setup script for Episode Matcher. +Use this to easily set up your TVDB API key. +""" + +import sys +import os +from pathlib import Path + +# Add src to path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from config import config_manager + + +def main(): + """Set up configuration interactively.""" + print("=== Episode Matcher Configuration Setup ===\n") + + config_manager.print_config_status() + print() + + # API Key setup + current_key = config_manager.get_tvdb_api_key() + if current_key: + response = input("API key is already configured. Update it? (y/n): ").strip().lower() + if response not in ['y', 'yes']: + print("Configuration unchanged.") + return + + print("\nTo get a TVDB API key:") + print("1. Go to https://thetvdb.com/api-information") + print("2. Create a free account") + print("3. Generate an API key") + print() + + api_key = input("Enter your TVDB API key (or press Enter to skip): ").strip() + + if api_key: + if config_manager.update_api_key(api_key): + print("✓ API key saved successfully!") + print("\nYou can now use the episode matcher without the --api-key argument:") + print('python episode_matcher.py "/path/to/episodes" "Show Name" 1') + else: + print("✗ Failed to save API key.") + else: + print("No API key entered. Mock data will be used.") + + print("\nConfiguration setup complete!") + + +if __name__ == "__main__": + main() diff --git a/src/.tvdb_cache/game_of_thrones_s01.json b/src/.tvdb_cache/game_of_thrones_s01.json new file mode 100644 index 0000000..2885c49 --- /dev/null +++ b/src/.tvdb_cache/game_of_thrones_s01.json @@ -0,0 +1,68 @@ +{ + "series_name": "Game of Thrones", + "season_number": 1, + "episodes": [ + { + "episode_number": 1, + "name": "Winter Is Coming", + "runtime": 61, + "aired": "2011-04-17" + }, + { + "episode_number": 2, + "name": "The Kingsroad", + "runtime": 55, + "aired": "2011-04-24" + }, + { + "episode_number": 3, + "name": "Lord Snow", + "runtime": 57, + "aired": "2011-05-01" + }, + { + "episode_number": 4, + "name": "Cripples, Bastards, and Broken Things", + "runtime": 55, + "aired": "2011-05-08" + }, + { + "episode_number": 5, + "name": "The Wolf and the Lion", + "runtime": 54, + "aired": "2011-05-15" + }, + { + "episode_number": 6, + "name": "A Golden Crown", + "runtime": 52, + "aired": "2011-05-22" + }, + { + "episode_number": 7, + "name": "You Win or You Die", + "runtime": 57, + "aired": "2011-05-29" + }, + { + "episode_number": 8, + "name": "The Pointy End", + "runtime": 58, + "aired": "2011-06-05" + }, + { + "episode_number": 9, + "name": "Baelor", + "runtime": 56, + "aired": "2011-06-12" + }, + { + "episode_number": 10, + "name": "Fire and Blood", + "runtime": 52, + "aired": "2011-06-19" + } + ], + "cached_at": "2025-08-07T19:06:57.180338", + "cache_duration_days": 7 +} \ No newline at end of file diff --git a/src/.tvdb_cache/game_of_thrones_s02.json b/src/.tvdb_cache/game_of_thrones_s02.json new file mode 100644 index 0000000..fe4a481 --- /dev/null +++ b/src/.tvdb_cache/game_of_thrones_s02.json @@ -0,0 +1,68 @@ +{ + "series_name": "Game of Thrones", + "season_number": 2, + "episodes": [ + { + "episode_number": 1, + "name": "The North Remembers", + "runtime": 52, + "aired": "2012-04-01" + }, + { + "episode_number": 2, + "name": "The Night Lands", + "runtime": 53, + "aired": "2012-04-08" + }, + { + "episode_number": 3, + "name": "What Is Dead May Never Die", + "runtime": 52, + "aired": "2012-04-15" + }, + { + "episode_number": 4, + "name": "Garden of Bones", + "runtime": 50, + "aired": "2012-04-22" + }, + { + "episode_number": 5, + "name": "The Ghost of Harrenhal", + "runtime": 54, + "aired": "2012-04-29" + }, + { + "episode_number": 6, + "name": "The Old Gods and the New", + "runtime": 53, + "aired": "2012-05-06" + }, + { + "episode_number": 7, + "name": "A Man Without Honor", + "runtime": 56, + "aired": "2012-05-13" + }, + { + "episode_number": 8, + "name": "The Prince of Winterfell", + "runtime": 53, + "aired": "2012-05-20" + }, + { + "episode_number": 9, + "name": "Blackwater", + "runtime": 54, + "aired": "2012-05-27" + }, + { + "episode_number": 10, + "name": "Valar Morghulis", + "runtime": 63, + "aired": "2012-06-03" + } + ], + "cached_at": "2025-08-07T19:38:16.028413", + "cache_duration_days": 7 +} \ No newline at end of file diff --git a/src/Matcher/__init__.py b/src/Matcher/__init__.py new file mode 100644 index 0000000..d9a4bd4 --- /dev/null +++ b/src/Matcher/__init__.py @@ -0,0 +1,5 @@ +"""Episode Matcher module for classifying and renaming video files.""" +from .file_classifier import FileClassifier +from .episode_renamer import EpisodeRenamer + +__all__ = ['FileClassifier', 'EpisodeRenamer'] diff --git a/src/Matcher/__pycache__/__init__.cpython-313.pyc b/src/Matcher/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55ff7a0970eb32435542a5e39b8568e5b6f5dc88 GIT binary patch literal 400 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 literal 0 HcmV?d00001 diff --git a/src/Matcher/__pycache__/file_classifier.cpython-313.pyc b/src/Matcher/__pycache__/file_classifier.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f0b7d2a86d3ef67c94b1d2467e0d9cc49f5b646 GIT binary patch literal 14870 zcmbVzYfu|mmS$$^fe=CxNIb+#$s18dV1vtsvaxv^u(3@dzss_a1eOgFl_}gt_2`gjYLXyt=6#%d^W1aK`OdjlU*zYTDY*E-w{QN(BNX*Zd{Hlb`r(Tt zJiJYD6i;z9$BfcEO;UzuAk~fPM)bU%#WMYsVdAhjOLD*c{6!4 zayg@xk$gU%rAnz9iZhi_T<&2;>4~>aGu70!yc{ZEpeUfo@MbqPRxw2#*SV=FdQVwC zl+~(b9&_U8U}PrD24@0-5SfaEd3Gwsv!RF(k3^^EBf?Ec*zjCLh=sxeJH^Ll+3?*s z9}v6`vHLL(E$hz(;x|1yS$`T@$@>0CFfJP==I3U@P?&or9Et>nqf@bmI9|xH24m5w z$h3bp5DiR+`JnO@%AK$k9>1ufk$KlBU>b!C<8|;^dQNxE(7|x}GU}F|H*$t+IoC|^ z%@pJrNnS1oIgoGe&?A$eR$e;C0y()PC!e&lrrViGo(=M>KqZg7**Pbdf6dHUfj+4R zr3LDxIDX1l|0Usg6!5#ms)hC2`q4idPj|8H@SR9wr&A@EyIX-qP9E=OC1In5@6Nm?IhWS>354Nh$gmh*_ zdgm5olOOtu#QlD`WN_D{d)0y>=vZij+w0Wlwx4skaBq!_5Z>81+<}LBOi{>^U8m*^z5Q$NCmx*EUZoSITd64HqaD<#OB5B=`E=u| zj#0@&I*t;s=i+oK4fQJmFt=ywDe4>0(CnM*gadapgX9F`c?3vLEX?+lhvhkbY^g1~J`XD$nAHpRfEC})N ztWwLHC>&H4QZyEay7SRc7n?9NduMOoNibf&OnVdc7Xv)5tuA&f#@>M?7|QB}G-07H zPp-1ejN!XrB?P$u#ds_hA4Hl1JRjp_{S2`m(=)M2Ssx9|hGnx55AbmT7qn~)%*}

maM-|ol+Dh5W?^_B)XXjeoR@aEsH6nJ7iiM{) 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 adl>3RrW3mVL7&il%$R!ebst-3xc>{C1y4x; literal 0 HcmV?d00001 diff --git a/src/Matcher/episode_renamer.py b/src/Matcher/episode_renamer.py new file mode 100644 index 0000000..46baed2 --- /dev/null +++ b/src/Matcher/episode_renamer.py @@ -0,0 +1,666 @@ +"""Episode renamer for creating Jellyfin-compatible filenames.""" +import os +import shutil +from pathlib import Path +from typing import List, Dict, Optional +import re + + +class EpisodeRenamer: + """Renames episode files to Jellyfin format and moves extras to subfolder.""" + + def __init__(self, folder_path: str, show_name: str, season_number: int): + """Initialize with folder path, show name, and season number.""" + self.folder_path = Path(folder_path) + self.show_name = show_name + self.season_number = season_number + self.extras_folder = self.folder_path / "extras" + + def _create_extras_folder(self) -> bool: + """Create extras folder if it doesn't exist.""" + try: + self.extras_folder.mkdir(exist_ok=True) + return True + except Exception as e: + print(f"Error creating extras folder: {e}") + return False + + def _sanitize_filename(self, filename: str) -> str: + """Sanitize filename to remove invalid characters.""" + # Remove invalid characters for file systems + invalid_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*'] + for char in invalid_chars: + filename = filename.replace(char, '') + + # Replace multiple spaces with single space + filename = re.sub(r'\s+', ' ', filename) + + return filename.strip() + + def _generate_episode_filename(self, episode_number: int, original_extension: str = ".mkv") -> str: + """Generate Jellyfin-compatible episode filename.""" + # Format: "Show Name s01e01.mkv" + season_str = f"{self.season_number:02d}" + episode_str = f"{episode_number:02d}" + + filename = f"{self.show_name} s{season_str}e{episode_str}{original_extension}" + return self._sanitize_filename(filename) + + def move_extras_to_folder(self, extras: List[Dict]) -> List[str]: + """Move extra files to the extras folder.""" + if not self._create_extras_folder(): + return [] + + moved_files = [] + + for extra in extras: + source_path = extra['path'] + dest_path = self.extras_folder / source_path.name + + try: + # Check if destination already exists + if dest_path.exists(): + print(f"Warning: {dest_path.name} already exists in extras folder, skipping.") + continue + + shutil.move(str(source_path), str(dest_path)) + moved_files.append(str(dest_path)) + print(f"Moved to extras: {source_path.name}") + + except Exception as e: + print(f"Error moving {source_path.name} to extras: {e}") + + return moved_files + + def _extract_disc_info(self, filename: str) -> Dict: + """Extract disc number and track number from filename.""" + # Look for patterns like "Disc 1", "Disc1", "D1", etc. + disc_match = re.search(r'[Dd]isc\s*(\d+)', filename, re.IGNORECASE) + disc_number = int(disc_match.group(1)) if disc_match else None + + # Look for track patterns like "_t01", "_t22", "track01", etc. + track_match = re.search(r'(?:_t|track)(\d+)', filename, re.IGNORECASE) + track_number = int(track_match.group(1)) if track_match else None + + return { + 'disc_number': disc_number, + 'track_number': track_number, + 'filename': filename + } + + def _estimate_episodes_per_disc(self, episodes_info: List[Dict], total_episodes: int) -> Dict[int, List[int]]: + """Estimate which episodes are on which disc based on file distribution.""" + # Group files by disc and sort within each disc + discs = {} + for info in episodes_info: + disc = info['disc_number'] + if disc: + if disc not in discs: + discs[disc] = [] + discs[disc].append(info) + + if not discs: + return {} + + # Sort files within each disc by filename (lexicographical order) + for disc_num in discs: + discs[disc_num].sort(key=lambda x: x['filename']) + + # Sort discs by number + sorted_disc_nums = sorted(discs.keys()) + episodes_per_disc = {} + + # Assign episodes sequentially across discs + episode_number = 1 + for disc_num in sorted_disc_nums: + files_on_disc = discs[disc_num] + episodes_on_this_disc = [] + + for _ in files_on_disc: + if episode_number <= total_episodes: + episodes_on_this_disc.append(episode_number) + episode_number += 1 + + episodes_per_disc[disc_num] = episodes_on_this_disc + + return episodes_per_disc + + def rename_episodes(self, episodes: List[Dict], tvdb_episodes: Optional[List[Dict]] = None) -> List[Dict]: + """ + Rename episode files to Jellyfin format. + + Args: + episodes: List of episode file info dictionaries + tvdb_episodes: Optional TVDB episode data for validation + + Returns: + List of rename operations performed + """ + if not episodes: + print("No episodes to rename.") + return [] + + # If we have TVDB data, try to match by duration + if tvdb_episodes: + matched_episodes = self._match_episodes_by_duration(episodes, tvdb_episodes) + else: + # Fallback: assume files are in episode order when sorted by size + sorted_episodes = sorted(episodes, key=lambda x: x['size_bytes'], reverse=True) + matched_episodes = [] + for i, episode in enumerate(sorted_episodes, 1): + matched_episodes.append({ + 'file_info': episode, + 'episode_number': i, + 'tvdb_info': None + }) + + # Perform renames + renamed_files = [] + + for match in matched_episodes: + file_info = match['file_info'] + episode_number = match['episode_number'] + source_path = file_info['path'] + + # Get file extension + original_extension = source_path.suffix + + # Generate new filename + new_filename = self._generate_episode_filename(episode_number, original_extension) + new_path = source_path.parent / new_filename + + # Check if destination already exists + if new_path.exists() and new_path != source_path: + print(f"Warning: {new_filename} already exists, skipping rename of {source_path.name}") + continue + + try: + # Rename the file + source_path.rename(new_path) + + rename_info = { + 'original_name': source_path.name, + 'new_name': new_filename, + 'episode_number': episode_number, + 'file_size_gb': file_info['size_gb'], + 'duration_minutes': file_info['duration_minutes'] + } + + if match['tvdb_info']: + rename_info['tvdb_duration'] = match['tvdb_info']['runtime'] + rename_info['tvdb_name'] = match['tvdb_info']['name'] + + renamed_files.append(rename_info) + print(f"Renamed: {source_path.name} → {new_filename}") + + except Exception as e: + print(f"Error renaming {source_path.name}: {e}") + + return renamed_files + + def _match_episodes_by_duration(self, episodes: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Match episode files with TVDB episode data based on duration and lexicographical order.""" + matched_episodes = [] + + # Extract disc and track information + episodes_info = [] + for episode in episodes: + filename = episode['path'].name + disc_info = self._extract_disc_info(filename) + episodes_info.append({ + 'file_info': episode, + 'disc_number': disc_info['disc_number'], + 'track_number': disc_info['track_number'], + 'filename': filename, + 'duration': episode['duration_minutes'] + }) + + # Sort by lexicographical order (this should match episode order) + episodes_info.sort(key=lambda x: x['filename']) + + # If we have disc information, use it for context but prioritize duration matching + if any(info['disc_number'] for info in episodes_info): + print("Found disc information - using flexible duration-first matching...") + # Use flexible matching that prioritizes duration over disc constraints + matched_episodes = self._flexible_duration_match(episodes_info, tvdb_episodes) + else: + # No disc info, use lexicographical order with precise duration matching + print("No disc information found, using lexicographical order with precise duration matching...") + matched_episodes = self._match_by_duration_and_order(episodes_info, tvdb_episodes) + + # Sort by episode number for final output + matched_episodes.sort(key=lambda x: x['episode_number']) + return matched_episodes + + def _flexible_duration_match(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Match episodes using sequential assignment with constraint validation.""" + # Sort episodes info by lexicographical order (this determines episode sequence) + sorted_episodes_info = sorted(episodes_info, key=lambda x: x['filename']) + + # Analyze disc capacity constraints + disc_capacity = self._analyze_disc_capacity(sorted_episodes_info) + + print(f" Sequential assignment ({len(sorted_episodes_info)} files → {len(tvdb_episodes)} episodes)...") + if disc_capacity: + print(f" Disc capacity constraints: {disc_capacity}") + + # Use strict sequential assignment with constraint validation + assignments = self._sequential_assignment_with_constraints(sorted_episodes_info, tvdb_episodes, disc_capacity) + + return assignments + + def _sequential_assignment_with_constraints(self, files: List[Dict], tvdb_episodes: List[Dict], disc_capacity: Dict[int, int]) -> List[Dict]: + """Assign files to episodes sequentially while respecting all constraints.""" + assignments = [] + + # Track disc usage + disc_assignments = {} + for disc_num in disc_capacity: + disc_assignments[disc_num] = 0 + + # Track which episodes have been assigned + used_episodes = set() + + # Go through files in lexicographical order + for file_idx, file_info in enumerate(files): + best_assignment = None + best_score = float('inf') + + # For each file, try to assign it to the next available episode in sequential order + for episode_number in range(1, len(tvdb_episodes) + 1): + if episode_number in used_episodes: + continue + + # Find the TVDB episode for this episode number + tvdb_episode = None + for ep in tvdb_episodes: + if ep['episode_number'] == episode_number: + tvdb_episode = ep + break + + if not tvdb_episode: + continue + + # Check all constraints + if self._validate_sequential_assignment(file_info, tvdb_episode, disc_assignments, disc_capacity): + file_duration = file_info['duration'] + tvdb_duration = tvdb_episode.get('runtime', 0) + + if tvdb_duration > 0: + duration_diff = abs(file_duration - tvdb_duration) + # Prefer episodes closer to the file's natural position, but also consider duration + position_penalty = abs(episode_number - (file_idx + 1)) * 0.5 # Small penalty for out-of-order + total_score = duration_diff + position_penalty + + if total_score < best_score: + best_score = total_score + best_assignment = (episode_number, tvdb_episode, duration_diff) + + # If we find a very good match (within 1 minute and correct position), take it immediately + if duration_diff <= 1.0 and episode_number == (file_idx + 1): + break + else: + # No TVDB duration, just assign by position + if episode_number == (file_idx + 1): + best_assignment = (episode_number, tvdb_episode, None) + break + + # Make the best assignment we found + if best_assignment: + episode_number, tvdb_episode, duration_diff = best_assignment + + # Update tracking + used_episodes.add(episode_number) + file_disc = file_info['disc_number'] + if file_disc and file_disc in disc_assignments: + disc_assignments[file_disc] += 1 + + assignments.append({ + 'file_info': file_info['file_info'], + 'episode_number': episode_number, + 'tvdb_info': tvdb_episode, + 'duration_diff': duration_diff, + 'assignment_cost': duration_diff or 0 + }) + + disc_num = file_info['disc_number'] or "?" + if duration_diff is not None: + if duration_diff <= 1.0: + print(f" Disc {disc_num}: {file_info['filename'][:50]}... → Episode {episode_number} (Δ{duration_diff:.1f}min) ✓") + elif duration_diff <= 2.0: + print(f" Disc {disc_num}: {file_info['filename'][:50]}... → Episode {episode_number} (Δ{duration_diff:.1f}min) ~ acceptable") + else: + print(f" Disc {disc_num}: {file_info['filename'][:50]}... → Episode {episode_number} (Δ{duration_diff:.1f}min) ⚠ large difference") + else: + print(f" Disc {disc_num}: {file_info['filename'][:50]}... → Episode {episode_number} (no TVDB duration)") + else: + # No valid assignment found + disc_num = file_info['disc_number'] or "?" + print(f" Disc {disc_num}: {file_info['filename'][:50]}... → No valid assignment (all constraints violated)") + + # Sort by episode number for output + assignments.sort(key=lambda x: x['episode_number']) + return assignments + + def _validate_sequential_assignment(self, file_info: Dict, tvdb_episode: Dict, + disc_assignments: Dict[int, int], disc_capacity: Dict[int, int]) -> bool: + """Validate a sequential assignment against all constraints.""" + file_duration = file_info['duration'] + tvdb_duration = tvdb_episode.get('runtime', 0) + file_disc = file_info['disc_number'] + + # Constraint 1: Duration difference must be ≤ 1 minute (but allow if no better options) + if tvdb_duration > 0: + duration_diff = abs(file_duration - tvdb_duration) + if duration_diff > 1.0: + # Allow violations if duration is close (within 2 minutes) for edge cases + if duration_diff > 2.0: + return False + + # Constraint 2: Video length should be >= TVDB duration (with reasonable tolerance) + if tvdb_duration > 0: + # Allow files to be up to 2 minutes shorter (encoding differences) + if file_duration < (tvdb_duration - 2.0): + return False + + # Constraint 3: Disc capacity constraint (strict) + if file_disc and disc_capacity: + max_episodes_on_disc = disc_capacity.get(file_disc, 0) + current_episodes_on_disc = disc_assignments.get(file_disc, 0) + + if current_episodes_on_disc >= max_episodes_on_disc: + return False + + return True + + def _find_optimal_assignment(self, files: List[Dict], tvdb_episodes: List[Dict], disc_capacity: Dict[int, int]) -> List[Dict]: + """Find optimal assignment using constraint-aware matching.""" + assignments = [] + used_files = set() + used_episodes = set() + + # First pass: Find perfect duration matches that satisfy all constraints + for file_info in files: + if id(file_info) in used_files: + continue + + best_match = None + best_score = float('inf') + + for tvdb_ep in tvdb_episodes: + if tvdb_ep['episode_number'] in used_episodes: + continue + + # Check if this assignment would be valid + file_duration = file_info['duration'] + tvdb_duration = tvdb_ep.get('runtime', 0) + + # Apply constraints + if not self._assignment_satisfies_constraints(file_info, tvdb_ep, assignments, disc_capacity): + continue + + # Calculate match quality + if tvdb_duration > 0: + duration_diff = abs(file_duration - tvdb_duration) + # Prefer matches within 1 minute + if duration_diff <= 1.0: + score = duration_diff + if score < best_score: + best_score = score + best_match = tvdb_ep + + # Accept good matches + if best_match and best_score <= 1.0: + assignments.append({ + 'file_info': file_info['file_info'], + 'episode_number': best_match['episode_number'], + 'tvdb_info': best_match, + 'duration_diff': best_score, + 'assignment_cost': best_score + }) + used_files.add(id(file_info)) + used_episodes.add(best_match['episode_number']) + + disc_num = file_info['disc_number'] or "?" + print(f" Disc {disc_num} optimal match: {file_info['filename'][:50]}... → Episode {best_match['episode_number']} (Δ{best_score:.1f}min)") + + # Second pass: Sequential assignment for remaining files (respecting order) + remaining_files = [f for f in files if id(f) not in used_files] + remaining_episodes = [ep for ep in tvdb_episodes if ep['episode_number'] not in used_episodes] + remaining_episodes.sort(key=lambda x: x['episode_number']) + + for file_info in remaining_files: + if not remaining_episodes: + break + + # Try to assign to the next available episode in order + for tvdb_ep in remaining_episodes[:]: + if self._assignment_satisfies_constraints(file_info, tvdb_ep, assignments, disc_capacity): + file_duration = file_info['duration'] + tvdb_duration = tvdb_ep.get('runtime', 0) + duration_diff = abs(file_duration - tvdb_duration) if tvdb_duration > 0 else None + + assignments.append({ + 'file_info': file_info['file_info'], + 'episode_number': tvdb_ep['episode_number'], + 'tvdb_info': tvdb_ep, + 'duration_diff': duration_diff, + 'assignment_cost': duration_diff or 0 + }) + + remaining_episodes.remove(tvdb_ep) + disc_num = file_info['disc_number'] or "?" + if duration_diff is not None: + print(f" Disc {disc_num} sequential: {file_info['filename'][:50]}... → Episode {tvdb_ep['episode_number']} (Δ{duration_diff:.1f}min)") + else: + print(f" Disc {disc_num} sequential: {file_info['filename'][:50]}... → Episode {tvdb_ep['episode_number']} (no duration)") + break + + # Sort by episode number for output + assignments.sort(key=lambda x: x['episode_number']) + return assignments + + def _assignment_satisfies_constraints(self, file_info: Dict, tvdb_episode: Dict, + current_assignments: List[Dict], disc_capacity: Dict[int, int]) -> bool: + """Check if assigning a file to an episode satisfies all constraints.""" + file_duration = file_info['duration'] + tvdb_duration = tvdb_episode.get('runtime', 0) + file_disc = file_info['disc_number'] + + # Constraint 1: Duration difference must be ≤ 1 minute + if tvdb_duration > 0: + duration_diff = abs(file_duration - tvdb_duration) + if duration_diff > 1.0: + return False + + # Constraint 2: Video length should be >= TVDB duration (with small tolerance) + if tvdb_duration > 0: + if file_duration < (tvdb_duration - 0.5): + return False + + # Constraint 3: Disc capacity constraint + if file_disc and disc_capacity: + max_episodes_on_disc = disc_capacity.get(file_disc, 0) + + # Count current assignments to this disc + current_disc_assignments = sum(1 for a in current_assignments + if a['file_info']['path'].name.find(f'Disc {file_disc}') != -1) + + if current_disc_assignments >= max_episodes_on_disc: + return False + + return True + + def _analyze_disc_capacity(self, episodes_info: List[Dict]) -> Dict[int, int]: + """Analyze the maximum number of episodes each disc can hold.""" + disc_capacity = {} + + # Group files by disc + disc_files = {} + for info in episodes_info: + disc_num = info['disc_number'] + if disc_num: + if disc_num not in disc_files: + disc_files[disc_num] = [] + disc_files[disc_num].append(info) + + # Each disc can hold at most as many episodes as it has files + for disc_num, files in disc_files.items(): + disc_capacity[disc_num] = len(files) + + return disc_capacity + + def _is_valid_assignment(self, file_info: Dict, tvdb_episode: Dict, disc_capacity: Dict[int, int], + episode_index: int, all_files: List[Dict]) -> bool: + """Validate if assigning a file to an episode satisfies all constraints.""" + file_duration = file_info['duration'] + tvdb_duration = tvdb_episode.get('runtime', 0) + episode_num = tvdb_episode['episode_number'] + file_disc = file_info['disc_number'] + + # Constraint 1: Duration difference must be ≤ 1 minute + if tvdb_duration > 0: + duration_diff = abs(file_duration - tvdb_duration) + if duration_diff > 1.0: + return False + + # Constraint 2: Video length should be >= TVDB duration + # (Allow small tolerance for encoding differences) + if tvdb_duration > 0: + if file_duration < (tvdb_duration - 0.5): # 30 second tolerance + return False + + # Constraint 3: Disc capacity constraint + # In diagonal traversal, we're assigning file_index to episode_index + # So we need to check if this disc has already been "filled up" by previous assignments + if file_disc and disc_capacity: + max_episodes_on_disc = disc_capacity.get(file_disc, 0) + + # Count how many previous files from this disc have been assigned + episodes_already_assigned_to_disc = 0 + for prev_file_idx in range(episode_index): # Previous assignments in diagonal + if prev_file_idx < len(all_files): + prev_file = all_files[prev_file_idx] + if prev_file['disc_number'] == file_disc: + episodes_already_assigned_to_disc += 1 + + # Adding this assignment would exceed disc capacity + if episodes_already_assigned_to_disc >= max_episodes_on_disc: + return False + + return True + + def _match_by_duration_and_disc(self, episodes_info: List[Dict], tvdb_episodes: List[Dict], episodes_per_disc: Dict[int, List[int]]) -> List[Dict]: + """Match episodes using disc constraints and precise duration matching.""" + matched_episodes = [] + used_episode_numbers = set() # Process each disc in order + for disc_num in sorted(episodes_per_disc.keys()): + files_on_disc = [f for f in episodes_info if f['disc_number'] == disc_num] + files_on_disc.sort(key=lambda x: x['filename']) + allowed_episodes = episodes_per_disc[disc_num] + + # Get TVDB episodes for this disc + disc_tvdb_episodes = [ep for ep in tvdb_episodes if ep['episode_number'] in allowed_episodes] + + # Match files to episodes by duration within this disc + disc_matches = self._precise_duration_match(files_on_disc, disc_tvdb_episodes, allowed_episodes) + + for match in disc_matches: + if match['episode_number'] not in used_episode_numbers: + matched_episodes.append(match) + used_episode_numbers.add(match['episode_number']) + + duration_diff = match.get('duration_diff', 0) + if duration_diff is not None: + print(f" Disc {disc_num} match: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (Δ{duration_diff:.1f}min)") + else: + print(f" Disc {disc_num} fallback: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (no duration match)") + + return matched_episodes + + def _match_by_duration_and_order(self, episodes_info: List[Dict], tvdb_episodes: List[Dict]) -> List[Dict]: + """Match episodes using lexicographical order and precise duration matching.""" + matched_episodes = [] + + # Try precise duration matching first + duration_matches = self._precise_duration_match(episodes_info, tvdb_episodes, list(range(1, len(tvdb_episodes) + 1))) + + for match in duration_matches: + matched_episodes.append(match) + duration_diff = match.get('duration_diff', 0) + if duration_diff is not None: + print(f" Duration match: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (Δ{duration_diff:.1f}min)") + else: + print(f" Sequential fallback: {match['file_info']['path'].name[:50]}... → Episode {match['episode_number']} (no duration match)") + + return matched_episodes + + def _precise_duration_match(self, files: List[Dict], tvdb_episodes: List[Dict], allowed_episodes: List[int]) -> List[Dict]: + """Perform precise duration matching with fallback to lexicographical order.""" + matched_episodes = [] + used_files = set() + used_episodes = set() + + # First pass: Find exact or very close duration matches (within 5 minutes) + for file_info in files: + if id(file_info) in used_files: + continue + + file_duration = file_info['duration'] + best_match = None + best_score = float('inf') + + for tvdb_ep in tvdb_episodes: + if tvdb_ep['episode_number'] in used_episodes: + continue + if tvdb_ep['episode_number'] not in allowed_episodes: + continue + + tvdb_duration = tvdb_ep.get('runtime', 0) + if tvdb_duration == 0: + continue + + # Calculate duration difference + duration_diff = abs(file_duration - tvdb_duration) + + if duration_diff < best_score: + best_score = duration_diff + best_match = tvdb_ep + + # Accept matches within 1 minute as good matches (strict duration matching) + if best_match and best_score <= 1.0: + matched_episodes.append({ + 'file_info': file_info['file_info'], + 'episode_number': best_match['episode_number'], + 'tvdb_info': best_match, + 'duration_diff': best_score + }) + used_files.add(id(file_info)) + used_episodes.add(best_match['episode_number']) + + # Second pass: Handle remaining files with sequential assignment + remaining_files = [f for f in files if id(f) not in used_files] + remaining_files.sort(key=lambda x: x['filename']) + + available_episodes = [ep_num for ep_num in allowed_episodes if ep_num not in used_episodes] + available_episodes.sort() + + for i, file_info in enumerate(remaining_files): + if i < len(available_episodes): + episode_number = available_episodes[i] + + # Find corresponding TVDB episode + tvdb_match = None + for tvdb_ep in tvdb_episodes: + if tvdb_ep['episode_number'] == episode_number: + tvdb_match = tvdb_ep + break + + matched_episodes.append({ + 'file_info': file_info['file_info'], + 'episode_number': episode_number, + 'tvdb_info': tvdb_match, + 'duration_diff': None + }) + + return matched_episodes diff --git a/src/Matcher/file_classifier.py b/src/Matcher/file_classifier.py new file mode 100644 index 0000000..a96bf57 --- /dev/null +++ b/src/Matcher/file_classifier.py @@ -0,0 +1,310 @@ +"""File classifier for distinguishing episodes from extras.""" +import os +import sys +from pathlib import Path +from typing import List, Dict, Tuple +try: + from pymediainfo import MediaInfo +except ImportError: + MediaInfo = None + +# Import config if available +try: + from ..config import config_manager +except ImportError: + config_manager = None + + +class FileClassifier: + """Classifies video files as episodes or extras based on file size and video duration.""" + + def __init__(self, folder_path: str): + """Initialize with folder path containing video files.""" + self.folder_path = Path(folder_path) + self.video_files = self._get_video_files() + self.file_info = self._analyze_files() + + def _get_video_files(self) -> List[Path]: + """Get all MKV files in the folder, excluding system files.""" + if not self.folder_path.exists(): + raise FileNotFoundError(f"Folder not found: {self.folder_path}") + + all_mkv_files = list(self.folder_path.glob("*.mkv")) + + # Filter out macOS resource fork files and other system files + video_files = [] + for file in all_mkv_files: + if file.name.startswith('._'): + continue # Skip macOS resource fork files + if file.name.startswith('.'): + continue # Skip any hidden files + video_files.append(file) + + if not video_files: + print(f"Warning: No valid MKV files found in {self.folder_path}") + + return video_files + + def _get_file_size(self, file_path: Path) -> int: + """Get file size in bytes.""" + return file_path.stat().st_size + + def _get_video_duration(self, file_path: Path) -> float: + """Get video duration in minutes using MediaInfo.""" + if MediaInfo is None: + print("MediaInfo not available, using file size as proxy for duration") + # Rough estimate: 1GB ≈ 45 minutes for typical video + size_gb = self._get_file_size(file_path) / (1024**3) + return size_gb * 45 + + try: + media_info = MediaInfo.parse(str(file_path)) + for track in media_info.tracks: + if track.track_type == 'Video': + duration_ms = track.duration + if duration_ms: + # Handle both string and numeric duration values + if isinstance(duration_ms, str): + try: + duration_ms = float(duration_ms) + except ValueError: + print(f"Warning: Could not parse duration '{duration_ms}' for {file_path}") + continue + + duration_minutes = float(duration_ms) / (1000 * 60) + + # Sanity check: if duration seems unreasonable, fall back to size estimation + if duration_minutes > 300: # More than 5 hours is likely wrong + print(f"Warning: Suspicious duration ({duration_minutes:.1f} min) for {file_path.name}, using size estimation") + size_gb = self._get_file_size(file_path) / (1024**3) + return size_gb * 45 + + return duration_minutes + except Exception as e: + print(f"Error getting duration for {file_path}: {e}") + + # Fallback to size-based estimation + size_gb = self._get_file_size(file_path) / (1024**3) + return size_gb * 45 + + def _analyze_files(self) -> List[Dict]: + """Analyze all video files to get size and duration info.""" + file_info = [] + + for file_path in self.video_files: + size_bytes = self._get_file_size(file_path) + duration_minutes = self._get_video_duration(file_path) + + info = { + 'path': file_path, + 'name': file_path.name, + 'size_bytes': size_bytes, + 'size_mb': size_bytes / (1024**2), + 'size_gb': size_bytes / (1024**3), + 'duration_minutes': duration_minutes + } + file_info.append(info) + + return file_info + + def _calculate_stats(self) -> Dict: + """Calculate statistics for file sizes and durations.""" + if not self.file_info: + return {} + + sizes = [info['size_bytes'] for info in self.file_info] + durations = [info['duration_minutes'] for info in self.file_info] + + stats = { + 'count': len(self.file_info), + 'avg_size_bytes': sum(sizes) / len(sizes), + 'avg_size_mb': sum(sizes) / len(sizes) / (1024**2), + 'avg_size_gb': sum(sizes) / len(sizes) / (1024**3), + 'avg_duration_minutes': sum(durations) / len(durations), + 'median_size_bytes': sorted(sizes)[len(sizes)//2], + 'median_duration_minutes': sorted(durations)[len(durations)//2] + } + + return stats + + def classify_files(self, expected_episode_count: int = None, tvdb_episodes: List[Dict] = None) -> Tuple[List[Dict], List[Dict]]: + """ + Classify files as episodes or extras. + + Args: + expected_episode_count: Expected number of episodes in the season + tvdb_episodes: TVDB episode data with durations for smarter matching + + Returns: + Tuple of (episodes, extras) lists containing file info dictionaries. + """ + if not self.file_info: + return [], [] + + # If we have TVDB episode data, use it for smarter classification + if tvdb_episodes and len(tvdb_episodes) > 0: + return self._classify_with_tvdb_data(tvdb_episodes, expected_episode_count) + + # Fallback to size/duration-based classification + return self._classify_by_stats(expected_episode_count) + + def _classify_with_tvdb_data(self, tvdb_episodes: List[Dict], expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: + """Classify files using TVDB episode duration data for better matching.""" + if not tvdb_episodes: + return self._classify_by_stats(expected_episode_count) + + # Get TVDB episode durations + tvdb_durations = [ep.get('runtime', 0) for ep in tvdb_episodes if ep.get('runtime', 0) > 0] + if not tvdb_durations: + return self._classify_by_stats(expected_episode_count) + + avg_tvdb_duration = sum(tvdb_durations) / len(tvdb_durations) + min_episode_duration = min(tvdb_durations) - 5 # 5 minute tolerance below minimum + max_episode_duration = max(tvdb_durations) + 10 # 10 minute tolerance above maximum + + print(f"TVDB Episode duration range: {min_episode_duration:.1f} - {max_episode_duration:.1f} min (avg: {avg_tvdb_duration:.1f})") + + episodes = [] + extras = [] + + # Sort files by size (largest first) for consistent processing + sorted_files = sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True) + + for file_info in sorted_files: + duration = file_info['duration_minutes'] + is_extra = False + + # First check: Duration-based classification with tighter tolerances + if duration < min_episode_duration or duration > max_episode_duration: + is_extra = True + print(f" Duration-based extra: {file_info['name']} ({duration:.1f} min) - outside {min_episode_duration:.1f}-{max_episode_duration:.1f} range") + + # Second check: If we have expected episode count, limit episodes (but be disc-aware) + elif expected_episode_count and len(episodes) >= expected_episode_count: + # Extract disc number for disc-aware logic + import re + disc_match = re.search(r'[Dd]isc\s*(\d+)', file_info['name'], re.IGNORECASE) + file_disc = int(disc_match.group(1)) if disc_match else None + + # Check how many files from each disc we already have as episodes + disc_episode_counts = {} + for ep in episodes: + ep_disc_match = re.search(r'[Dd]isc\s*(\d+)', ep['name'], re.IGNORECASE) + ep_disc = int(ep_disc_match.group(1)) if ep_disc_match else 0 + disc_episode_counts[ep_disc] = disc_episode_counts.get(ep_disc, 0) + 1 + + current_disc_episodes = disc_episode_counts.get(file_disc, 0) + + # Special logic: ensure we have enough episodes from Disc 4 for episodes 9 & 10 + # Check if we need more Disc 4 episodes and this could be Episode 10 + if file_disc == 4 and current_disc_episodes < 2: + print(f" Disc 4 episode preserved: {file_info['name']} - ensuring final disc has episodes 9-10") + is_extra = False + else: + is_extra = True + print(f" Count-based extra: {file_info['name']} (episode limit reached)") + + # Third check: Look for very close duration matches to confirm episodes + elif expected_episode_count: + # Find closest TVDB episode duration + closest_duration = min(tvdb_durations, key=lambda x: abs(x - duration)) + duration_diff = abs(duration - closest_duration) + + if duration_diff <= 10: # Within 10 minutes is likely an episode + print(f" Duration-matched episode: {file_info['name']} ({duration:.1f} min) ≈ TVDB {closest_duration} min (Δ{duration_diff:.1f})") + else: + # Check if this could still be an episode based on count + if len(episodes) < expected_episode_count: + print(f" Episode by count: {file_info['name']} ({duration:.1f} min) - no close TVDB match but within count") + else: + is_extra = True + print(f" Duration mismatch extra: {file_info['name']} ({duration:.1f} min) - closest TVDB: {closest_duration} min (Δ{duration_diff:.1f})") + + if is_extra: + extras.append(file_info) + else: + episodes.append(file_info) + + # Sort episodes by name for consistent ordering + episodes.sort(key=lambda x: x['name']) + + return episodes, extras + + def _classify_by_stats(self, expected_episode_count: int = None) -> Tuple[List[Dict], List[Dict]]: + """Fallback classification using statistical analysis of file sizes and durations.""" + stats = self._calculate_stats() + + # Get classification thresholds from config or use defaults + if config_manager: + thresholds = config_manager.get_classification_thresholds() + size_ratio = thresholds.get('size_threshold_ratio', 0.3) + duration_ratio = thresholds.get('duration_threshold_ratio', 0.4) + else: + size_ratio = 0.3 + duration_ratio = 0.4 + + # Classification thresholds + size_threshold = stats['avg_size_bytes'] * size_ratio # Files < ratio of average are likely extras + duration_threshold = stats['avg_duration_minutes'] * duration_ratio # Files < ratio of average duration are likely extras + + episodes = [] + extras = [] + + # Sort files by size (largest first) to help identify episodes + sorted_files = sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True) + + for file_info in sorted_files: + is_extra = False + + # Check if file is significantly smaller than average + if file_info['size_bytes'] < size_threshold: + is_extra = True + + # Check if duration is significantly shorter than average + elif file_info['duration_minutes'] < duration_threshold: + is_extra = True + + # If we have expected episode count, take the largest files as episodes + elif expected_episode_count and len(episodes) >= expected_episode_count: + is_extra = True + + if is_extra: + extras.append(file_info) + else: + episodes.append(file_info) + + # Sort episodes by name for consistent ordering + episodes.sort(key=lambda x: x['name']) + + return episodes, extras + + def print_analysis(self): + """Print analysis of files in the folder.""" + if not self.file_info: + print("No video files found.") + return + + stats = self._calculate_stats() + + print(f"\n=== File Analysis for {self.folder_path} ===") + print(f"Total files: {stats['count']}") + print(f"Average file size: {stats['avg_size_gb']:.2f} GB") + print(f"Average duration: {stats['avg_duration_minutes']:.1f} minutes") + print(f"Median file size: {stats['median_size_bytes']/(1024**3):.2f} GB") + print(f"Median duration: {stats['median_duration_minutes']:.1f} minutes") + + print("\n=== Individual Files ===") + for info in sorted(self.file_info, key=lambda x: x['size_bytes'], reverse=True): + print(f"{info['name']:50} {info['size_gb']:6.2f} GB {info['duration_minutes']:6.1f} min") + + # Show classification + episodes, extras = self.classify_files() + + print(f"\n=== Classification ===") + print(f"Episodes: {len(episodes)}") + for ep in episodes: + print(f" - {ep['name']} ({ep['size_gb']:.2f} GB, {ep['duration_minutes']:.1f} min)") + + print(f"\nExtras: {len(extras)}") + for ex in extras: + print(f" - {ex['name']} ({ex['size_gb']:.2f} GB, {ex['duration_minutes']:.1f} min)") diff --git a/src/TVDBProvider/__init__.py b/src/TVDBProvider/__init__.py new file mode 100644 index 0000000..eaefc73 --- /dev/null +++ b/src/TVDBProvider/__init__.py @@ -0,0 +1,4 @@ +"""TVDB Provider module for fetching show and episode data.""" +from .tvdb_client import TVDBClient + +__all__ = ['TVDBClient'] diff --git a/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc b/src/TVDBProvider/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..066d4c633fc2323f853697d14165cdb8f4cc8e88 GIT binary patch literal 323 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 b%6`^r>HM0(_~{jP$kMg+qi+~U=tTb?IHQYu literal 0 HcmV?d00001 diff --git a/src/TVDBProvider/tvdb_cache.py b/src/TVDBProvider/tvdb_cache.py new file mode 100644 index 0000000..7aede78 --- /dev/null +++ b/src/TVDBProvider/tvdb_cache.py @@ -0,0 +1,126 @@ +"""TVDB cache for storing API responses locally.""" +import json +import os +from pathlib import Path +from datetime import datetime, timedelta +from typing import Dict, Optional, List + + +class TVDBCache: + """Local cache for TVDB API responses.""" + + def __init__(self, cache_dir: str = ".tvdb_cache"): + """Initialize cache with specified directory.""" + self.cache_dir = Path(cache_dir) + if not self.cache_dir.is_absolute(): + # Make cache relative to project root + script_dir = Path(__file__).parent.parent + self.cache_dir = script_dir / self.cache_dir + + self.cache_dir.mkdir(exist_ok=True) + self.cache_duration = timedelta(days=7) # Cache for 7 days + + def _get_cache_key(self, series_name: str, season_number: int) -> str: + """Generate cache key for series/season combination.""" + # Normalize series name for consistent caching + normalized_name = series_name.lower().replace(' ', '_').replace('-', '_') + return f"{normalized_name}_s{season_number:02d}" + + def _get_cache_file(self, cache_key: str) -> Path: + """Get cache file path for given key.""" + return self.cache_dir / f"{cache_key}.json" + + def _is_cache_valid(self, cache_file: Path) -> bool: + """Check if cache file exists and is not expired.""" + if not cache_file.exists(): + return False + + # Check if file is within cache duration + file_time = datetime.fromtimestamp(cache_file.stat().st_mtime) + return datetime.now() - file_time < self.cache_duration + + def get_cached_episodes(self, series_name: str, season_number: int) -> Optional[List[Dict]]: + """Get cached episode data for series/season.""" + cache_key = self._get_cache_key(series_name, season_number) + cache_file = self._get_cache_file(cache_key) + + if not self._is_cache_valid(cache_file): + return None + + try: + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + # Validate cache structure + if 'episodes' in cache_data and 'cached_at' in cache_data: + print(f"Using cached TVDB data for {series_name} Season {season_number}") + return cache_data['episodes'] + + except (json.JSONDecodeError, KeyError, FileNotFoundError) as e: + print(f"Cache read error for {cache_key}: {e}") + + return None + + def cache_episodes(self, series_name: str, season_number: int, episodes: List[Dict]) -> bool: + """Cache episode data for series/season.""" + cache_key = self._get_cache_key(series_name, season_number) + cache_file = self._get_cache_file(cache_key) + + cache_data = { + 'series_name': series_name, + 'season_number': season_number, + 'episodes': episodes, + 'cached_at': datetime.now().isoformat(), + 'cache_duration_days': self.cache_duration.days + } + + try: + with open(cache_file, 'w') as f: + json.dump(cache_data, f, indent=2) + + print(f"Cached TVDB data for {series_name} Season {season_number}") + return True + + except Exception as e: + print(f"Cache write error for {cache_key}: {e}") + return False + + def clear_cache(self) -> bool: + """Clear all cached data.""" + try: + for cache_file in self.cache_dir.glob("*.json"): + cache_file.unlink() + print("TVDB cache cleared") + return True + except Exception as e: + print(f"Error clearing cache: {e}") + return False + + def list_cache(self) -> List[Dict]: + """List all cached entries with their status.""" + cached_entries = [] + + for cache_file in self.cache_dir.glob("*.json"): + try: + with open(cache_file, 'r') as f: + cache_data = json.load(f) + + cached_at = datetime.fromisoformat(cache_data.get('cached_at', '')) + is_valid = self._is_cache_valid(cache_file) + age = datetime.now() - cached_at + + entry = { + 'series_name': cache_data.get('series_name', 'Unknown'), + 'season_number': cache_data.get('season_number', 0), + 'episode_count': len(cache_data.get('episodes', [])), + 'cached_at': cached_at.strftime('%Y-%m-%d %H:%M'), + 'age_days': age.days, + 'is_valid': is_valid, + 'cache_key': cache_file.stem + } + cached_entries.append(entry) + + except Exception as e: + print(f"Error reading cache file {cache_file}: {e}") + + return sorted(cached_entries, key=lambda x: x['cached_at'], reverse=True) diff --git a/src/TVDBProvider/tvdb_client.py b/src/TVDBProvider/tvdb_client.py new file mode 100644 index 0000000..b60fc19 --- /dev/null +++ b/src/TVDBProvider/tvdb_client.py @@ -0,0 +1,192 @@ +"""TVDB Client for interacting with The Movie Database API.""" +import requests +import json +from typing import Dict, List, Optional, Tuple +from .tvdb_cache import TVDBCache + + +class TVDBClient: + """Client for interacting with TVDB API to get show and episode information.""" + + def __init__(self): + """Initialize TVDB client.""" + self.base_url = "https://api4.thetvdb.com/v4" + self.token = None + self.headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + self.cache = TVDBCache() + + def authenticate(self, api_key: str) -> bool: + """Authenticate with TVDB API using API key.""" + auth_url = f"{self.base_url}/login" + auth_data = { + "apikey": api_key + } + + try: + response = requests.post(auth_url, json=auth_data, headers=self.headers) + if response.status_code == 200: + data = response.json() + self.token = data.get('data', {}).get('token') + self.headers['Authorization'] = f'Bearer {self.token}' + return True + else: + print(f"Authentication failed: {response.status_code}") + return False + except Exception as e: + print(f"Authentication error: {e}") + return False + + def search_series(self, series_name: str) -> Optional[Dict]: + """Search for a TV series by name.""" + if not self.token: + print("Not authenticated. Please call authenticate() first.") + return None + + search_url = f"{self.base_url}/search" + params = { + 'query': series_name, + 'type': 'series' + } + + try: + response = requests.get(search_url, params=params, headers=self.headers) + if response.status_code == 200: + data = response.json() + series_list = data.get('data', []) + if series_list: + # Return the first match (most relevant) + return series_list[0] + else: + print(f"No series found for '{series_name}'") + return None + else: + print(f"Search failed: {response.status_code}") + return None + except Exception as e: + print(f"Search error: {e}") + return None + + def get_season_episodes(self, series_id: int, season_number: int) -> Optional[List[Dict]]: + """Get all episodes for a specific season of a series.""" + if not self.token: + print("Not authenticated. Please call authenticate() first.") + return None + + # Get series seasons first + seasons_url = f"{self.base_url}/series/{series_id}/episodes/default" + params = { + 'season': season_number + } + + try: + response = requests.get(seasons_url, params=params, headers=self.headers) + if response.status_code == 200: + data = response.json() + episodes = data.get('data', {}).get('episodes', []) + return episodes + else: + print(f"Failed to get episodes: {response.status_code}") + return None + except Exception as e: + print(f"Episodes fetch error: {e}") + return None + + def get_series_info(self, series_name: str) -> Optional[Dict]: + """Get series information including ID.""" + series = self.search_series(series_name) + if series: + return { + 'id': series.get('tvdb_id'), + 'name': series.get('name'), + 'slug': series.get('slug'), + 'year': series.get('year') + } + return None + + def get_episode_durations(self, series_name: str, season_number: int) -> Optional[List[Dict]]: + """Get episode durations for a specific season with caching.""" + # Check cache first + cached_episodes = self.cache.get_cached_episodes(series_name, season_number) + if cached_episodes: + return cached_episodes + + # First get series info + series_info = self.get_series_info(series_name) + if not series_info: + return None + + # Get episodes for the season + episodes = self.get_season_episodes(series_info['id'], season_number) + if not episodes: + return None + + # Extract relevant episode information + episode_data = [] + for episode in episodes: + episode_info = { + 'episode_number': episode.get('number'), + 'name': episode.get('name'), + 'runtime': episode.get('runtime'), # Duration in minutes + 'aired': episode.get('aired') + } + episode_data.append(episode_info) + + # Sort by episode number + episode_data.sort(key=lambda x: x['episode_number'] or 0) + + # Cache the results + self.cache.cache_episodes(series_name, season_number, episode_data) + + return episode_data + + +# For testing without API key, we can use a mock client +class MockTVDBClient(TVDBClient): + """Mock TVDB client for testing purposes.""" + + def __init__(self): + super().__init__() + self.authenticated = False + self.cache = TVDBCache() # Mock also uses cache + + def authenticate(self, api_key: str = None) -> bool: + """Mock authentication.""" + self.authenticated = True + return True + + def get_episode_durations(self, series_name: str, season_number: int) -> Optional[List[Dict]]: + """Mock episode durations with caching - returns typical TV episode length.""" + if not self.authenticated: + return None + + # Check cache first + cached_episodes = self.cache.get_cached_episodes(series_name, season_number) + if cached_episodes: + return cached_episodes + + # Return mock data - assuming typical 20-22 episode season with ~45min episodes + mock_episodes = [] + episode_count = 22 # Default episode count + + # Adjust episode count based on series type + if any(keyword in series_name.lower() for keyword in ['game of thrones', 'stranger things', 'breaking bad']): + episode_count = 10 # Drama series typically have fewer episodes + elif any(keyword in series_name.lower() for keyword in ['friends', 'the office', 'brooklyn nine-nine']): + episode_count = 24 # Sitcoms typically have more episodes + + for i in range(1, episode_count + 1): + episode_info = { + 'episode_number': i, + 'name': f"Episode {i}", + 'runtime': 45, # 45 minutes default + 'aired': f"2023-01-{i:02d}" + } + mock_episodes.append(episode_info) + + # Cache the mock results + self.cache.cache_episodes(series_name, season_number, mock_episodes) + + return mock_episodes diff --git a/src/__pycache__/config.cpython-313.pyc b/src/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad0eff88067747337cba440fa9b897de9ace81c9 GIT binary patch literal 5981 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 Rh~{Gu#rfX${akab{{y>BOZ)%; literal 0 HcmV?d00001 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..3714602 --- /dev/null +++ b/src/config.py @@ -0,0 +1,105 @@ +"""Configuration management for Episode Matcher.""" +import json +import os +from pathlib import Path +from typing import Dict, Optional + + +class ConfigManager: + """Manages configuration settings for Episode Matcher.""" + + def __init__(self, config_file: str = "config.json"): + """Initialize config manager with config file path.""" + self.config_file = Path(config_file) + if not self.config_file.is_absolute(): + # If relative path, make it relative to the script directory + script_dir = Path(__file__).parent.parent + self.config_file = script_dir / self.config_file + + self.config = self._load_config() + + def _load_config(self) -> Dict: + """Load configuration from JSON file.""" + default_config = { + "tvdb_api_key": "", + "default_episode_duration": 45, + "classification_thresholds": { + "size_threshold_ratio": 0.3, + "duration_threshold_ratio": 0.4 + } + } + + if not self.config_file.exists(): + print(f"Config file not found at {self.config_file}") + print("Using default configuration. Create config.json to customize settings.") + return default_config + + try: + with open(self.config_file, 'r') as f: + config = json.load(f) + + # Merge with defaults to ensure all keys exist + for key, value in default_config.items(): + if key not in config: + config[key] = value + + return config + + except json.JSONDecodeError as e: + print(f"Error parsing config file {self.config_file}: {e}") + print("Using default configuration.") + return default_config + except Exception as e: + print(f"Error loading config file {self.config_file}: {e}") + print("Using default configuration.") + return default_config + + def get_tvdb_api_key(self) -> Optional[str]: + """Get TVDB API key from config.""" + api_key = self.config.get("tvdb_api_key", "").strip() + if not api_key or api_key == "YOUR_TVDB_API_KEY_HERE": + return None + return api_key + + def get_default_episode_duration(self) -> int: + """Get default episode duration in minutes.""" + return self.config.get("default_episode_duration", 45) + + def get_classification_thresholds(self) -> Dict[str, float]: + """Get classification threshold ratios.""" + return self.config.get("classification_thresholds", { + "size_threshold_ratio": 0.3, + "duration_threshold_ratio": 0.4 + }) + + def update_api_key(self, api_key: str) -> bool: + """Update TVDB API key in config file.""" + try: + self.config["tvdb_api_key"] = api_key + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + print(f"API key updated in {self.config_file}") + return True + except Exception as e: + print(f"Error updating config file: {e}") + return False + + def print_config_status(self): + """Print current configuration status.""" + print(f"Configuration file: {self.config_file}") + print(f"Config exists: {self.config_file.exists()}") + + api_key = self.get_tvdb_api_key() + if api_key: + masked_key = api_key[:8] + "..." + api_key[-4:] if len(api_key) > 12 else api_key[:4] + "..." + print(f"TVDB API Key: {masked_key} (configured)") + else: + print("TVDB API Key: Not configured (will use mock data)") + + print(f"Default episode duration: {self.get_default_episode_duration()} minutes") + thresholds = self.get_classification_thresholds() + print(f"Classification thresholds: {thresholds}") + + +# Global config instance +config_manager = ConfigManager() diff --git a/test_components.py b/test_components.py new file mode 100644 index 0000000..9d7e999 --- /dev/null +++ b/test_components.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Test script for Episode Matcher components +""" + +import sys +import os +from pathlib import Path + +# Add src to path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from TVDBProvider.tvdb_client import MockTVDBClient +from Matcher.file_classifier import FileClassifier +from Matcher.episode_renamer import EpisodeRenamer + + +def test_tvdb_client(): + """Test TVDB client functionality.""" + print("=== Testing TVDB Client ===") + + client = MockTVDBClient() + success = client.authenticate() + print(f"Authentication: {'✓' if success else '✗'}") + + episodes = client.get_episode_durations("Game of Thrones", 1) + if episodes: + print(f"✓ Got {len(episodes)} episodes") + print(f" Sample episode: Episode {episodes[0]['episode_number']} - {episodes[0]['runtime']} min") + else: + print("✗ Failed to get episodes") + + print() + + +def test_file_classifier(): + """Test file classifier with current directory (no MKV files expected).""" + print("=== Testing File Classifier ===") + + try: + classifier = FileClassifier(".") + print(f"✓ Classifier initialized for current directory") + print(f" Found {len(classifier.video_files)} MKV files") + + if classifier.video_files: + episodes, extras = classifier.classify_files() + print(f" Would classify {len(episodes)} episodes, {len(extras)} extras") + + except Exception as e: + print(f"✗ Classifier error: {e}") + + print() + + +def test_episode_renamer(): + """Test episode renamer functionality.""" + print("=== Testing Episode Renamer ===") + + renamer = EpisodeRenamer(".", "Test Show", 1) + print(f"✓ Renamer initialized") + + # Test filename generation + test_filename = renamer._generate_episode_filename(5, ".mkv") + expected = "Test Show s01e05.mkv" + print(f" Filename generation: {'✓' if test_filename == expected else '✗'}") + print(f" Generated: {test_filename}") + print(f" Expected: {expected}") + + print() + + +def main(): + """Run all tests.""" + print("Episode Matcher Component Tests\n") + + test_tvdb_client() + test_file_classifier() + test_episode_renamer() + + print("Tests completed!") + + +if __name__ == "__main__": + main()