jfmo 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. jfmo/__init__.py +124 -0
  2. jfmo/cli.py +59 -0
  3. jfmo/config.py +206 -0
  4. jfmo/daemon.py +105 -0
  5. jfmo/di.py +44 -0
  6. jfmo/exceptions.py +10 -0
  7. jfmo/formatter.py +59 -0
  8. jfmo/metadata/__init__.py +1 -0
  9. jfmo/metadata/tmdb.py +99 -0
  10. jfmo/parser/__init__.py +5 -0
  11. jfmo/parser/context.py +19 -0
  12. jfmo/parser/parser.py +19 -0
  13. jfmo/parser/protocol.py +7 -0
  14. jfmo/parser/steps/__init__.py +27 -0
  15. jfmo/parser/steps/codec.py +18 -0
  16. jfmo/parser/steps/episode.py +28 -0
  17. jfmo/parser/steps/extension.py +14 -0
  18. jfmo/parser/steps/hdr.py +18 -0
  19. jfmo/parser/steps/media_type.py +52 -0
  20. jfmo/parser/steps/quality.py +60 -0
  21. jfmo/parser/steps/release_group.py +15 -0
  22. jfmo/parser/steps/season.py +47 -0
  23. jfmo/parser/steps/service.py +17 -0
  24. jfmo/parser/steps/source.py +18 -0
  25. jfmo/parser/steps/title.py +24 -0
  26. jfmo/parser/steps/year.py +29 -0
  27. jfmo/parser/tokens.py +15 -0
  28. jfmo/processors/__init__.py +4 -0
  29. jfmo/processors/movie_processor.py +38 -0
  30. jfmo/processors/result.py +15 -0
  31. jfmo/processors/tv_processor.py +45 -0
  32. jfmo/transliteration/__init__.py +5 -0
  33. jfmo/transliteration/core.py +91 -0
  34. jfmo/transliteration/models/__init__.py +0 -0
  35. jfmo/transliteration/models/jfmo_english_model.pkl +0 -0
  36. jfmo/transliteration/models/jfmo_russian_model.pkl +0 -0
  37. jfmo/utils/__init__.py +9 -0
  38. jfmo/utils/cli_output.py +50 -0
  39. jfmo/utils/fs/__init__.py +4 -0
  40. jfmo/utils/fs/file_ops.py +64 -0
  41. jfmo/utils/fs/file_stability_tracker.py +60 -0
  42. jfmo/utils/token_formatter.py +34 -0
  43. jfmo-3.0.0.dist-info/METADATA +247 -0
  44. jfmo-3.0.0.dist-info/RECORD +46 -0
  45. jfmo-3.0.0.dist-info/WHEEL +4 -0
  46. jfmo-3.0.0.dist-info/entry_points.txt +3 -0
