Refine watchlist search and provider fallback
This commit is contained in:
+137
-14
@@ -23,6 +23,10 @@ def clean_component(value, default="Anime"):
|
||||
return text.strip(". ") or default
|
||||
|
||||
|
||||
def normalize_title(value):
|
||||
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
||||
|
||||
|
||||
def parse_show_id(value, default_provider="anikoto"):
|
||||
text = str(value or "").strip()
|
||||
if ":" in text:
|
||||
@@ -44,6 +48,52 @@ def bridge(command, provider, *args):
|
||||
raise RuntimeError(f"Provider bridge returned invalid JSON: {exc}") from exc
|
||||
|
||||
|
||||
def provider_order(primary):
|
||||
ordered = []
|
||||
primary = str(primary or "").strip().lower()
|
||||
if primary in PROVIDERS:
|
||||
ordered.append(primary)
|
||||
for provider in PROVIDERS:
|
||||
if provider not in ordered:
|
||||
ordered.append(provider)
|
||||
return ordered
|
||||
|
||||
|
||||
def find_provider_show(provider, title, original_provider, original_show_id):
|
||||
if provider == original_provider:
|
||||
return original_show_id
|
||||
query = str(title or "").strip()
|
||||
if not query:
|
||||
return ""
|
||||
payload = bridge("search", provider, query, 1)
|
||||
results = payload.get("results") or []
|
||||
if not results:
|
||||
return ""
|
||||
target = normalize_title(query)
|
||||
scored = []
|
||||
for item in results:
|
||||
candidate = normalize_title(item.get("title"))
|
||||
if not candidate:
|
||||
continue
|
||||
if candidate == target:
|
||||
score = 100
|
||||
elif target and (candidate in target or target in candidate):
|
||||
score = 75 - abs(len(candidate) - len(target))
|
||||
else:
|
||||
shared = set(target.split()) & set(candidate.split())
|
||||
score = len(shared) * 5
|
||||
scored.append((score, item))
|
||||
if not scored:
|
||||
return ""
|
||||
scored.sort(key=lambda row: row[0], reverse=True)
|
||||
if scored[0][0] <= 0:
|
||||
return ""
|
||||
provider_id = str(scored[0][1].get("provider_id") or scored[0][1].get("id") or "").strip()
|
||||
if ":" in provider_id:
|
||||
provider_id = provider_id.split(":", 1)[1].strip()
|
||||
return provider_id
|
||||
|
||||
|
||||
def episode_values(spec, episodes):
|
||||
values = []
|
||||
by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes}
|
||||
@@ -71,6 +121,47 @@ def episode_values(spec, episodes):
|
||||
return values
|
||||
|
||||
|
||||
def requested_episode_numbers(spec, episodes):
|
||||
text = str(spec or "").strip()
|
||||
if not text:
|
||||
return [episode.get("number") for episode in episodes or [] if episode.get("number") not in (None, "")]
|
||||
numbers = []
|
||||
for part in re.split(r"[\s,]+", text):
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
start, end = part.split("-", 1)
|
||||
try:
|
||||
left = int(float(start))
|
||||
right = int(float(end))
|
||||
except ValueError:
|
||||
continue
|
||||
step = 1 if right >= left else -1
|
||||
numbers.extend(range(left, right + step, step))
|
||||
continue
|
||||
if re.match(r"^\d+(?:\.\d+)?$", part):
|
||||
numbers.append(float(part) if "." in part else int(part))
|
||||
return numbers
|
||||
|
||||
|
||||
def wanted_episodes(spec, episodes):
|
||||
by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes or []}
|
||||
wanted = []
|
||||
for number in requested_episode_numbers(spec, episodes):
|
||||
key = str(number).rstrip("0").rstrip(".")
|
||||
wanted.append(by_number.get(key) or {"number": number, "id": ""})
|
||||
return wanted
|
||||
|
||||
|
||||
def episode_by_number(number, episodes):
|
||||
wanted = str(number).rstrip("0").rstrip(".")
|
||||
for episode in episodes or []:
|
||||
current = str(episode.get("number")).rstrip("0").rstrip(".")
|
||||
if current == wanted:
|
||||
return episode
|
||||
return None
|
||||
|
||||
|
||||
def ffmpeg_headers(headers):
|
||||
pairs = []
|
||||
for key, value in (headers or {}).items():
|
||||
@@ -108,6 +199,24 @@ def download_episode(stream, target):
|
||||
return subprocess.call(cmd)
|
||||
|
||||
|
||||
def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode):
|
||||
number = wanted_episode.get("number")
|
||||
for provider in provider_order(primary_provider):
|
||||
try:
|
||||
provider_show_id = find_provider_show(provider, title, primary_provider, primary_show_id)
|
||||
if not provider_show_id:
|
||||
print(f"Fallback skipped {provider}: no matching title.")
|
||||
continue
|
||||
episodes = bridge("episodes", provider, provider_show_id).get("episodes") or []
|
||||
episode = episode_by_number(number, episodes)
|
||||
if not episode:
|
||||
print(f"Fallback skipped {provider}: episode {number} is not listed.")
|
||||
continue
|
||||
yield provider, provider_show_id, episode
|
||||
except Exception as exc:
|
||||
print(f"Fallback skipped {provider}: {exc}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--provider", default="anikoto")
|
||||
@@ -122,7 +231,7 @@ def main():
|
||||
provider, provider_show_id = parse_show_id(args.show_id, args.provider)
|
||||
info = bridge("info", provider, provider_show_id)
|
||||
episodes = bridge("episodes", provider, provider_show_id).get("episodes") or []
|
||||
wanted = episode_values(args.episodes, episodes)
|
||||
wanted = wanted_episodes(args.episodes, episodes)
|
||||
if not wanted:
|
||||
raise SystemExit(f"No matching episodes for spec {args.episodes!r}.")
|
||||
|
||||
@@ -140,20 +249,34 @@ def main():
|
||||
padded = f"{int(float(number)):02d}" if str(number).replace(".", "", 1).isdigit() else str(number)
|
||||
basename = f"{series_title} - S01E{padded}"
|
||||
target = output_dir / f"{basename}.mp4"
|
||||
print(f"Resolving episode {number} ({args.mode}, {args.quality})...")
|
||||
stream = bridge("resolve", provider, ep_id, args.mode, args.quality)
|
||||
print(f"Downloading episode {number} from {stream.get('quality') or 'auto'}...")
|
||||
code = download_episode(stream, target)
|
||||
if code != 0:
|
||||
print(f"ffmpeg failed for episode {number} with exit code {code}", file=sys.stderr)
|
||||
exit_code = code
|
||||
last_error = None
|
||||
downloaded = False
|
||||
for active_provider, _active_show_id, active_ep in provider_episode_candidates(provider, provider_show_id, args.title or info.get("title"), ep):
|
||||
try:
|
||||
active_ep_id = active_ep.get("id")
|
||||
print(f"Resolving episode {number} on {active_provider} ({args.mode}, {args.quality})...")
|
||||
stream = bridge("resolve", active_provider, active_ep_id, args.mode, args.quality)
|
||||
print(f"Downloading episode {number} from {active_provider} {stream.get('quality') or 'auto'}...")
|
||||
code = download_episode(stream, target)
|
||||
if code != 0:
|
||||
last_error = RuntimeError(f"ffmpeg failed on {active_provider} with exit code {code}")
|
||||
print(str(last_error), file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
for subtitle_path in download_subtitles(stream.get("subtitles") or [], target):
|
||||
print(f"Saved subtitle: {subtitle_path.name}")
|
||||
except Exception as exc:
|
||||
print(f"Subtitle download skipped: {exc}")
|
||||
print(f"Saved: {target.name}")
|
||||
downloaded = True
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
print(f"Provider {active_provider} failed for episode {number}: {exc}", file=sys.stderr)
|
||||
if not downloaded:
|
||||
print(f"Episode {number} failed on all providers: {last_error}", file=sys.stderr)
|
||||
exit_code = 1
|
||||
break
|
||||
try:
|
||||
for subtitle_path in download_subtitles(stream.get("subtitles") or [], target):
|
||||
print(f"Saved subtitle: {subtitle_path.name}")
|
||||
except Exception as exc:
|
||||
print(f"Subtitle download skipped: {exc}")
|
||||
print(f"Saved: {target.name}")
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user