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.
Files changed (37) hide show
  1. {cinnamon_cli-0.2.40/cinnamon_cli.egg-info → cinnamon_cli-0.3.0}/PKG-INFO +1 -1
  2. cinnamon_cli-0.3.0/cinnamon/__init__.py +1 -0
  3. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/anilist.py +33 -0
  4. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/cli.py +625 -122
  5. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/anime.py +75 -33
  6. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0/cinnamon_cli.egg-info}/PKG-INFO +1 -1
  7. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/pyproject.toml +1 -1
  8. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_ui_smoke.py +1 -1
  9. cinnamon_cli-0.2.40/cinnamon/__init__.py +0 -1
  10. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/LICENSE +0 -0
  11. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/README.md +0 -0
  12. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/config.py +0 -0
  13. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/downloads.py +0 -0
  14. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/embedapi.py +0 -0
  15. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/errors.py +0 -0
  16. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/history.py +0 -0
  17. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/player.py +0 -0
  18. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/__init__.py +0 -0
  19. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/_vidlink_crypto.py +0 -0
  20. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/base.py +0 -0
  21. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/torrentio.py +0 -0
  22. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/vidsrc.py +0 -0
  23. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/scrapers/webstream.py +0 -0
  24. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/tmdb.py +0 -0
  25. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon/tui.py +0 -0
  26. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/SOURCES.txt +0 -0
  27. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  28. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  29. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/requires.txt +0 -0
  30. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/cinnamon_cli.egg-info/top_level.txt +0 -0
  31. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/setup.cfg +0 -0
  32. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream.py +0 -0
  33. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream2.py +0 -0
  34. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream3.py +0 -0
  35. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream4.py +0 -0
  36. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream5.py +0 -0
  37. {cinnamon_cli-0.2.40 → cinnamon_cli-0.3.0}/tests/test_directstream6.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.40
3
+ Version: 0.3.0
4
4
  Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.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
- return TMDBClient()
249
- except MissingAPIKey:
250
- return EmbedClient()
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 = int(parts[0])
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 = int(parts[1])
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
- _check_for_updates()
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
- if media_type == "tv":
903
- combined = [dict(r, _media_type="tv") for r in tmdb.search_tv(query_str).get("results", [])]
904
- elif media_type == "movie":
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
- mtype = show.get("_media_type", "tv")
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 type given: search both TV and movies, then dispatch by what is picked.
1051
- if tmdb_id:
1052
- # Ambiguous id without a type — try TV first, fall back to movie.
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
- if scraper is None and _is_anime(show):
1091
- scraper = "anime"
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
- _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only, ep_start=ep_start, ep_end=ep_end)
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
- ep_chosen = _select(
1179
- f"Season {season_num} - Select an episode:",
1180
- choices=ep_choices,
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
- _print_info(f"No anime found for \"{query_str}\".")
1596
- return
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 _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False, translation=None):
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
- if tt != "sub":
1846
- console.print(f" [dim]Using {tt}[/dim]")
2291
+ console.print(f" [dim]Only {tt} available[/dim]")
1847
2292
 
1848
- episodes = [int(e) for e in season_data[tt]]
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 episodes:
1854
- # The suggested next episode may not exist yet (e.g. unaired) —
1855
- # fall back to the last available episode instead of hard-erroring.
1856
- fallback = max_ep
1857
- _print_info(f"Episode {ep_start} not available yet — using E{fallback}.")
1858
- ep_start = fallback
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 = [questionary.Choice(title=f"Episode {e}", value=e) for e in sorted(episodes)]
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
- results = _allanime_search(session, name)
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
- q = name.lower().strip()
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
- q_words = frozenset(w for w in re.split(r"\W+", q) if len(w) > 2)
417
- best_sub = None
418
- best_sub_diff = float("inf")
445
+
446
+ # Collect episode totals once
447
+ ep_info = {} # _id -> total
419
448
  for r in results:
420
- rn = r.get("name", "").lower().strip()
421
- r_words = set(re.split(r"\W+", rn))
422
- shared = q_words & r_words
423
- if len(shared) >= max(len(q_words) * 0.5, 1) or rn in q or q in rn:
424
- diff = abs(len(rn) - len(q))
425
- if diff < best_sub_diff:
426
- best_sub_diff = diff
427
- best_sub = r["_id"]
428
- if best_sub:
429
- return best_sub
430
-
431
- q_has_movie = bool(re.search(r"\b(?:movie|film)\b", q))
432
- q_words = frozenset(w for w in re.split(r"[^\w]+", q) if len(w) > 2)
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
- rn = r.get("name", "").lower().strip()
436
- r_words = set(re.split(r"[^\w]+", rn))
437
- overlap = len(q_words & r_words)
438
- all_match = q_words <= r_words
439
- has_suffix = bool(re.search(r"\b(?:part|season|special|cour|ova|movie|film)\s*\d*\b", rn))
440
- if q_has_movie:
441
- suffix_bonus = 1 if has_suffix else 0
442
- else:
443
- suffix_bonus = 0 if has_suffix else 1
444
- extra_words = len(r_words - q_words)
445
- name_len = len(rn)
446
- scored.append((overlap, all_match, suffix_bonus, extra_words, name_len, r["_id"]))
447
- scored.sort(key=lambda x: (-x[0], -x[1], -x[2], x[3], x[4]))
448
- return scored[0][5]
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.40
3
+ Version: 0.3.0
4
4
  Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cinnamon-cli"
7
- version = "0.2.40"
7
+ version = "0.3.0"
8
8
  description = "Watch Movies, TV shows and anime from your terminal."
9
9
  license = { text = "CC BY-NC 4.0" }
10
10
  readme = "README.md"
@@ -206,7 +206,7 @@ def test_setup_wizard():
206
206
  import webbrowser
207
207
  webbrowser.open = lambda *a, **k: True
208
208
  c._setup_api_key = lambda: "fakekey"
209
- _run("setup", ["setup"], "")
209
+ _run("setup", ["setup"], "y\n")
210
210
 
211
211
 
212
212
  def test_tui_command_dead():
@@ -1 +0,0 @@
1
- __version__ = "0.2.40"
File without changes
File without changes
File without changes