jfmo/__init__.py ADDED
@@ -0,0 +1,124 @@
1
+ import os
2
+ import signal
3
+ import sys
4
+
5
+ from loguru import logger
6
+
7
+ from .cli import CLI
8
+ from .config import config
9
+ from .daemon import FileWatcher
10
+ from .di import Container
11
+ from .exceptions import DirectoryNotFoundError, TransliterationModelError
12
+ from .utils.cli_output import print_dry_run_banner, print_entry_header, print_header, print_result, print_summary
13
+ from .utils.fs.file_ops import is_video_file
14
+
15
+ EXIT_SUCCESS = 0
16
+ EXIT_CONFIG_ERROR = 1
17
+ EXIT_DIRECTORY_ERROR = 2
18
+ EXIT_SYSTEM_ERROR = 5
19
+ EXIT_MODEL_ERROR = 6
20
+
21
+
22
+ def _run(apply: bool) -> None:
23
+ config.DRY_RUN = not apply
24
+ container = Container()
25
+
26
+ show_output = not config.DAEMON_MODE
27
+
28
+ video_entries = [
29
+ name
30
+ for name in os.listdir(config.DOWNLOADS_DIR)
31
+ if os.path.isdir(os.path.join(config.DOWNLOADS_DIR, name))
32
+ or (os.path.isfile(os.path.join(config.DOWNLOADS_DIR, name)) and is_video_file(name))
33
+ ]
34
+
35
+ if show_output and config.DRY_RUN:
36
+ print_dry_run_banner()
37
+ if show_output:
38
+ print_header(len(video_entries))
39
+
40
+ all_results = []
41
+ skipped_count = 0
42
+
43
+ for name in video_entries:
44
+ path = os.path.join(config.DOWNLOADS_DIR, name)
45
+
46
+ if show_output:
47
+ print_entry_header(name, is_dir=os.path.isdir(path))
48
+
49
+ if os.path.isdir(path):
50
+ results = container.formatter.format_directory(path)
51
+ all_results.extend(results)
52
+ if show_output:
53
+ for r in results:
54
+ print_result(r)
55
+ else:
56
+ result = container.formatter.format_file(path)
57
+ if result is None:
58
+ skipped_count += 1
59
+ else:
60
+ all_results.append(result)
61
+ if show_output:
62
+ print_result(result)
63
+
64
+ if show_output:
65
+ print_summary(all_results, skipped_count, config.DRY_RUN)
66
+ if config.DRY_RUN:
67
+ print_dry_run_banner()
68
+
69
+
70
+ def _run_daemon() -> None:
71
+ container = Container()
72
+ watcher = FileWatcher(config.DOWNLOADS_DIR, config.DAEMON_INTERVAL_SEC, container.formatter)
73
+
74
+ def _stop(signum, _frame):
75
+ logger.info(f"Signal {signum}. Stopping...")
76
+ watcher.stop()
77
+ sys.exit(0)
78
+
79
+ signal.signal(signal.SIGINT, _stop)
80
+ signal.signal(signal.SIGTERM, _stop)
81
+
82
+ logger.info("Daemon starting...")
83
+ logger.info(config)
84
+ watcher.start()
85
+
86
+
87
+ def main():
88
+ cli = CLI()
89
+ command = cli.read_command()
90
+
91
+ if command is None:
92
+ cli.print_help()
93
+ sys.exit(EXIT_SUCCESS)
94
+
95
+ try:
96
+ config.DAEMON_MODE = command == "daemon"
97
+ config.load(cli.read_config_path())
98
+
99
+ if command == "daemon":
100
+ _run_daemon()
101
+ else:
102
+ _run(cli.read_run_apply())
103
+
104
+ except FileNotFoundError as e:
105
+ logger.error(str(e))
106
+ sys.exit(EXIT_CONFIG_ERROR)
107
+
108
+ except ValueError as e:
109
+ logger.error(str(e))
110
+ sys.exit(EXIT_CONFIG_ERROR)
111
+
112
+ except DirectoryNotFoundError as e:
113
+ logger.error(str(e))
114
+ sys.exit(EXIT_DIRECTORY_ERROR)
115
+
116
+ except TransliterationModelError as e:
117
+ logger.error(str(e))
118
+ sys.exit(EXIT_MODEL_ERROR)
119
+
120
+ except Exception as e:
121
+ logger.error(f"Unexpected error: {e}")
122
+ sys.exit(EXIT_SYSTEM_ERROR)
123
+
124
+ sys.exit(EXIT_SUCCESS)
jfmo/cli.py ADDED
@@ -0,0 +1,59 @@
1
+ from argparse import ArgumentParser, RawDescriptionHelpFormatter
2
+ from importlib.metadata import version
3
+
4
+
5
+ class CLI:
6
+ def __init__(self) -> None:
7
+ self._parser = self._create_parser()
8
+ self._args = self._parser.parse_args()
9
+
10
+ def _create_parser(self) -> ArgumentParser:
11
+ parser = ArgumentParser(
12
+ prog="jfmo",
13
+ description="Jellyfin Format Media Organizer",
14
+ usage="%(prog)s <command> [options]",
15
+ formatter_class=RawDescriptionHelpFormatter,
16
+ add_help=True,
17
+ )
18
+ parser.add_argument(
19
+ "-v",
20
+ "--version",
21
+ action="version",
22
+ version=f"{parser.prog} {version('jfmo')}",
23
+ )
24
+
25
+ parser.add_argument(
26
+ "-c",
27
+ "--config",
28
+ metavar="FILE",
29
+ default="/etc/jfmo/config.yaml",
30
+ help="Path to configuration file (default: /etc/jfmo/config.yaml)",
31
+ )
32
+
33
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
34
+
35
+ # Run command
36
+ run_parser = subparsers.add_parser("run", help="Process downloads directory (default: dry-run)")
37
+ run_parser.add_argument(
38
+ "-a",
39
+ "--apply",
40
+ action="store_true",
41
+ help="Apply changes (move files). Without this flag runs as dry-run.",
42
+ )
43
+
44
+ # Daemon command
45
+ subparsers.add_parser("daemon", help="Watch downloads directory for new files")
46
+
47
+ return parser
48
+
49
+ def read_command(self) -> str:
50
+ return self._args.command
51
+
52
+ def read_config_path(self) -> str:
53
+ return self._args.config
54
+
55
+ def read_run_apply(self) -> bool:
56
+ return getattr(self._args, "apply", False)
57
+
58
+ def print_help(self) -> None:
59
+ self._parser.print_help()
jfmo/config.py ADDED
@@ -0,0 +1,206 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from loguru import logger
8
+
9
+ from .parser.tokens import Token
10
+
11
+ # Valid tokens per naming pattern
12
+ _VALID_TOKENS: dict[str, set[Token]] = {
13
+ "naming.movie.file": {
14
+ Token.TITLE,
15
+ Token.YEAR,
16
+ Token.QUALITY,
17
+ Token.TMDB_ID,
18
+ Token.SOURCE,
19
+ Token.CODEC,
20
+ Token.HDR,
21
+ Token.SERVICE,
22
+ Token.RELEASE_GROUP,
23
+ },
24
+ "naming.tv.folder": {Token.TITLE, Token.YEAR, Token.TMDB_ID},
25
+ "naming.tv.season": {Token.SEASON},
26
+ "naming.tv.file": {
27
+ Token.TITLE,
28
+ Token.SEASON,
29
+ Token.EPISODE,
30
+ Token.QUALITY,
31
+ Token.SOURCE,
32
+ Token.CODEC,
33
+ Token.HDR,
34
+ Token.SERVICE,
35
+ Token.RELEASE_GROUP,
36
+ },
37
+ }
38
+
39
+
40
+ class Config:
41
+ def __init__(self) -> None:
42
+ self.LOG_LEVEL: str = "INFO"
43
+
44
+ self.DAEMON_INTERVAL_SEC: int = 30
45
+
46
+ self.DOWNLOADS_DIR: str
47
+ self.MOVIES_DIR: str
48
+ self.TV_DIR: str
49
+
50
+ # Naming format tokens
51
+ self.FORMAT_MOVIE_FILE: str = "{title} ({year}) [tmdbid-{tmdb_id}] - {quality}"
52
+ self.FORMAT_TV_FOLDER: str = "{title} ({year}) [tmdbid-{tmdb_id}]"
53
+ self.FORMAT_TV_SEASON_FOLDER: str = "Season {season}"
54
+ self.FORMAT_TV_FILE: str = "{title} S{season}E{episode} - {quality}"
55
+
56
+ # TMDB configuration
57
+ self.TMDB_API_KEY: str | None = None
58
+
59
+ self.DRY_RUN: bool = False
60
+ self.DAEMON_MODE: bool = False
61
+
62
+ def _setup_logger(self) -> None:
63
+ logger.remove()
64
+
65
+ if self.DAEMON_MODE:
66
+ logger.add(
67
+ sys.stderr,
68
+ colorize=True,
69
+ level=self.LOG_LEVEL,
70
+ format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan> - <level>{message}</level>",
71
+ )
72
+
73
+ log_dir = Path("/var/log/jfmo")
74
+ try:
75
+ log_dir.mkdir(parents=True, exist_ok=True)
76
+ logger.add(
77
+ str(log_dir / ("jfmo_{time:YYYY-MM-DD}.log")),
78
+ rotation="10 MB",
79
+ retention="30 days",
80
+ compression="zip",
81
+ level=self.LOG_LEVEL,
82
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
83
+ )
84
+ except PermissionError:
85
+ pass # No file logging if lacking permissions
86
+
87
+ def load(self, path: str) -> None:
88
+ """Load configuration from a YAML file.
89
+
90
+ Raises:
91
+ FileNotFoundError: If the config file does not exist.
92
+ ValueError: If the YAML is invalid or cannot be applied.
93
+ """
94
+ if not os.path.exists(path):
95
+ raise FileNotFoundError(f"Config file not found: {path}")
96
+
97
+ try:
98
+ with open(path, encoding="utf-8") as f:
99
+ data = yaml.safe_load(f)
100
+ except yaml.YAMLError as e:
101
+ raise ValueError(f"Invalid YAML in config file: {e}") from e
102
+
103
+ if not data:
104
+ self._setup_logger()
105
+ return
106
+
107
+ # Logging
108
+ if "logging" in data:
109
+ log = data["logging"]
110
+ if "log_level" in log:
111
+ self.LOG_LEVEL = log["log_level"].upper()
112
+
113
+ # Daemon
114
+ if "daemon" in data and "interval" in data["daemon"]:
115
+ self.DAEMON_INTERVAL_SEC = data["daemon"]["interval"]
116
+
117
+ # Directories
118
+ if "directories" in data:
119
+ dirs = data["directories"]
120
+ if "downloads" in dirs:
121
+ dl = dirs["downloads"]
122
+ if isinstance(dl, dict) and "path" in dl:
123
+ self.DOWNLOADS_DIR = dl["path"]
124
+ if "media" in dirs:
125
+ media = dirs["media"]
126
+ if "movies" in media:
127
+ self.MOVIES_DIR = media["movies"]
128
+ if "tv" in media:
129
+ self.TV_DIR = media["tv"]
130
+
131
+ # Naming tokens
132
+ if "naming" in data:
133
+ naming = data["naming"]
134
+ if "movie" in naming and isinstance(naming["movie"], dict) and "file" in naming["movie"]:
135
+ self.FORMAT_MOVIE_FILE = naming["movie"]["file"]
136
+ if "tv" in naming and isinstance(naming["tv"], dict):
137
+ tv = naming["tv"]
138
+ if "folder" in tv:
139
+ self.FORMAT_TV_FOLDER = tv["folder"]
140
+ if "season" in tv:
141
+ self.FORMAT_TV_SEASON_FOLDER = tv["season"]
142
+ if "file" in tv:
143
+ self.FORMAT_TV_FILE = tv["file"]
144
+
145
+ # TMDB
146
+ if "tmdb" in data and (api_key := data["tmdb"].get("api_key")):
147
+ self.TMDB_API_KEY = api_key
148
+
149
+ self._setup_logger()
150
+ self._validate()
151
+
152
+ def _validate(self) -> None:
153
+ """Validate configuration values after loading.
154
+
155
+ Raises:
156
+ ValueError: If required directories are missing or paths do not exist.
157
+ """
158
+ errors: list[str] = []
159
+
160
+ # Required directory attributes must be set
161
+ for attr in ("DOWNLOADS_DIR", "MOVIES_DIR", "TV_DIR"):
162
+ if not hasattr(self, attr):
163
+ errors.append(f"Required directory not configured: '{attr.lower()}'")
164
+ else:
165
+ path = getattr(self, attr)
166
+ if not os.path.exists(path):
167
+ errors.append(f"Directory does not exist: {attr}={path!r}")
168
+ elif not os.path.isdir(path):
169
+ errors.append(f"Path is not a directory: {attr}={path!r}")
170
+
171
+ if self.DAEMON_INTERVAL_SEC < 30:
172
+ errors.append(f"daemon.interval must be >= 30, got {self.DAEMON_INTERVAL_SEC}")
173
+
174
+ errors.extend(self._validate_naming_patterns())
175
+
176
+ if errors:
177
+ raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
178
+
179
+ def _validate_naming_patterns(self) -> list[str]:
180
+ errors = []
181
+ token_pattern = re.compile(r"\{(\w+)\}")
182
+
183
+ for label, pattern in [
184
+ ("naming.movie.file", self.FORMAT_MOVIE_FILE),
185
+ ("naming.tv.folder", self.FORMAT_TV_FOLDER),
186
+ ("naming.tv.season", self.FORMAT_TV_SEASON_FOLDER),
187
+ ("naming.tv.file", self.FORMAT_TV_FILE),
188
+ ]:
189
+ used_tokens = set(token_pattern.findall(pattern))
190
+ unknown = used_tokens - _VALID_TOKENS[label]
191
+ if unknown:
192
+ errors.append(f"{label}: invalid tokens {unknown} (allowed: {_VALID_TOKENS[label]})")
193
+
194
+ return errors
195
+
196
+ def __str__(self) -> str:
197
+ return (
198
+ f"Current configuration:\n"
199
+ f" Download: {self.DOWNLOADS_DIR}\n"
200
+ f" Movies: {self.MOVIES_DIR} - TV: {self.TV_DIR}\n"
201
+ f" Daemon Interval: {self.DAEMON_INTERVAL_SEC} sec\n"
202
+ f" TMDB Integration: {'enable' if self.TMDB_API_KEY else 'disable'}"
203
+ )
204
+
205
+
206
+ config = Config()
jfmo/daemon.py ADDED
@@ -0,0 +1,105 @@
1
+ import os
2
+ from datetime import datetime
3
+ from threading import Event
4
+
5
+ from loguru import logger
6
+
7
+ from .formatter import Formatter
8
+ from .utils.fs.file_ops import is_video_file
9
+ from .utils.fs.file_stability_tracker import FileStabilityTracker
10
+
11
+
12
+ class FileWatcher:
13
+ def __init__(self, watch_dir: str, check_interval: int, formatter: Formatter) -> None:
14
+ self.watch_dir = watch_dir
15
+ self.check_interval = check_interval
16
+ self.formatter = formatter
17
+ self.known_entries: set[str] = set()
18
+ self.stability_tracker = FileStabilityTracker(stability_cycles=2)
19
+ self.stop_event = Event()
20
+ self._cycle = 0
21
+
22
+ def _scan_entries(self) -> set[str]:
23
+ """Return direct children of watch_dir (dirs and video files)."""
24
+ found: set[str] = set()
25
+ try:
26
+ for name in os.listdir(self.watch_dir):
27
+ path = os.path.join(self.watch_dir, name)
28
+ if os.path.isdir(path) or (os.path.isfile(path) and is_video_file(name)):
29
+ found.add(path)
30
+ except Exception as e:
31
+ logger.error(f"Error scanning directory: {e}")
32
+ return found
33
+
34
+ def _is_stable(self, path: str) -> bool:
35
+ """Check stability: for dirs, check all video files inside are stable."""
36
+ if os.path.isfile(path):
37
+ return self.stability_tracker.is_stable(path)
38
+ for root, _, filenames in os.walk(path):
39
+ for filename in filenames:
40
+ if is_video_file(filename) and not self.stability_tracker.is_stable(os.path.join(root, filename)):
41
+ return False
42
+ return True
43
+
44
+ def _mark_processed(self, path: str) -> None:
45
+ if os.path.isfile(path):
46
+ self.stability_tracker.mark_processed(path)
47
+ else:
48
+ for root, _, filenames in os.walk(path):
49
+ for filename in filenames:
50
+ if is_video_file(filename):
51
+ self.stability_tracker.mark_processed(os.path.join(root, filename))
52
+
53
+ def _ts(self) -> str:
54
+ return datetime.now().strftime("%H:%M:%S")
55
+
56
+ def _process_new_entry(self, path: str) -> None:
57
+ name = os.path.basename(path)
58
+
59
+ if not self._is_stable(path):
60
+ return
61
+
62
+ self._mark_processed(path)
63
+ logger.info(f"New entry: {name}")
64
+
65
+ try:
66
+ result = self.formatter.format_directory(path) if os.path.isdir(path) else self.formatter.format_file(path)
67
+
68
+ if result is None:
69
+ logger.info(f"Skipped: {name}")
70
+ elif result:
71
+ logger.info(f"Processed: {name}")
72
+ else:
73
+ logger.error(f"Failed: {name}")
74
+ except Exception as e:
75
+ logger.error(f"Error processing {name}: {e}")
76
+
77
+ def start(self) -> None:
78
+ self.known_entries = self._scan_entries()
79
+ logger.info(f"Watching {len(self.known_entries)} existing entries")
80
+
81
+ while not self.stop_event.is_set():
82
+ try:
83
+ current_entries = self._scan_entries()
84
+ new_entries = current_entries - self.known_entries
85
+
86
+ for path in new_entries:
87
+ self._process_new_entry(path)
88
+
89
+ self.known_entries = current_entries
90
+ self._cycle += 1
91
+
92
+ if self._cycle % 10 == 0:
93
+ logger.info(f"[{self._ts()}] watching {len(current_entries)} entries")
94
+
95
+ self.stop_event.wait(self.check_interval)
96
+
97
+ except KeyboardInterrupt:
98
+ logger.info("Interrupted")
99
+ break
100
+ except Exception as e:
101
+ logger.error(f"Watch loop error: {e}")
102
+ self.stop_event.wait(self.check_interval)
103
+
104
+ def stop(self) -> None:
105
+ self.stop_event.set()
jfmo/di.py ADDED
@@ -0,0 +1,44 @@
1
+ from .config import config
2
+ from .formatter import Formatter
3
+ from .metadata import TMDBClient
4
+ from .parser import Parser
5
+ from .parser.steps import (
6
+ CodecStep,
7
+ EpisodeStep,
8
+ ExtensionStep,
9
+ HdrStep,
10
+ MediaTypeStep,
11
+ QualityStep,
12
+ ReleaseGroupStep,
13
+ SeasonStep,
14
+ ServiceStep,
15
+ SourceStep,
16
+ TitleStep,
17
+ YearStep,
18
+ )
19
+ from .processors import MovieProcessor, TvProcessor
20
+
21
+
22
+ class Container:
23
+ def __init__(self) -> None:
24
+ self.tmdb_client = TMDBClient(config.TMDB_API_KEY)
25
+
26
+ self.parser = Parser(
27
+ ExtensionStep(),
28
+ SeasonStep(),
29
+ EpisodeStep(),
30
+ YearStep(),
31
+ SourceStep(),
32
+ CodecStep(),
33
+ HdrStep(),
34
+ ServiceStep(),
35
+ ReleaseGroupStep(),
36
+ QualityStep(),
37
+ MediaTypeStep(),
38
+ TitleStep(),
39
+ )
40
+
41
+ self.movie_processor = MovieProcessor(self.tmdb_client)
42
+ self.tv_processor = TvProcessor(self.tmdb_client)
43
+
44
+ self.formatter = Formatter(self.parser, self.movie_processor, self.tv_processor)
jfmo/exceptions.py ADDED
@@ -0,0 +1,10 @@
1
+ class DirectoryNotFoundError(Exception):
2
+ """Raised when a required media directory does not exist."""
3
+
4
+
5
+ class DaemonError(Exception):
6
+ """Raised when the daemon fails to start."""
7
+
8
+
9
+ class TransliterationModelError(Exception):
10
+ """Raised when language models cannot be loaded."""
jfmo/formatter.py ADDED
@@ -0,0 +1,59 @@
1
+ import os
2
+
3
+ from loguru import logger
4
+
5
+ from .parser import MediaType, ParseContext, Parser
6
+ from .parser.tokens import Token
7
+ from .processors.movie_processor import MovieProcessor
8
+ from .processors.result import ProcessResult
9
+ from .processors.tv_processor import TvProcessor
10
+ from .transliteration import Transliterator
11
+ from .utils.fs.file_ops import is_video_file
12
+
13
+
14
+ class Formatter:
15
+ def __init__(self, parser: Parser, movie_processor: MovieProcessor, tv_processor: TvProcessor) -> None:
16
+ self._parser = parser
17
+ self._movie = movie_processor
18
+ self._tv = tv_processor
19
+
20
+ def format_file(self, filepath: str) -> ProcessResult | None:
21
+ ctx = self._parser.parse(filepath)
22
+ if ctx.skip_reason:
23
+ logger.info(f"Skipped {os.path.basename(filepath)}: {ctx.skip_reason}")
24
+ return None
25
+ ctx.tokens[Token.TITLE] = Transliterator.transliterate_text(ctx.tokens.get(Token.TITLE, ""))
26
+ if ctx.media_type == MediaType.TV:
27
+ return self._tv.process(ctx)
28
+ return self._movie.process(ctx)
29
+
30
+ def format_directory(self, dirpath: str) -> list[ProcessResult]:
31
+ root_ctx = self._parser.parse(os.path.basename(dirpath))
32
+ root_season = root_ctx.tokens.get(Token.SEASON)
33
+ root_title = root_ctx.tokens.get(Token.TITLE, "")
34
+
35
+ results: list[ProcessResult] = []
36
+ for current_dir, _, files in os.walk(dirpath):
37
+ # Determine season: filename > subdirectory > root dir > None
38
+ if current_dir == dirpath:
39
+ effective_season = root_season
40
+ else:
41
+ sub_ctx = self._parser.parse(os.path.basename(current_dir))
42
+ effective_season = sub_ctx.tokens.get(Token.SEASON) or root_season
43
+
44
+ for file in files:
45
+ if is_video_file(file):
46
+ filepath = os.path.join(current_dir, file)
47
+ tokens = {Token.SEASON: effective_season} if effective_season else {}
48
+ seed = ParseContext(filepath=filepath, tokens=tokens)
49
+ ctx = self._parser.parse(filepath, seed=seed)
50
+ if ctx.skip_reason:
51
+ logger.info(f"Skipped {file}: {ctx.skip_reason}")
52
+ continue
53
+ if not ctx.tokens.get(Token.TITLE) and root_title:
54
+ ctx.tokens[Token.TITLE] = Transliterator.transliterate_text(root_title)
55
+ else:
56
+ ctx.tokens[Token.TITLE] = Transliterator.transliterate_text(ctx.tokens.get(Token.TITLE, ""))
57
+ results.append(self._tv.process(ctx))
58
+
59
+ return results
@@ -0,0 +1 @@
1
+ from .tmdb import TMDBClient as TMDBClient