cinnamon-cli 0.2.40__tar.gz → 0.3.0__tar.gz
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.
- {cinnamon_cli-0.2.40/cinnamon_cli.egg-info → cinnamon_cli-0.3.0}/PKG-INFO +1 -1
- cinnamon_cli-0.3.0/cinnamon/__init__.py +1 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/anilist.py +33 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/cli.py +625 -122
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/anime.py +75 -33
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0/cinnamon_cli.egg-info}/PKG-INFO +1 -1
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/pyproject.toml +1 -1
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_ui_smoke.py +1 -1
- cinnamon_cli-0.2.40/cinnamon/__init__.py +0 -1
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/LICENSE +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/README.md +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/config.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/downloads.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/embedapi.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/errors.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/history.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/player.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/__init__.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/_vidlink_crypto.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/base.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/torrentio.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/vidsrc.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/webstream.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/tmdb.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/tui.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/entry_points.txt +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/requires.txt +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/top_level.txt +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/setup.cfg +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream2.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream3.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream4.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream5.py +0 -0
- {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream6.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -120,6 +120,39 @@ query ($page: Int, $perPage: Int, $airingAtGreater: Int, $airingAtLesser: Int) {
|
|
|
120
120
|
"""
|
|
121
121
|
|
|
122
122
|
|
|
123
|
+
_MEDIA_AIRING_QUERY = """
|
|
124
|
+
query ($mediaId: Int) {
|
|
125
|
+
Page(page: 1, perPage: 100) {
|
|
126
|
+
airingSchedules(mediaId: $mediaId, sort: EPISODE) {
|
|
127
|
+
airingAt
|
|
128
|
+
episode
|
|
129
|
+
timeUntilAiring
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_media_airing(media_id):
|
|
137
|
+
"""Return list of {airingAt, episode, timeUntilAiring} for a given media ID."""
|
|
138
|
+
try:
|
|
139
|
+
resp = requests.post(
|
|
140
|
+
API,
|
|
141
|
+
json={"query": _MEDIA_AIRING_QUERY, "variables": {"mediaId": media_id}},
|
|
142
|
+
timeout=15,
|
|
143
|
+
headers={"User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json"},
|
|
144
|
+
)
|
|
145
|
+
resp.raise_for_status()
|
|
146
|
+
data = resp.json()
|
|
147
|
+
return data.get("data", {}).get("Page", {}).get("airingSchedules", [])
|
|
148
|
+
except requests.ConnectionError:
|
|
149
|
+
return []
|
|
150
|
+
except requests.Timeout:
|
|
151
|
+
return []
|
|
152
|
+
except requests.HTTPError:
|
|
153
|
+
return []
|
|
154
|
+
|
|
155
|
+
|
|
123
156
|
def get_schedule(days=7):
|
|
124
157
|
now = int(_time.time())
|
|
125
158
|
end = now + (days * 86400)
|
|
@@ -132,6 +132,7 @@ def _q_style():
|
|
|
132
132
|
("separator", f"fg:{dim}"),
|
|
133
133
|
("instruction", f"fg:{dim}"),
|
|
134
134
|
("answer", f"bold fg:{accent}"),
|
|
135
|
+
("disabled", f"fg:{dim} italic"),
|
|
135
136
|
]
|
|
136
137
|
)
|
|
137
138
|
|
|
@@ -163,7 +164,7 @@ def _prompt(message, default=None, password=False):
|
|
|
163
164
|
|
|
164
165
|
|
|
165
166
|
_UPDATE_CHECK_CACHE = 86400 # 24 hours
|
|
166
|
-
|
|
167
|
+
_UPDATE_CHECKED = False # in-process dedup
|
|
167
168
|
|
|
168
169
|
_UPDATE_REPO = "pizza-droid/cinnamon"
|
|
169
170
|
|
|
@@ -171,6 +172,10 @@ _UPDATE_REPO = "pizza-droid/cinnamon"
|
|
|
171
172
|
_UPDATE_CHECK_FALLBACK_DAYS = 7
|
|
172
173
|
|
|
173
174
|
|
|
175
|
+
_search_cache = {}
|
|
176
|
+
_search_history = [] # Track search queries in this session
|
|
177
|
+
|
|
178
|
+
|
|
174
179
|
def _latest_version():
|
|
175
180
|
import requests
|
|
176
181
|
|
|
@@ -207,6 +212,10 @@ def _latest_version():
|
|
|
207
212
|
|
|
208
213
|
|
|
209
214
|
def _check_for_updates():
|
|
215
|
+
global _UPDATE_CHECKED
|
|
216
|
+
if _UPDATE_CHECKED:
|
|
217
|
+
return
|
|
218
|
+
_UPDATE_CHECKED = True
|
|
210
219
|
try:
|
|
211
220
|
cfg = load_config()
|
|
212
221
|
last_check = cfg.get("_update_check", 0)
|
|
@@ -243,11 +252,430 @@ def _check_for_updates():
|
|
|
243
252
|
pass
|
|
244
253
|
|
|
245
254
|
|
|
255
|
+
_tmdb_client_instance = None
|
|
256
|
+
|
|
257
|
+
|
|
246
258
|
def _get_tmdb():
|
|
259
|
+
global _tmdb_client_instance
|
|
260
|
+
if _tmdb_client_instance is not None:
|
|
261
|
+
return _tmdb_client_instance
|
|
247
262
|
try:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
client = TMDBClient()
|
|
264
|
+
# Validate the key with a minimal request so a revoked/invalid key
|
|
265
|
+
# falls back to EmbedClient immediately instead of mid-search.
|
|
266
|
+
client.search_tv("a")
|
|
267
|
+
_tmdb_client_instance = client
|
|
268
|
+
return client
|
|
269
|
+
except (MissingAPIKey, TMDBAuthError):
|
|
270
|
+
_tmdb_client_instance = EmbedClient()
|
|
271
|
+
return _tmdb_client_instance
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# unified search pipeline
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _search_tmdb(query: str, media_type: str | None = None) -> list[dict]:
|
|
280
|
+
"""Search TMDB (or 2embed fallback) for TV and/or movies."""
|
|
281
|
+
tmdb = _get_tmdb()
|
|
282
|
+
results = []
|
|
283
|
+
|
|
284
|
+
if media_type in (None, "tv"):
|
|
285
|
+
tv = tmdb.search_tv(query).get("results", [])
|
|
286
|
+
for r in tv:
|
|
287
|
+
r["_media_type"] = "tv"
|
|
288
|
+
r["_source"] = "tmdb"
|
|
289
|
+
results.append(r)
|
|
290
|
+
|
|
291
|
+
if media_type in (None, "movie"):
|
|
292
|
+
movie = tmdb.search_movie(query).get("results", [])
|
|
293
|
+
for r in movie:
|
|
294
|
+
r["_media_type"] = "movie"
|
|
295
|
+
r["_source"] = "tmdb"
|
|
296
|
+
results.append(r)
|
|
297
|
+
|
|
298
|
+
return results
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _search_anilist(query: str) -> list[dict]:
|
|
302
|
+
"""Search AniList for anime."""
|
|
303
|
+
try:
|
|
304
|
+
from .anilist import search_anime
|
|
305
|
+
|
|
306
|
+
results = search_anime(query)
|
|
307
|
+
for r in results:
|
|
308
|
+
r["_media_type"] = "anime"
|
|
309
|
+
r["_source"] = "anilist"
|
|
310
|
+
return results
|
|
311
|
+
except Exception:
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _merge_and_rank_results(
|
|
316
|
+
tmdb_results: list[dict],
|
|
317
|
+
anilist_results: list[dict],
|
|
318
|
+
query: str,
|
|
319
|
+
) -> list[dict]:
|
|
320
|
+
"""Merge TMDB and AniList results, deduplicate, and sort by relevance."""
|
|
321
|
+
# Build a map of normalized titles to detect duplicates across sources.
|
|
322
|
+
# Key is just the normalized title — if TMDB and AniList return the same
|
|
323
|
+
# show, only the TMDB entry is kept (it has real IDs for scrapers).
|
|
324
|
+
seen = set()
|
|
325
|
+
merged = []
|
|
326
|
+
|
|
327
|
+
def norm_title(t: str) -> str:
|
|
328
|
+
import re
|
|
329
|
+
return re.sub(r"[^\w]", "", t.lower())
|
|
330
|
+
|
|
331
|
+
q_norm = norm_title(query)
|
|
332
|
+
|
|
333
|
+
# Add TMDB results first (they have real IDs for scrapers)
|
|
334
|
+
for r in tmdb_results:
|
|
335
|
+
title = r.get("name") or r.get("title") or "?"
|
|
336
|
+
key = norm_title(title)
|
|
337
|
+
if key not in seen:
|
|
338
|
+
seen.add(key)
|
|
339
|
+
merged.append(r)
|
|
340
|
+
|
|
341
|
+
# Add AniList results, skipping cross-source duplicates
|
|
342
|
+
for r in anilist_results:
|
|
343
|
+
title = (
|
|
344
|
+
r.get("title", {}).get("english")
|
|
345
|
+
or r.get("title", {}).get("romaji")
|
|
346
|
+
or r.get("title", {}).get("native")
|
|
347
|
+
or "?"
|
|
348
|
+
)
|
|
349
|
+
key = norm_title(title)
|
|
350
|
+
if key not in seen:
|
|
351
|
+
seen.add(key)
|
|
352
|
+
merged.append(r)
|
|
353
|
+
|
|
354
|
+
# Score and sort by relevance
|
|
355
|
+
def score(item: dict) -> tuple:
|
|
356
|
+
if item.get("_media_type") == "anime":
|
|
357
|
+
title = (
|
|
358
|
+
item.get("title", {}).get("english")
|
|
359
|
+
or item.get("title", {}).get("romaji")
|
|
360
|
+
or item.get("title", {}).get("native")
|
|
361
|
+
or ""
|
|
362
|
+
)
|
|
363
|
+
# anime has genre, season info etc. - prioritize exact matches
|
|
364
|
+
else:
|
|
365
|
+
title = item.get("name") or item.get("title") or ""
|
|
366
|
+
|
|
367
|
+
t_norm = norm_title(title)
|
|
368
|
+
# Exact match first
|
|
369
|
+
if t_norm == q_norm:
|
|
370
|
+
return (0, -item.get("popularity", 0))
|
|
371
|
+
# Starts with query
|
|
372
|
+
if t_norm.startswith(q_norm):
|
|
373
|
+
return (1, -item.get("popularity", 0))
|
|
374
|
+
# Contains query
|
|
375
|
+
if q_norm in t_norm:
|
|
376
|
+
return (2, -item.get("popularity", 0))
|
|
377
|
+
# Fuzzy: word overlap
|
|
378
|
+
q_words = set(q_norm.split())
|
|
379
|
+
t_words = set(t_norm.split())
|
|
380
|
+
overlap = len(q_words & t_words)
|
|
381
|
+
if overlap > 0:
|
|
382
|
+
return (3, -overlap, -item.get("popularity", 0))
|
|
383
|
+
return (4, -item.get("popularity", 0))
|
|
384
|
+
|
|
385
|
+
merged.sort(key=score)
|
|
386
|
+
return merged
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _format_search_result(item: dict) -> tuple[str, str, str]:
|
|
390
|
+
"""Return (title, year, tag) for a search result item."""
|
|
391
|
+
mtype = item.get("_media_type", "tv")
|
|
392
|
+
source = item.get("_source", "tmdb")
|
|
393
|
+
|
|
394
|
+
if mtype == "anime":
|
|
395
|
+
title = (
|
|
396
|
+
item.get("title", {}).get("english")
|
|
397
|
+
or item.get("title", {}).get("romaji")
|
|
398
|
+
or item.get("title", {}).get("native")
|
|
399
|
+
or "?"
|
|
400
|
+
)
|
|
401
|
+
year = str((item.get("startDate") or {}).get("year", ""))
|
|
402
|
+
tag = "Anime"
|
|
403
|
+
return title, year, tag
|
|
404
|
+
|
|
405
|
+
if mtype == "movie":
|
|
406
|
+
title = item.get("title", "?")
|
|
407
|
+
year = (item.get("release_date", "") or "")[:4]
|
|
408
|
+
tag = "Movie"
|
|
409
|
+
return title, year, tag
|
|
410
|
+
|
|
411
|
+
# TV
|
|
412
|
+
title = item.get("name", "?")
|
|
413
|
+
year = (item.get("first_air_date", "") or "")[:4]
|
|
414
|
+
tag = "Anime" if _is_anime(item) else "TV"
|
|
415
|
+
return title, year, tag
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _pick_search_result(results: list[dict], message: str) -> dict | None:
|
|
419
|
+
"""Unified picker for search results with type tags."""
|
|
420
|
+
if not results:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
choices = []
|
|
425
|
+
for item in results:
|
|
426
|
+
title, year, tag = _format_search_result(item)
|
|
427
|
+
parts = []
|
|
428
|
+
if year:
|
|
429
|
+
parts.append(f"({year})")
|
|
430
|
+
parts.append(f"[{tag}]")
|
|
431
|
+
label = f"{title} {' '.join(parts)}"
|
|
432
|
+
choices.append(questionary.Choice(title=label, value=item))
|
|
433
|
+
return _select(message, choices)
|
|
434
|
+
except Exception:
|
|
435
|
+
# Fallback to numbered table
|
|
436
|
+
table = Table(border_style="dim")
|
|
437
|
+
table.add_column("#", style="cyan")
|
|
438
|
+
table.add_column("Title", style="white")
|
|
439
|
+
table.add_column("Info", style="green")
|
|
440
|
+
for i, item in enumerate(results, 1):
|
|
441
|
+
title, year, tag = _format_search_result(item)
|
|
442
|
+
info = f"({year}) [{tag}]" if year else f"[{tag}]"
|
|
443
|
+
table.add_row(str(i), title, info)
|
|
444
|
+
console.print(table)
|
|
445
|
+
attempts = 0
|
|
446
|
+
while True:
|
|
447
|
+
try:
|
|
448
|
+
choice = int(_prompt(f"{message} (enter number)", default="1"))
|
|
449
|
+
if 1 <= choice <= len(results):
|
|
450
|
+
return results[choice - 1]
|
|
451
|
+
console.print(f"[red]Pick a number between 1 and {len(results)}.[/red]")
|
|
452
|
+
except ValueError:
|
|
453
|
+
console.print("[red]Invalid input. Enter a number.[/red]")
|
|
454
|
+
attempts += 1
|
|
455
|
+
if attempts >= 10:
|
|
456
|
+
console.print("[red]Too many invalid attempts.[/red]")
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _narrow_and_search(query: str, tmdb_only: bool = False) -> list[dict]:
|
|
461
|
+
"""Progressively narrow query by removing trailing words until results found."""
|
|
462
|
+
cache_key = (query, tmdb_only)
|
|
463
|
+
if cache_key in _search_cache:
|
|
464
|
+
return _search_cache[cache_key]
|
|
465
|
+
|
|
466
|
+
narrowed = query.split()
|
|
467
|
+
while narrowed:
|
|
468
|
+
q = " ".join(narrowed)
|
|
469
|
+
tmdb_results = _search_tmdb(q)
|
|
470
|
+
anilist_results = [] if tmdb_only else _search_anilist(q)
|
|
471
|
+
results = _merge_and_rank_results(tmdb_results, anilist_results, query)
|
|
472
|
+
if results:
|
|
473
|
+
_search_cache[cache_key] = results
|
|
474
|
+
return results
|
|
475
|
+
narrowed = narrowed[:-1]
|
|
476
|
+
|
|
477
|
+
_search_cache[cache_key] = []
|
|
478
|
+
return []
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _run_unified_search(query: str, tmdb_only: bool = False) -> dict | None:
|
|
482
|
+
"""Run unified search and return selected item, or None if cancelled."""
|
|
483
|
+
if not query or not query.strip():
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
query = query.strip().replace("'", "")
|
|
487
|
+
|
|
488
|
+
# Save to search history
|
|
489
|
+
global _search_history
|
|
490
|
+
if query not in _search_history and load_config().get("history_enabled", True):
|
|
491
|
+
_search_history.append(query)
|
|
492
|
+
# Keep only last 50
|
|
493
|
+
if len(_search_history) > 50:
|
|
494
|
+
_search_history = _search_history[-50:]
|
|
495
|
+
|
|
496
|
+
# Try cached or fresh search
|
|
497
|
+
results = _narrow_and_search(query, tmdb_only=tmdb_only)
|
|
498
|
+
|
|
499
|
+
if not results:
|
|
500
|
+
_print_info(f"No results found for \"{query}\".")
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
show = _pick_search_result(results, "Select a title:")
|
|
504
|
+
return show
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _show_search_history():
|
|
508
|
+
"""Show in-memory search history and let user pick one to re-search."""
|
|
509
|
+
if not load_config().get("history_enabled", True):
|
|
510
|
+
_print_info("Search history is disabled (use 'cinnamon history toggle' to enable).")
|
|
511
|
+
return
|
|
512
|
+
global _search_history
|
|
513
|
+
|
|
514
|
+
if not _search_history:
|
|
515
|
+
_print_info("No search history in this session.")
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
# Show most recent first
|
|
519
|
+
history = list(reversed(_search_history))
|
|
520
|
+
|
|
521
|
+
table = Table(border_style="dim")
|
|
522
|
+
table.add_column("#", style="cyan")
|
|
523
|
+
table.add_column("Recent Searches", style="white")
|
|
524
|
+
|
|
525
|
+
for i, q in enumerate(history, 1):
|
|
526
|
+
table.add_row(str(i), q)
|
|
527
|
+
|
|
528
|
+
console.print(table)
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
choice = int(_prompt("Search again (enter number)", default="1"))
|
|
532
|
+
if 1 <= choice <= len(history):
|
|
533
|
+
selected = history[choice - 1]
|
|
534
|
+
ctx = click.get_current_context()
|
|
535
|
+
if ctx:
|
|
536
|
+
ctx.invoke(search, query=(selected,))
|
|
537
|
+
except (ValueError, EOFError, KeyboardInterrupt):
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _find_show_allanime(session, show_dict):
|
|
542
|
+
"""Try to find an anime on allanime using all available title variants
|
|
543
|
+
from an AniList result dict (english, romaji, native)."""
|
|
544
|
+
from .scrapers.anime import _find_show
|
|
545
|
+
titles = show_dict.get("title", {})
|
|
546
|
+
tried = set()
|
|
547
|
+
for key in ("english", "romaji", "native"):
|
|
548
|
+
name = titles.get(key)
|
|
549
|
+
if name:
|
|
550
|
+
lowered = name.lower().strip()
|
|
551
|
+
if lowered not in tried:
|
|
552
|
+
tried.add(lowered)
|
|
553
|
+
aid = _find_show(session, name)
|
|
554
|
+
if aid:
|
|
555
|
+
return aid
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _handle_anime_selection(show: dict, season: int | None, ep_str: str | None,
|
|
560
|
+
player: str | None, quality: str | None,
|
|
561
|
+
info_only: bool, download: bool,
|
|
562
|
+
scraper: str | None):
|
|
563
|
+
"""Route an anime result through the anime flow."""
|
|
564
|
+
from .scrapers.anime import _find_show, _allanime_episodes, _run_anime_flow
|
|
565
|
+
from .history import get_history as _get_history
|
|
566
|
+
import requests as _req
|
|
567
|
+
|
|
568
|
+
show_name = _format_search_result(show)[0]
|
|
569
|
+
theme = get_theme()
|
|
570
|
+
|
|
571
|
+
session = _req.Session()
|
|
572
|
+
session.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
573
|
+
|
|
574
|
+
allanime_id = _find_show(session, show_name)
|
|
575
|
+
if not allanime_id:
|
|
576
|
+
allanime_id = _find_show_allanime(session, show)
|
|
577
|
+
if not allanime_id:
|
|
578
|
+
_print_error(f"No match for \"{show_name}\" on allanime.")
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
episodes_detail = _allanime_episodes(session, allanime_id)
|
|
582
|
+
if not episodes_detail:
|
|
583
|
+
_print_error("No episode data from allanime.")
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
# Check history for resume
|
|
587
|
+
if not ep_str:
|
|
588
|
+
entries = _get_history(show_name)
|
|
589
|
+
if entries:
|
|
590
|
+
last = entries[0]
|
|
591
|
+
if last.get("episode"):
|
|
592
|
+
resume_ep = last["episode"] + 1
|
|
593
|
+
try:
|
|
594
|
+
answer = Prompt.ask(
|
|
595
|
+
f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
|
|
596
|
+
default="y",
|
|
597
|
+
show_default=False,
|
|
598
|
+
).strip().lower()
|
|
599
|
+
if answer in ("", "y", "yes"):
|
|
600
|
+
ep_str = str(resume_ep)
|
|
601
|
+
except (EOFError, KeyboardInterrupt):
|
|
602
|
+
ep_str = str(resume_ep)
|
|
603
|
+
|
|
604
|
+
anilist_id = show.get("id") if show.get("_source") == "anilist" else None
|
|
605
|
+
_run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, allanime_id=allanime_id, anilist_id=anilist_id)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _handle_tv_selection(show: dict, tmdb, season: int | None, ep_start: int | None,
|
|
609
|
+
ep_end: int | None, scraper: str | None,
|
|
610
|
+
player: str | None, quality: str | None,
|
|
611
|
+
info_only: bool, download: bool):
|
|
612
|
+
"""Route a TV result through the episode picker."""
|
|
613
|
+
mtype = show.get("_media_type", "tv")
|
|
614
|
+
title_key = "name" if mtype == "tv" else "title"
|
|
615
|
+
date_key = "first_air_date" if mtype == "tv" else "release_date"
|
|
616
|
+
show_name = show.get(title_key, "?")
|
|
617
|
+
show_id = show["id"]
|
|
618
|
+
|
|
619
|
+
# Auto-detect anime from TMDB data
|
|
620
|
+
if scraper is None and mtype == "tv" and _is_anime(show):
|
|
621
|
+
scraper = "anime"
|
|
622
|
+
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
623
|
+
|
|
624
|
+
if mtype == "movie":
|
|
625
|
+
_play_movie(show, scraper, player, quality, info_only, download)
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
if season is not None and ep_start is not None:
|
|
629
|
+
_play_with_menu(show, season, ep_start, ep_end, f"S{season:02d}E{ep_start:02d}", scraper, player, quality, info_only, download)
|
|
630
|
+
return
|
|
631
|
+
|
|
632
|
+
theme = get_theme()
|
|
633
|
+
console.clear()
|
|
634
|
+
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
635
|
+
|
|
636
|
+
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download, ep_start, ep_end)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _handle_search_selection(show: dict, season: int | None, ep_str: str | None,
|
|
640
|
+
player: str | None, quality: str | None,
|
|
641
|
+
info_only: bool, download: bool,
|
|
642
|
+
scraper: str | None):
|
|
643
|
+
"""Route a search result to the appropriate handler."""
|
|
644
|
+
mtype = show.get("_media_type", "tv")
|
|
645
|
+
source = show.get("_source", "tmdb")
|
|
646
|
+
|
|
647
|
+
tmdb = _get_tmdb()
|
|
648
|
+
|
|
649
|
+
if mtype == "anime":
|
|
650
|
+
_handle_anime_selection(show, season, ep_str, player, quality, info_only, download, scraper)
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
# TV or Movie from TMDB
|
|
654
|
+
if mtype == "movie":
|
|
655
|
+
_play_movie(show, scraper, player, quality, info_only, download)
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
# TV show
|
|
659
|
+
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
660
|
+
|
|
661
|
+
# Auto-detect anime
|
|
662
|
+
if scraper is None and _is_anime(show):
|
|
663
|
+
scraper = "anime"
|
|
664
|
+
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
665
|
+
|
|
666
|
+
if season is not None and ep_start is not None:
|
|
667
|
+
_play_with_menu(show, season, ep_start, ep_end, f"S{season:02d}E{ep_start:02d}", scraper, player, quality, info_only, download)
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
theme = get_theme()
|
|
671
|
+
console.clear()
|
|
672
|
+
show_name = show.get("name", "?")
|
|
673
|
+
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
674
|
+
|
|
675
|
+
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download, ep_start, ep_end)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ---------------------------------------------------------------------------
|
|
251
679
|
|
|
252
680
|
|
|
253
681
|
def _handle_errors(fn):
|
|
@@ -385,12 +813,12 @@ def _parse_episode(ep_str):
|
|
|
385
813
|
return None, None
|
|
386
814
|
parts = str(ep_str).split("-", 1)
|
|
387
815
|
try:
|
|
388
|
-
start =
|
|
816
|
+
start = float(parts[0])
|
|
389
817
|
except ValueError:
|
|
390
818
|
return None, None
|
|
391
819
|
if len(parts) == 2:
|
|
392
820
|
try:
|
|
393
|
-
end =
|
|
821
|
+
end = float(parts[1])
|
|
394
822
|
except ValueError:
|
|
395
823
|
return None, None
|
|
396
824
|
if end < start:
|
|
@@ -535,6 +963,8 @@ def _resolve_and_play(show, season_num, ep_num, ep_name, scraper, player, qualit
|
|
|
535
963
|
info["translation"] = translation
|
|
536
964
|
if download:
|
|
537
965
|
info["download"] = True
|
|
966
|
+
if show.get("allanime_id"):
|
|
967
|
+
info["allanime_id"] = show["allanime_id"]
|
|
538
968
|
future = pool.submit(attempt.resolve, info)
|
|
539
969
|
result = future.result(timeout=RESOLVE_TIMEOUT)
|
|
540
970
|
if result:
|
|
@@ -708,7 +1138,8 @@ class CinnamonGroup(click.Group):
|
|
|
708
1138
|
@click.pass_context
|
|
709
1139
|
def cli(ctx, setup):
|
|
710
1140
|
"""Cinnamon - Stream TV shows, movies & anime from the command line."""
|
|
711
|
-
|
|
1141
|
+
if ctx.invoked_subcommand != "update":
|
|
1142
|
+
_check_for_updates()
|
|
712
1143
|
if setup:
|
|
713
1144
|
ctx.invoke(setup_cmd)
|
|
714
1145
|
return
|
|
@@ -732,6 +1163,9 @@ def cli(ctx, setup):
|
|
|
732
1163
|
query = _prompt(f"[bold]Search for a[/bold] [{theme['info']}]show or movie[/{theme['info']}]")
|
|
733
1164
|
if query and query.strip():
|
|
734
1165
|
ctx.invoke(search, query=(query.strip(),))
|
|
1166
|
+
else:
|
|
1167
|
+
# Empty input - show search history
|
|
1168
|
+
_show_search_history()
|
|
735
1169
|
|
|
736
1170
|
|
|
737
1171
|
# ---------------------------------------------------------------------------
|
|
@@ -890,69 +1324,22 @@ def _theme_preview(name):
|
|
|
890
1324
|
def search(query, media_type, season, ep_str, scraper, player, quality, download, info_only):
|
|
891
1325
|
"""Search for a show or movie, pick one, and watch it."""
|
|
892
1326
|
_check_for_updates()
|
|
893
|
-
tmdb = _get_tmdb()
|
|
894
1327
|
|
|
895
1328
|
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
896
1329
|
|
|
897
|
-
query_str = " ".join(query) if query else None
|
|
1330
|
+
query_str = " ".join(query).replace("'", "") if query else None
|
|
898
1331
|
if not query_str:
|
|
899
1332
|
console.clear()
|
|
900
1333
|
query_str = Prompt.ask("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
|
|
901
1334
|
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
combined = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
|
|
906
|
-
else:
|
|
907
|
-
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
|
|
908
|
-
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query_str).get("results", [])]
|
|
909
|
-
combined = tv + movie
|
|
910
|
-
|
|
911
|
-
if not combined:
|
|
912
|
-
_print_info(f"No results found for \"{query_str}\".")
|
|
913
|
-
return
|
|
914
|
-
|
|
915
|
-
show = _pick_combined(combined, "Select a title:")
|
|
1335
|
+
# Use unified search - include anime results unless media_type is explicitly set
|
|
1336
|
+
tmdb_only = media_type is not None
|
|
1337
|
+
show = _run_unified_search(query_str, tmdb_only=tmdb_only)
|
|
916
1338
|
|
|
917
1339
|
if not show:
|
|
918
1340
|
return
|
|
919
1341
|
|
|
920
|
-
|
|
921
|
-
title_key = "name" if mtype == "tv" else "title"
|
|
922
|
-
date_key = "first_air_date" if mtype == "tv" else "release_date"
|
|
923
|
-
show_name = show.get(title_key, "?")
|
|
924
|
-
show_id = show["id"]
|
|
925
|
-
|
|
926
|
-
if scraper is None and mtype == "tv" and _is_anime(show):
|
|
927
|
-
# Route anime through allanime's available-episode list so we never
|
|
928
|
-
# offer an episode the source doesn't have (same as `cinnamon anime`).
|
|
929
|
-
from .scrapers.anime import _find_show, _allanime_episodes
|
|
930
|
-
import requests as _req
|
|
931
|
-
_as = _req.Session()
|
|
932
|
-
_as.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
933
|
-
_aid = _find_show(_as, show_name)
|
|
934
|
-
if _aid:
|
|
935
|
-
_ed = _allanime_episodes(_as, _aid)
|
|
936
|
-
if _ed:
|
|
937
|
-
_run_anime_flow(show_name, _ed, season, ep_str, player, quality, info_only, download)
|
|
938
|
-
return
|
|
939
|
-
# Fall through to generic TV flow if allanime lookup fails.
|
|
940
|
-
scraper = "anime"
|
|
941
|
-
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
942
|
-
|
|
943
|
-
if mtype == "movie":
|
|
944
|
-
_play_movie(show, scraper, player, quality, info_only, download)
|
|
945
|
-
return
|
|
946
|
-
|
|
947
|
-
if season is not None and ep_start is not None:
|
|
948
|
-
_play_with_menu(show, season, ep_start, ep_end, f"S{season:02d}E{ep_start:02d}", scraper, player, quality, info_only, download)
|
|
949
|
-
return
|
|
950
|
-
|
|
951
|
-
theme = get_theme()
|
|
952
|
-
console.clear()
|
|
953
|
-
console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
|
|
954
|
-
|
|
955
|
-
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, download, ep_start, ep_end)
|
|
1342
|
+
_handle_search_selection(show, season, ep_str, player, quality, info_only, download, scraper)
|
|
956
1343
|
|
|
957
1344
|
|
|
958
1345
|
# ---------------------------------------------------------------------------
|
|
@@ -1002,6 +1389,14 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
|
|
|
1002
1389
|
_interactive_episode_picker(_get_tmdb(), show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
1003
1390
|
return
|
|
1004
1391
|
|
|
1392
|
+
# If query provided but no type, use unified search
|
|
1393
|
+
if query and media_type is None:
|
|
1394
|
+
show = _run_unified_search(query.strip().replace("'", ""))
|
|
1395
|
+
if not show:
|
|
1396
|
+
return
|
|
1397
|
+
_handle_search_selection(show, season, ep_str, player, quality, info_only, download, scraper)
|
|
1398
|
+
return
|
|
1399
|
+
|
|
1005
1400
|
tmdb = _get_tmdb()
|
|
1006
1401
|
|
|
1007
1402
|
# Explicit movie request.
|
|
@@ -1047,53 +1442,15 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
|
|
|
1047
1442
|
_interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
|
|
1048
1443
|
return
|
|
1049
1444
|
|
|
1050
|
-
# No
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
try:
|
|
1054
|
-
show = tmdb.get_tv_details(tmdb_id)
|
|
1055
|
-
mtype = "tv"
|
|
1056
|
-
except TMDBNotFoundError:
|
|
1057
|
-
show = tmdb.get_movie_details(tmdb_id)
|
|
1058
|
-
mtype = "movie"
|
|
1059
|
-
elif query:
|
|
1060
|
-
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
|
|
1061
|
-
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
|
|
1062
|
-
combined = tv + movie
|
|
1063
|
-
if not combined:
|
|
1064
|
-
_print_info(f"No results found for \"{query}\".")
|
|
1065
|
-
return
|
|
1066
|
-
show = _pick_combined(combined, "Select a title:")
|
|
1067
|
-
if not show:
|
|
1068
|
-
return
|
|
1069
|
-
mtype = show.get("_media_type", "tv")
|
|
1070
|
-
else:
|
|
1071
|
-
query = _prompt("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
|
|
1072
|
-
if not query or not query.strip():
|
|
1073
|
-
return
|
|
1074
|
-
query = query.strip()
|
|
1075
|
-
tv = [dict(r, _media_type="tv") for r in tmdb.search_tv(query).get("results", [])]
|
|
1076
|
-
movie = [dict(r, _media_type="movie") for r in tmdb.search_movie(query).get("results", [])]
|
|
1077
|
-
combined = tv + movie
|
|
1078
|
-
if not combined:
|
|
1079
|
-
_print_info(f"No results found for \"{query}\".")
|
|
1080
|
-
return
|
|
1081
|
-
show = _pick_combined(combined, "Select a title:")
|
|
1082
|
-
if not show:
|
|
1083
|
-
return
|
|
1084
|
-
mtype = show.get("_media_type", "tv")
|
|
1085
|
-
|
|
1086
|
-
if mtype == "movie":
|
|
1087
|
-
_play_movie(show, scraper, player, quality, info_only, download)
|
|
1445
|
+
# No query, no type, no id - prompt for search
|
|
1446
|
+
query = _prompt("[bold]Search for a[/bold] [cyan]show or movie[/cyan]")
|
|
1447
|
+
if not query or not query.strip():
|
|
1088
1448
|
return
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
_print_info(f"Detected anime — using [bold]anime[/bold] scraper")
|
|
1093
|
-
if season is not None and ep_start is not None:
|
|
1094
|
-
_play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
|
|
1449
|
+
query = query.strip()
|
|
1450
|
+
show = _run_unified_search(query.replace("'", ""))
|
|
1451
|
+
if not show:
|
|
1095
1452
|
return
|
|
1096
|
-
|
|
1453
|
+
_handle_search_selection(show, season, ep_str, player, quality, info_only, download, scraper)
|
|
1097
1454
|
|
|
1098
1455
|
|
|
1099
1456
|
# ---------------------------------------------------------------------------
|
|
@@ -1168,6 +1525,22 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1168
1525
|
_play_with_menu(show, season_num, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download)
|
|
1169
1526
|
return
|
|
1170
1527
|
|
|
1528
|
+
# Instant episode search/filter
|
|
1529
|
+
def _filter_episodes(eps, search_term):
|
|
1530
|
+
"""Filter episodes by episode number or title."""
|
|
1531
|
+
if not search_term:
|
|
1532
|
+
return eps
|
|
1533
|
+
search_term = search_term.lower().strip()
|
|
1534
|
+
filtered = []
|
|
1535
|
+
for ep in eps:
|
|
1536
|
+
ep_num = ep.get("episode_number")
|
|
1537
|
+
ep_title = (ep.get("name") or "").lower()
|
|
1538
|
+
if isinstance(ep_num, int) and search_term in str(ep_num):
|
|
1539
|
+
filtered.append(ep)
|
|
1540
|
+
elif search_term in ep_title:
|
|
1541
|
+
filtered.append(ep)
|
|
1542
|
+
return filtered
|
|
1543
|
+
|
|
1171
1544
|
try:
|
|
1172
1545
|
ep_choices = []
|
|
1173
1546
|
for ep in episodes:
|
|
@@ -1175,10 +1548,41 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
|
|
|
1175
1548
|
ep_title = ep.get("name", f"Episode {ep_num}")
|
|
1176
1549
|
label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
|
|
1177
1550
|
ep_choices.append(questionary.Choice(title=label, value=ep))
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1551
|
+
|
|
1552
|
+
# Add a search/filter option at the top
|
|
1553
|
+
search_choice = questionary.Choice(
|
|
1554
|
+
title="[Search episodes by number/title...]",
|
|
1555
|
+
value="__search__"
|
|
1181
1556
|
)
|
|
1557
|
+
ep_choices.insert(0, search_choice)
|
|
1558
|
+
|
|
1559
|
+
while True:
|
|
1560
|
+
ep_chosen = _select(
|
|
1561
|
+
f"Season {season_num} - Select an episode:",
|
|
1562
|
+
choices=ep_choices,
|
|
1563
|
+
)
|
|
1564
|
+
if not ep_chosen:
|
|
1565
|
+
return
|
|
1566
|
+
|
|
1567
|
+
if ep_chosen == "__search__":
|
|
1568
|
+
search_term = _prompt("Filter episodes (number or title)")
|
|
1569
|
+
if search_term:
|
|
1570
|
+
filtered = _filter_episodes(episodes, search_term)
|
|
1571
|
+
if not filtered:
|
|
1572
|
+
console.print(f" [dim]No episodes match \"{search_term}\"[/dim]")
|
|
1573
|
+
continue
|
|
1574
|
+
# Rebuild choices with filtered episodes
|
|
1575
|
+
ep_choices = []
|
|
1576
|
+
for ep in filtered:
|
|
1577
|
+
ep_num = ep.get("episode_number", "?")
|
|
1578
|
+
ep_title = ep.get("name", f"Episode {ep_num}")
|
|
1579
|
+
label = f"E{ep_num:02d} {ep_title}" if isinstance(ep_num, int) else f"{ep_num} {ep_title}"
|
|
1580
|
+
ep_choices.append(questionary.Choice(title=label, value=ep))
|
|
1581
|
+
ep_choices.insert(0, search_choice)
|
|
1582
|
+
continue
|
|
1583
|
+
continue
|
|
1584
|
+
break
|
|
1585
|
+
|
|
1182
1586
|
if not ep_chosen:
|
|
1183
1587
|
return
|
|
1184
1588
|
except Exception:
|
|
@@ -1585,15 +1989,20 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1585
1989
|
"""Search anime via AniList (no API key needed) and stream from allanime."""
|
|
1586
1990
|
_check_for_updates()
|
|
1587
1991
|
|
|
1588
|
-
query_str = " ".join(query) if query else None
|
|
1992
|
+
query_str = " ".join(query).replace("'", "") if query else None
|
|
1589
1993
|
if not query_str:
|
|
1590
1994
|
query_str = Prompt.ask("[bold]Search for an[/bold] [magenta]anime[/magenta]")
|
|
1591
1995
|
from .anilist import search_anime
|
|
1592
1996
|
|
|
1593
1997
|
results = search_anime(query_str)
|
|
1594
1998
|
if not results:
|
|
1595
|
-
|
|
1596
|
-
|
|
1999
|
+
narrowed = query_str.split()
|
|
2000
|
+
while not results and len(narrowed) > 1:
|
|
2001
|
+
narrowed = narrowed[:-1]
|
|
2002
|
+
results = search_anime(" ".join(narrowed))
|
|
2003
|
+
if not results:
|
|
2004
|
+
_print_info(f"No anime found for \"{query_str}\".")
|
|
2005
|
+
return
|
|
1597
2006
|
|
|
1598
2007
|
def _title(m):
|
|
1599
2008
|
return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
|
|
@@ -1635,6 +2044,10 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1635
2044
|
session = _req.Session()
|
|
1636
2045
|
session.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
1637
2046
|
allanime_id = _find_show(session, show_name)
|
|
2047
|
+
if not allanime_id:
|
|
2048
|
+
allanime_id = _find_show_allanime(session, show)
|
|
2049
|
+
if allanime_id:
|
|
2050
|
+
show_dict["allanime_id"] = allanime_id
|
|
1638
2051
|
tt = "sub"
|
|
1639
2052
|
if allanime_id:
|
|
1640
2053
|
eps = _allanime_episodes(session, allanime_id)
|
|
@@ -1652,6 +2065,7 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1652
2065
|
tt = "sub"
|
|
1653
2066
|
elif tt_keys:
|
|
1654
2067
|
tt = tt_keys[0]
|
|
2068
|
+
console.print(f" [dim]Only {tt} available[/dim]")
|
|
1655
2069
|
|
|
1656
2070
|
proc = _resolve_and_play(show_dict, None, 1, show_name, "anime", player, quality, info_only, download, translation=tt)
|
|
1657
2071
|
if proc is None and not info_only and not download:
|
|
@@ -1698,6 +2112,8 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1698
2112
|
session.headers.update({"User-Agent": "Mozilla/5.0"})
|
|
1699
2113
|
|
|
1700
2114
|
allanime_id = _find_show(session, show_name)
|
|
2115
|
+
if not allanime_id:
|
|
2116
|
+
allanime_id = _find_show_allanime(session, show)
|
|
1701
2117
|
if not allanime_id:
|
|
1702
2118
|
_print_error(f"No match for \"{show_name}\" on allanime.")
|
|
1703
2119
|
return
|
|
@@ -1711,7 +2127,7 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
|
|
|
1711
2127
|
_print_info("Both --sub and --dub given; ignoring and asking.")
|
|
1712
2128
|
prefer_sub = prefer_dub = False
|
|
1713
2129
|
translation = "sub" if prefer_sub else ("dub" if prefer_dub else None)
|
|
1714
|
-
_run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, translation=translation)
|
|
2130
|
+
_run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, translation=translation, allanime_id=allanime_id, anilist_id=show.get("id"))
|
|
1715
2131
|
|
|
1716
2132
|
|
|
1717
2133
|
def _anime_movie_tmdb_id(show_name, year=None):
|
|
@@ -1793,7 +2209,37 @@ def _normalize_movie_title(name):
|
|
|
1793
2209
|
return n
|
|
1794
2210
|
|
|
1795
2211
|
|
|
1796
|
-
def
|
|
2212
|
+
def _format_airing_time(airing_at):
|
|
2213
|
+
"""Format a future Unix timestamp into a human-readable relative time string."""
|
|
2214
|
+
import time as _time
|
|
2215
|
+
now = int(_time.time())
|
|
2216
|
+
diff = airing_at - now
|
|
2217
|
+
if diff <= 0:
|
|
2218
|
+
return "now"
|
|
2219
|
+
if diff < 3600:
|
|
2220
|
+
minutes = diff // 60
|
|
2221
|
+
return f"in {minutes}m"
|
|
2222
|
+
if diff < 86400:
|
|
2223
|
+
hours = diff // 3600
|
|
2224
|
+
return f"in {hours}h"
|
|
2225
|
+
if diff < 604800:
|
|
2226
|
+
days = diff // 86400
|
|
2227
|
+
return f"in {days}d"
|
|
2228
|
+
import datetime as _dt
|
|
2229
|
+
dt = _dt.datetime.fromtimestamp(airing_at, tz=_dt.timezone.utc)
|
|
2230
|
+
return dt.strftime("on %b %d")
|
|
2231
|
+
|
|
2232
|
+
|
|
2233
|
+
def _format_airing_date(timestamp):
|
|
2234
|
+
"""Format a past Unix timestamp as an aired-date string."""
|
|
2235
|
+
import datetime as _dt
|
|
2236
|
+
dt = _dt.datetime.fromtimestamp(timestamp, tz=_dt.timezone.utc)
|
|
2237
|
+
return dt.strftime("on %b %d")
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None,
|
|
2241
|
+
player=None, quality=None, info_only=False, download=False,
|
|
2242
|
+
translation=None, allanime_id=None, anilist_id=None):
|
|
1797
2243
|
"""Drive the episode picker from allanime's *available* episodes (not TMDB's
|
|
1798
2244
|
full list), so we never offer an episode the anime source doesn't have."""
|
|
1799
2245
|
parsed = {}
|
|
@@ -1842,25 +2288,79 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
|
|
|
1842
2288
|
tt = "sub"
|
|
1843
2289
|
else:
|
|
1844
2290
|
tt = tt_keys[0]
|
|
1845
|
-
|
|
1846
|
-
console.print(f" [dim]Using {tt}[/dim]")
|
|
2291
|
+
console.print(f" [dim]Only {tt} available[/dim]")
|
|
1847
2292
|
|
|
1848
|
-
episodes = [
|
|
2293
|
+
episodes = [float(e) for e in season_data[tt]]
|
|
2294
|
+
available = set(episodes)
|
|
1849
2295
|
max_ep = max(episodes)
|
|
2296
|
+
|
|
2297
|
+
# Fetch airing schedule from AniList — show future episodes and recently
|
|
2298
|
+
# aired ones (within 14 days) that haven't appeared on allanime yet.
|
|
2299
|
+
# Cap at 10 total to avoid clutter (One Piece has 27+ stale entries).
|
|
2300
|
+
airing_map = {} # episode -> (airingAt, timeUntilAiring)
|
|
2301
|
+
if anilist_id:
|
|
2302
|
+
try:
|
|
2303
|
+
from .anilist import get_media_airing
|
|
2304
|
+
schedule = get_media_airing(anilist_id)
|
|
2305
|
+
now = time.time()
|
|
2306
|
+
for entry in schedule:
|
|
2307
|
+
ep = entry.get("episode")
|
|
2308
|
+
airing_at = entry.get("airingAt", 0)
|
|
2309
|
+
if not ep or ep in available:
|
|
2310
|
+
continue
|
|
2311
|
+
is_future = airing_at > now
|
|
2312
|
+
recently_aired = not is_future and (now - airing_at) <= 14 * 86400
|
|
2313
|
+
if not is_future and not recently_aired:
|
|
2314
|
+
continue
|
|
2315
|
+
airing_map[ep] = (airing_at, entry.get("timeUntilAiring", 0))
|
|
2316
|
+
# Cap at 10 closest entries
|
|
2317
|
+
if len(airing_map) > 10:
|
|
2318
|
+
sorted_eps = sorted(airing_map.items(), key=lambda x: x[1][0])
|
|
2319
|
+
airing_map = dict(sorted_eps[:10])
|
|
2320
|
+
except Exception:
|
|
2321
|
+
pass
|
|
2322
|
+
|
|
2323
|
+
# Merge available + scheduled episodes for the picker
|
|
2324
|
+
all_known = sorted(available | set(airing_map.keys()))
|
|
2325
|
+
max_ep = max(all_known) if all_known else max_ep
|
|
2326
|
+
|
|
1850
2327
|
ep_start, ep_end = _parse_episode(ep_str) if ep_str else (None, None)
|
|
1851
2328
|
|
|
1852
2329
|
if ep_start is not None:
|
|
1853
|
-
if ep_start not in
|
|
1854
|
-
#
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
2330
|
+
if ep_start not in available:
|
|
2331
|
+
# Only fall back if it's a confirmed future episode
|
|
2332
|
+
is_future = ep_start in airing_map and airing_map[ep_start][0] > time.time()
|
|
2333
|
+
if is_future:
|
|
2334
|
+
fallback = max_ep
|
|
2335
|
+
_print_info(f"Episode {ep_start} not available yet — using E{fallback}.")
|
|
2336
|
+
ep_start = fallback
|
|
1859
2337
|
if ep_end is not None and ep_end > max_ep:
|
|
1860
2338
|
ep_end = max_ep
|
|
1861
2339
|
else:
|
|
1862
2340
|
try:
|
|
1863
|
-
ep_choices = [
|
|
2341
|
+
ep_choices = []
|
|
2342
|
+
for e in all_known:
|
|
2343
|
+
ep_label = f"Episode {int(e)}" if e == int(e) else f"Episode {e}"
|
|
2344
|
+
if e in available:
|
|
2345
|
+
ep_choices.append(questionary.Choice(title=ep_label, value=e))
|
|
2346
|
+
elif e in airing_map:
|
|
2347
|
+
airing_at, _ = airing_map[e]
|
|
2348
|
+
if airing_at > time.time():
|
|
2349
|
+
time_str = _format_airing_time(airing_at)
|
|
2350
|
+
label = f"Airs {time_str}"
|
|
2351
|
+
else:
|
|
2352
|
+
label = f"Aired {_format_airing_date(airing_at)} — coming soon"
|
|
2353
|
+
ep_choices.append(questionary.Choice(
|
|
2354
|
+
title=ep_label,
|
|
2355
|
+
value=e,
|
|
2356
|
+
disabled=label,
|
|
2357
|
+
))
|
|
2358
|
+
else:
|
|
2359
|
+
ep_choices.append(questionary.Choice(
|
|
2360
|
+
title=f"Episode {e}",
|
|
2361
|
+
value=e,
|
|
2362
|
+
disabled="Not yet aired",
|
|
2363
|
+
))
|
|
1864
2364
|
ep_chosen = _select("Select an episode:", choices=ep_choices)
|
|
1865
2365
|
if not ep_chosen:
|
|
1866
2366
|
return
|
|
@@ -1876,6 +2376,8 @@ def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player
|
|
|
1876
2376
|
scraper = "anime"
|
|
1877
2377
|
|
|
1878
2378
|
show_dict = {"name": show_name, "id": 0}
|
|
2379
|
+
if allanime_id:
|
|
2380
|
+
show_dict["allanime_id"] = allanime_id
|
|
1879
2381
|
ep_name = f"S{season:02d}E{ep_start:02d}"
|
|
1880
2382
|
_play_with_menu(show_dict, season, ep_start, ep_end, ep_name, scraper, player, quality, info_only, download, translation=tt)
|
|
1881
2383
|
|
|
@@ -1946,6 +2448,7 @@ def update():
|
|
|
1946
2448
|
@click.pass_context
|
|
1947
2449
|
def schedule(ctx):
|
|
1948
2450
|
"""Show this week's trending TV shows and movies (requires TMDB key)."""
|
|
2451
|
+
_check_for_updates()
|
|
1949
2452
|
if ctx.invoked_subcommand is None:
|
|
1950
2453
|
_schedule_impl()
|
|
1951
2454
|
|
|
@@ -404,48 +404,90 @@ def _allanime_sources(session, show_id, ep_str, tt="sub"):
|
|
|
404
404
|
raise ScraperNoStreamError("allanime", "No stream data for this episode (source may be unavailable)")
|
|
405
405
|
return episode.get("sourceUrls", [])
|
|
406
406
|
|
|
407
|
+
def _normalize_quotes(text: str) -> str:
|
|
408
|
+
"""Replace fancy/curly quotes with straight ASCII equivalents."""
|
|
409
|
+
return text.replace("\u2018", "'").replace("\u2019", "'").replace("\u201c", '"').replace("\u201d", '"').replace("\u2013", "-").replace("\u2014", "--")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _simplify_query(text: str) -> str:
|
|
413
|
+
"""Strip common punctuation that allanime's search may choke on."""
|
|
414
|
+
import re
|
|
415
|
+
return re.sub(r"[!?.,;:()\[\]{}@#$%^&*+=~`<>|/\\]", "", text).strip()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _normalize_name(s):
|
|
419
|
+
"""Normalize Unicode characters commonly used in anime titles to ASCII."""
|
|
420
|
+
return s.replace("\u00d7", "x") # multiplication sign → x
|
|
421
|
+
|
|
407
422
|
def _find_show(session, name):
|
|
408
|
-
|
|
423
|
+
name = _normalize_quotes(name)
|
|
424
|
+
|
|
425
|
+
# Try the original query, then a simplified version without punctuation.
|
|
426
|
+
queries = [name]
|
|
427
|
+
simplified = _simplify_query(name)
|
|
428
|
+
if simplified != name:
|
|
429
|
+
queries.append(simplified)
|
|
430
|
+
|
|
431
|
+
results = None
|
|
432
|
+
for q in queries:
|
|
433
|
+
results = _allanime_search(session, q)
|
|
434
|
+
if results:
|
|
435
|
+
break
|
|
409
436
|
if not results:
|
|
410
437
|
return None
|
|
411
|
-
|
|
438
|
+
|
|
439
|
+
q = _normalize_name(name.lower().strip())
|
|
440
|
+
# Exact match first
|
|
412
441
|
for r in results:
|
|
413
442
|
rn = r.get("name", "").lower().strip()
|
|
414
443
|
if q == rn:
|
|
415
444
|
return r["_id"]
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
445
|
+
|
|
446
|
+
# Collect episode totals once
|
|
447
|
+
ep_info = {} # _id -> total
|
|
419
448
|
for r in results:
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
449
|
+
ep_data = r.get("availableEpisodes", {})
|
|
450
|
+
if isinstance(ep_data, dict):
|
|
451
|
+
total = sum(int(v) for v in ep_data.values() if isinstance(v, (int, str)) and str(v).isdigit())
|
|
452
|
+
else:
|
|
453
|
+
total = 0
|
|
454
|
+
ep_info[r["_id"]] = total
|
|
455
|
+
|
|
456
|
+
# Shortcut: if one result has dramatically more episodes than all others
|
|
457
|
+
# (5x the next best) and at least 50 total episodes, it's likely the main
|
|
458
|
+
# series under an abbreviated name (e.g. "1P" = One Piece, 1171 eps).
|
|
459
|
+
ranked = sorted(ep_info.items(), key=lambda x: -x[1])
|
|
460
|
+
if len(ranked) >= 2 and ranked[0][1] >= 50 and ranked[0][1] > ranked[1][1] * 5:
|
|
461
|
+
return ranked[0][0]
|
|
462
|
+
|
|
463
|
+
# Split query into significant words (len > 1)
|
|
464
|
+
q_words = frozenset(w for w in re.split(r"\W+", q) if len(w) > 1)
|
|
465
|
+
|
|
466
|
+
# Score each result by word overlap, episode count, name closeness
|
|
433
467
|
scored = []
|
|
468
|
+
scored_single = []
|
|
434
469
|
for r in results:
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
470
|
+
rn_raw = r.get("name", "").lower().strip()
|
|
471
|
+
rn = _normalize_name(rn_raw)
|
|
472
|
+
r_words = set(re.split(r"\W+", rn))
|
|
473
|
+
shared = q_words & r_words if q_words else frozenset()
|
|
474
|
+
word_score = len(shared)
|
|
475
|
+
ep_total = ep_info.get(r["_id"], 0)
|
|
476
|
+
cond_multi = len(q_words) >= 2 and word_score >= max(len(q_words) // 2, 2)
|
|
477
|
+
if cond_multi:
|
|
478
|
+
scored.append((word_score * 20 + min(ep_total, 9999), r["_id"]))
|
|
479
|
+
elif len(q_words) < 2 and (
|
|
480
|
+
q.replace(" ", "") in rn.replace(" ", "") or rn.replace(" ", "") in q.replace(" ", "")
|
|
481
|
+
):
|
|
482
|
+
scored_single.append((ep_total, r["_id"]))
|
|
483
|
+
|
|
484
|
+
if scored or scored_single:
|
|
485
|
+
if scored:
|
|
486
|
+
scored.sort(key=lambda x: -x[0])
|
|
487
|
+
return scored[0][1]
|
|
488
|
+
# Single-word path: return highest-episode-count match
|
|
489
|
+
scored_single.sort(key=lambda x: -x[0])
|
|
490
|
+
return scored_single[0][1]
|
|
449
491
|
|
|
450
492
|
def _extract_mp4upload(session, embed_url):
|
|
451
493
|
resp = session.get(embed_url, timeout=TIMEOUT)
|
|
@@ -551,7 +593,7 @@ class AnimeScraper(BaseScraper):
|
|
|
551
593
|
|
|
552
594
|
if _time.time() >= deadline:
|
|
553
595
|
raise ScraperNoStreamError(self.name, "Timeout searching allanime")
|
|
554
|
-
show_id = _find_show(session, show_name)
|
|
596
|
+
show_id = episode_info.get("allanime_id") or _find_show(session, show_name)
|
|
555
597
|
if not show_id:
|
|
556
598
|
raise ScraperNoStreamError(self.name, f'No allanime match for "{show_name}"')
|
|
557
599
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.40"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|