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.
- jfmo/__init__.py +124 -0
- jfmo/cli.py +59 -0
- jfmo/config.py +206 -0
- jfmo/daemon.py +105 -0
- jfmo/di.py +44 -0
- jfmo/exceptions.py +10 -0
- jfmo/formatter.py +59 -0
- jfmo/metadata/__init__.py +1 -0
- jfmo/metadata/tmdb.py +99 -0
- jfmo/parser/__init__.py +5 -0
- jfmo/parser/context.py +19 -0
- jfmo/parser/parser.py +19 -0
- jfmo/parser/protocol.py +7 -0
- jfmo/parser/steps/__init__.py +27 -0
- jfmo/parser/steps/codec.py +18 -0
- jfmo/parser/steps/episode.py +28 -0
- jfmo/parser/steps/extension.py +14 -0
- jfmo/parser/steps/hdr.py +18 -0
- jfmo/parser/steps/media_type.py +52 -0
- jfmo/parser/steps/quality.py +60 -0
- jfmo/parser/steps/release_group.py +15 -0
- jfmo/parser/steps/season.py +47 -0
- jfmo/parser/steps/service.py +17 -0
- jfmo/parser/steps/source.py +18 -0
- jfmo/parser/steps/title.py +24 -0
- jfmo/parser/steps/year.py +29 -0
- jfmo/parser/tokens.py +15 -0
- jfmo/processors/__init__.py +4 -0
- jfmo/processors/movie_processor.py +38 -0
- jfmo/processors/result.py +15 -0
- jfmo/processors/tv_processor.py +45 -0
- jfmo/transliteration/__init__.py +5 -0
- jfmo/transliteration/core.py +91 -0
- jfmo/transliteration/models/__init__.py +0 -0
- jfmo/transliteration/models/jfmo_english_model.pkl +0 -0
- jfmo/transliteration/models/jfmo_russian_model.pkl +0 -0
- jfmo/utils/__init__.py +9 -0
- jfmo/utils/cli_output.py +50 -0
- jfmo/utils/fs/__init__.py +4 -0
- jfmo/utils/fs/file_ops.py +64 -0
- jfmo/utils/fs/file_stability_tracker.py +60 -0
- jfmo/utils/token_formatter.py +34 -0
- jfmo-3.0.0.dist-info/METADATA +247 -0
- jfmo-3.0.0.dist-info/RECORD +46 -0
- jfmo-3.0.0.dist-info/WHEEL +4 -0
- 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
|