-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
321 lines (270 loc) · 12.5 KB
/
Copy pathmain.py
File metadata and controls
321 lines (270 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
#!/usr/bin/env python3
import argparse
import json
import logging
import os
import sqlite3
import sys
import time
from pathlib import Path
import yaml
from dotenv import load_dotenv
from modules.shoko_client import ShokoClient
from modules.search.manager import SearchManager
from modules.qbit_client import QbitClient
from modules.discord_notifier import DiscordNotifier
from modules.parser import build_queries_for_episode, infer_season_from_title
from modules.cache import Cache
from utils.logger import setup_logging
from utils.notifier import Notifier
from utils.pathing import render_path_template, safe_name
from utils.i18n import set_locale, t
def expand_env_vars(obj):
if isinstance(obj, dict):
return {k: expand_env_vars(v) for k, v in obj.items()}
if isinstance(obj, list):
return [expand_env_vars(v) for v in obj]
if isinstance(obj, str) and obj.startswith("${") and obj.endswith("}"):
return os.environ.get(obj[2:-1], "")
return obj
def to_bool(value, default=False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, str):
s = value.strip().lower()
if s == "":
return default
if s in ("1", "true", "yes", "on"): # common truthy
return True
if s in ("0", "false", "no", "off"): # common falsy
return False
return bool(value)
def load_config(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
cfg = yaml.safe_load(f)
return expand_env_vars(cfg)
def ensure_cache_db(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
def run_cycle(cfg: dict, logger: logging.Logger, qbit: QbitClient, shoko: ShokoClient, search_manager: SearchManager, cache: Cache, notifier: Notifier, discord: DiscordNotifier, max_items: int):
try:
qbit.ensure_connected()
except Exception as e:
if not qbit.dry_run:
logger.error(t("log.qbit_connect_fail"), e)
return
else:
logger.warning(t("log.qbit_not_connected_dryrun"), e)
# Request Shoko to update series stats and wait a bit to ensure fresh data (configurable)
# Prioritize environment variables over config file to prevent stale volume issues
update_enabled_env = os.environ.get("SHOKO_UPDATE_SERIES_STATS")
if update_enabled_env is not None:
update_enabled = to_bool(update_enabled_env, default=True)
else:
update_enabled = to_bool(cfg.get("general", {}).get("shoko_update_series_stats", None), default=True)
wait_raw_env = os.environ.get("SHOKO_UPDATE_WAIT_SECONDS")
if wait_raw_env is not None:
wait_raw = wait_raw_env
else:
wait_raw = cfg.get("general", {}).get("shoko_update_wait_seconds", None)
try:
wait_seconds = int(str(wait_raw).strip()) if str(wait_raw).strip() != "" else 20
except Exception:
wait_seconds = 20
if update_enabled:
logger.info(t("log.shoko_update_series_stats"))
try:
shoko.update_series_stats()
except Exception as e:
logger.warning(t("log.shoko_update_series_stats_failed"), e)
if wait_seconds > 0:
logger.info(t("log.waiting_after_shoko_update"), wait_seconds)
time.sleep(wait_seconds)
logger.info(t("log.fetching_missing"))
episodes = shoko.get_missing_episodes(
page_size=int(cfg["shoko"].get("page_size", 100)),
include_data_from=cfg["shoko"].get("include_data_from", ["AniDB"]),
collecting_only=bool(cfg["shoko"].get("collecting_only", False)),
include_xrefs=True,
)
logger.info(t("log.missing_found_count"), len(episodes))
processed = 0
added_count = 0
not_found_count = 0
for ep in episodes:
if processed >= max_items:
break
shoko_ep_id = (ep.get("IDs") or {}).get("ID") or ep.get("ID")
shoko_series_id = (ep.get("IDs") or {}).get("ParentSeries")
ep_num = (ep.get("AniDB") or {}).get("EpisodeNumber")
series_title = shoko.get_series_name(shoko_series_id)
season = None # Non fourni directement; on s'appuie sur requêtes E## + VOSTFR
if not series_title or not ep_num:
logger.debug(t("log.insufficient_info"), series_title, ep_num, shoko_ep_id)
continue
queries = build_queries_for_episode(series_title, season, ep_num)
disp_season = int(season) if season else infer_season_from_title(series_title, default=1)
logger.info(t("log.searching_for"), series_title, f"{int(disp_season):02d}", int(ep_num), shoko_ep_id)
results = search_manager.search(queries)
if not results:
logger.info(t("log.no_results"), queries[0])
not_found_count += 1
processed += 1
continue
# Prendre le meilleur résultat selon préférences
best = results[0]
magnet = best.magnet
title = best.title
parsed = (best.parsed_metadata or {}).get("parsed") or {}
if not magnet:
logger.debug(t("log.no_link_for_title"), title)
not_found_count += 1
processed += 1
continue
# Category: SERIES Sxx in uppercase (optional)
s_for_cat = int(season) if season else infer_season_from_title(series_title, default=1)
category_enabled = True if cfg["qbittorrent"].get("category_enabled", None) is None else to_bool(cfg["qbittorrent"].get("category_enabled"), True)
category = f"{safe_name(series_title).upper()} S{s_for_cat:02d}" if category_enabled else None
# Build customizable save path from template
tmpl = cfg["qbittorrent"].get("path_template") or "{save_root}/{series}"
save_root = cfg["qbittorrent"].get("save_root", "/data/anime")
mapping = {
'save_root': save_root,
'series': safe_name(series_title),
'season': str(season or ""),
'season2': f"{int(season):02d}" if season else "",
'episode': str(ep_num or ""),
'episode2': f"{int(ep_num):02d}" if ep_num else "",
'quality': parsed.get('quality') or "",
'group': parsed.get('group') or "",
'source': parsed.get('source') or "",
}
save_path = render_path_template(tmpl, mapping)
# Tag: single tag (optional, customizable)
tag_enabled = True if cfg["qbittorrent"].get("tag_enabled", None) is None else to_bool(cfg["qbittorrent"].get("tag_enabled"), True)
tag_value = str(cfg["qbittorrent"].get("tag_value", "ShokoAT")) if tag_enabled else ""
tags = tag_value if tag_enabled and tag_value else ""
if cache.is_episode_downloaded(shoko_ep_id):
logger.info(t("log.already_downloaded_cache"), title)
processed += 1
continue
logger.info(t("log.adding_qbit"), title)
try:
qbit.add_magnet(magnet, save_path=save_path, category=category, tags=tags)
cache.mark_episode_downloaded(shoko_ep_id, shoko_series_id, magnet, title)
added_count += 1
# Send Discord notification with episode details
try:
episode_details = shoko.get_episode_details(shoko_ep_id, include_data_from=["AniDB", "TmDB"])
discord.notify_download(
series_title=series_title,
season=s_for_cat,
episode=int(ep_num),
release_title=title,
episode_details=episode_details
)
except Exception as discord_err:
logger.warning(t("log.discord_notification_failed"), discord_err)
except Exception as e:
logger.error(t("log.qbit_add_fail"), e)
notifier.notify_error(t("notify.qbit_add_fail_title", title=title), str(e))
processed += 1
time.sleep(search_manager.rate_limit_seconds)
logger.info(t("log.processing_done_count"), processed)
logger.info(t("log.cycle_summary"), len(episodes), added_count, not_found_count)
def main():
load_dotenv()
# First pass parser to get --config and --lang early
pre = argparse.ArgumentParser(add_help=False)
pre.add_argument("--config", default="config.yaml")
pre.add_argument("--lang", default=None)
pre_args, _ = pre.parse_known_args()
# Resolve config path, supporting named volume at /app/config/config.yaml
CONFIG_DIR = Path("/app/config")
CFG_IN_VOLUME = CONFIG_DIR / "config.yaml"
DEFAULT_CFG_IN_IMAGE = Path("/app/config.yaml")
def resolve_config_path(requested: str) -> Path:
req = Path(requested)
if str(req) == "config.yaml":
if CFG_IN_VOLUME.exists():
return CFG_IN_VOLUME
# Try to seed from default if possible
try:
if CONFIG_DIR.exists() and DEFAULT_CFG_IN_IMAGE.exists() and os.access(CONFIG_DIR, os.W_OK):
import shutil
shutil.copy2(DEFAULT_CFG_IN_IMAGE, CFG_IN_VOLUME)
return CFG_IN_VOLUME
except Exception:
pass
return DEFAULT_CFG_IN_IMAGE if DEFAULT_CFG_IN_IMAGE.exists() else req
return req
pre_cfg_path = resolve_config_path(pre_args.config)
cfg = load_config(pre_cfg_path)
language = pre_args.lang or str(cfg.get("general", {}).get("language", "fr"))
set_locale(language)
parser = argparse.ArgumentParser(description=t("cli.description"))
parser.add_argument("--config", default="config.yaml", help=t("cli.config_help"))
parser.add_argument("--limit", type=int, default=None, help=t("cli.limit_help"))
parser.add_argument("--dry-run", action="store_true", help=t("cli.dry_run_help"))
parser.add_argument("--lang", default=None, help=t("cli.lang_help"))
args = parser.parse_args()
# Re-load config with final resolution
cfg_path = resolve_config_path(args.config)
cfg = load_config(cfg_path)
log_level = getattr(logging, str(cfg.get("general", {}).get("log_level", "INFO")).upper(), logging.INFO)
setup_logging(level=log_level)
logger = logging.getLogger("main")
# DRY-RUN: CLI flag overrides config/env; default True if unset
dry_run_cfg = cfg.get("general", {}).get("dry_run", None)
dry_run = args.dry_run or to_bool(dry_run_cfg, default=True)
max_items = args.limit or int(cfg.get("general", {}).get("max_items", 10))
# EARLY_EXIT: default True if unset
early_exit_cfg = cfg.get("general", {}).get("early_exit", None)
early_exit = to_bool(early_exit_cfg, default=True)
# Scheduler interval in hours (default 24h if unset/empty)
sched_hours_raw = cfg.get("general", {}).get("schedule_hours", None)
try:
schedule_hours = int(str(sched_hours_raw).strip()) if str(sched_hours_raw).strip() != "" else 24
except Exception:
schedule_hours = 24
cache_path = Path(cfg.get("cache", {}).get("path", ".cache/shoko_auto_torrent.db"))
ensure_cache_db(cache_path)
cache = Cache(cache_path, ttl_hours=int(cfg.get("cache", {}).get("ttl_hours", 24)))
notifier = Notifier(cfg.get("notify", {}))
discord = DiscordNotifier(
webhook_url=cfg.get("notify", {}).get("discord_webhook_url"),
dry_run=dry_run
)
shoko = ShokoClient(
base_url=cfg["shoko"]["base_url"],
api_key=cfg["shoko"]["api_key"],
)
search_manager = SearchManager(cfg, cache=cache, early_exit=early_exit)
qbit = QbitClient(
url=cfg["qbittorrent"]["url"],
username=cfg["qbittorrent"].get("username", ""),
password=cfg["qbittorrent"].get("password", ""),
dry_run=dry_run,
verify_cert=bool(cfg["qbittorrent"].get("verify_cert", True)),
prefer_http=bool(cfg["qbittorrent"].get("prefer_http", False)),
)
logger.info(t("log.scheduler_enabled"), schedule_hours)
try:
while True:
start_ts = int(time.time())
try:
run_cycle(cfg, logger, qbit, shoko, search_manager, cache, notifier, discord, max_items=max_items)
except Exception as e:
logger.exception(t("log.cycle_error"), e)
notifier.notify_error(t("notify.cycle_error_title"), str(e))
if schedule_hours <= 0:
break
elapsed = int(time.time()) - start_ts
sleep_s = max(0, schedule_hours * 3600 - elapsed)
logger.info(t("log.next_run_in"), sleep_s, sleep_s / 3600)
time.sleep(sleep_s)
except KeyboardInterrupt:
logger.info(t("log.shutdown_requested"))
if __name__ == "__main__":
main()