cinnamon-cli 0.2.37__tar.gz → 0.2.38__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.37 → cinnamon_cli-0.2.38}/PKG-INFO +10 -4
  2. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/README.md +6 -2
  3. cinnamon_cli-0.2.38/cinnamon/__init__.py +1 -0
  4. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/cli.py +311 -73
  5. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/config.py +1 -0
  6. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/history.py +22 -9
  7. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/player.py +209 -9
  8. cinnamon_cli-0.2.38/cinnamon/scrapers/_vidlink_crypto.py +163 -0
  9. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/anime.py +247 -10
  10. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/webstream.py +38 -18
  11. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/PKG-INFO +10 -4
  12. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/SOURCES.txt +1 -0
  13. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/requires.txt +3 -0
  14. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/pyproject.toml +5 -2
  15. cinnamon_cli-0.2.37/cinnamon/__init__.py +0 -1
  16. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/LICENSE +0 -0
  17. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/anilist.py +0 -0
  18. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/downloads.py +0 -0
  19. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/embedapi.py +0 -0
  20. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/errors.py +0 -0
  21. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/__init__.py +0 -0
  22. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/base.py +0 -0
  23. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/torrentio.py +0 -0
  24. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/scrapers/vidsrc.py +0 -0
  25. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/tmdb.py +0 -0
  26. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon/tui.py +0 -0
  27. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/dependency_links.txt +0 -0
  28. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/entry_points.txt +0 -0
  29. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/cinnamon_cli.egg-info/top_level.txt +0 -0
  30. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/setup.cfg +0 -0
  31. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream.py +0 -0
  32. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream2.py +0 -0
  33. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream3.py +0 -0
  34. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream4.py +0 -0
  35. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream5.py +0 -0
  36. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_directstream6.py +0 -0
  37. {cinnamon_cli-0.2.37 → cinnamon_cli-0.2.38}/tests/test_ui_smoke.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinnamon-cli
3
- Version: 0.2.37
4
- Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
3
+ Version: 0.2.38
4
+ Summary: Watch Movies, TV shows and anime from your terminal.
5
5
  Author: pizza-droid
6
6
  License: CC BY-NC 4.0
7
7
  Project-URL: Homepage, https://github.com/pizza-droid/cinnamon
@@ -23,6 +23,8 @@ Requires-Dist: questionary>=2
23
23
  Requires-Dist: pycryptodome>=3
24
24
  Provides-Extra: scrapers
25
25
  Requires-Dist: playwright>=1.60; extra == "scrapers"
26
+ Provides-Extra: vidlink
27
+ Requires-Dist: pynacl>=1.5; extra == "vidlink"
26
28
  Provides-Extra: download
27
29
  Requires-Dist: yt-dlp>=2024.1.0; extra == "download"
28
30
  Dynamic: license-file
@@ -117,8 +119,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
117
119
  | `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
118
120
  | `cinnamon play-url <url>` | Play any m3u8/mp4 link |
119
121
  | `cinnamon resume` | Continue an interrupted download |
120
- | `cinnamon history` | Show watch history and resume from the next unwatched episode |
121
- | `cinnamon history --clear` | Clear all watch history |
122
+ | `cinnamon history` | Show watch history |
123
+ | `cinnamon history <query>` | Show episodes played for a show and continue from next |
124
+ | `cinnamon history clear` | Clear all watch history |
125
+ | `cinnamon history toggle [on|off]` | Enable or disable history tracking |
126
+ | `cinnamon config history show` | Show whether history tracking is enabled |
127
+ | `cinnamon config history toggle [on|off]` | Enable or disable via config |
122
128
  | `cinnamon scrapers` | See available streaming sources |
123
129
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
124
130
  | `cinnamon update` | Check for and install the latest version |
@@ -88,8 +88,12 @@ cinnamon search "Breaking Bad" -d -e 1-5
88
88
  | `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
89
89
  | `cinnamon play-url <url>` | Play any m3u8/mp4 link |
90
90
  | `cinnamon resume` | Continue an interrupted download |
91
- | `cinnamon history` | Show watch history and resume from the next unwatched episode |
92
- | `cinnamon history --clear` | Clear all watch history |
91
+ | `cinnamon history` | Show watch history |
92
+ | `cinnamon history <query>` | Show episodes played for a show and continue from next |
93
+ | `cinnamon history clear` | Clear all watch history |
94
+ | `cinnamon history toggle [on|off]` | Enable or disable history tracking |
95
+ | `cinnamon config history show` | Show whether history tracking is enabled |
96
+ | `cinnamon config history toggle [on|off]` | Enable or disable via config |
93
97
  | `cinnamon scrapers` | See available streaming sources |
94
98
  | `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
95
99
  | `cinnamon update` | Check for and install the latest version |
@@ -0,0 +1 @@
1
+ __version__ = "0.2.38"
@@ -746,9 +746,7 @@ def cli(ctx, setup):
746
746
  ))
747
747
  console.print()
748
748
  console.print(" [dim]Search & watch will use an experimental proxy (no key needed).[/dim]")
749
- if Confirm.ask("Add a TMDB API key for better results?", default=False):
750
- ctx.invoke(setup_cmd)
751
- return
749
+ console.print(" [dim]Optional: set a TMDB key later with [cyan]cinnamon config set-api-key <key>[/cyan].[/dim]")
752
750
  theme = get_theme()
753
751
  console.clear()
754
752
  console.print(Panel.fit(
@@ -1008,11 +1006,21 @@ def watch(query, tmdb_id, media_type, season, ep_str, scraper, player, download,
1008
1006
  # Explicit id given: skip TMDB entirely (no API key needed) and let the
1009
1007
  # scraper resolve the stream straight from the id.
1010
1008
  if tmdb_id and media_type == "movie":
1011
- show = {"title": f"Movie {tmdb_id}", "id": tmdb_id}
1009
+ try:
1010
+ details = _get_tmdb().get_movie_details(tmdb_id)
1011
+ title = details.get("title", f"Movie {tmdb_id}")
1012
+ except Exception:
1013
+ title = f"Movie {tmdb_id}"
1014
+ show = {"title": title, "id": tmdb_id}
1012
1015
  _play_movie(show, scraper, player, quality, info_only, download)
1013
1016
  return
1014
1017
  if tmdb_id and media_type == "tv":
1015
- show = {"name": f"Show {tmdb_id}", "id": tmdb_id}
1018
+ try:
1019
+ details = _get_tmdb().get_tv_details(tmdb_id)
1020
+ name = details.get("name", f"Show {tmdb_id}")
1021
+ except Exception:
1022
+ name = f"Show {tmdb_id}"
1023
+ show = {"name": name, "id": tmdb_id}
1016
1024
  if season is not None and ep_start is not None:
1017
1025
  _play_with_menu(show, season, ep_start, ep_end, f"E{ep_start}", scraper, player, quality, info_only, download)
1018
1026
  else:
@@ -1158,27 +1166,27 @@ def _interactive_episode_picker(tmdb, show, scraper, player, quality, info_only,
1158
1166
 
1159
1167
  if ep_start is None:
1160
1168
  from .history import get_history as _get_history
1161
- last = _get_history(show_name)
1162
- if last and last.get("season") == season_num and last.get("episode"):
1163
- # Suggest the next unwatched episode (last watched + 1) when it
1164
- # exists in this season; otherwise fall back to the last watched.
1165
- resume_ep = last["episode"] + 1
1166
- if not any(
1167
- isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
1168
- for ep in episodes
1169
- ):
1170
- resume_ep = last["episode"]
1171
- try:
1172
- answer = Prompt.ask(
1173
- f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
1174
- default="y",
1175
- show_default=False,
1176
- ).strip().lower()
1177
- resume = answer in ("", "y", "yes")
1178
- except (EOFError, KeyboardInterrupt):
1179
- resume = True
1180
- if resume:
1181
- ep_start = resume_ep
1169
+ entries = _get_history(show_name)
1170
+ if entries:
1171
+ last = entries[0]
1172
+ if last.get("season") == season_num and last.get("episode"):
1173
+ resume_ep = last["episode"] + 1
1174
+ if not any(
1175
+ isinstance(ep.get("episode_number"), int) and ep["episode_number"] == resume_ep
1176
+ for ep in episodes
1177
+ ):
1178
+ resume_ep = last["episode"]
1179
+ try:
1180
+ answer = Prompt.ask(
1181
+ f" Resume from [bold]E{resume_ep:02d}[/bold]? [dim](y/n)[/dim]",
1182
+ default="y",
1183
+ show_default=False,
1184
+ ).strip().lower()
1185
+ resume = answer in ("", "y", "yes")
1186
+ except (EOFError, KeyboardInterrupt):
1187
+ resume = True
1188
+ if resume:
1189
+ ep_start = resume_ep
1182
1190
 
1183
1191
  if ep_start is not None:
1184
1192
  ep_name = f"S{season_num:02d}E{ep_start:02d}"
@@ -1467,6 +1475,34 @@ def config_default_player(player):
1467
1475
  _print_success(f"Default player set to [bold]{player}[/bold].")
1468
1476
 
1469
1477
 
1478
+ @config.group("history")
1479
+ def config_history():
1480
+ """View or change watch history tracking."""
1481
+
1482
+
1483
+ @config_history.command("show")
1484
+ def config_history_show():
1485
+ """Show whether watch history tracking is enabled."""
1486
+ cfg = load_config()
1487
+ enabled = cfg.get("history_enabled", True)
1488
+ status = "enabled" if enabled else "disabled"
1489
+ console.print(f" Watch history tracking: [bold]{status}[/bold]")
1490
+
1491
+
1492
+ @config_history.command("toggle")
1493
+ @click.argument("state", type=click.Choice(["on", "off"]), required=False)
1494
+ def config_history_toggle(state):
1495
+ """Enable or disable watch history tracking."""
1496
+ cfg = load_config()
1497
+ if state:
1498
+ cfg["history_enabled"] = (state == "on")
1499
+ else:
1500
+ cfg["history_enabled"] = not cfg.get("history_enabled", True)
1501
+ save_config(cfg)
1502
+ status = "enabled" if cfg["history_enabled"] else "disabled"
1503
+ _print_success(f"Watch history tracking {status}.")
1504
+
1505
+
1470
1506
  @config.group("scraper")
1471
1507
  def config_scraper():
1472
1508
  """Configure a specific scraper."""
@@ -1610,26 +1646,51 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1610
1646
 
1611
1647
  show_name = _title(show)
1612
1648
  theme = get_theme()
1649
+
1650
+ if show.get("format") == "MOVIE":
1651
+ console.print(f" [{theme['info']}]Anime movie — playing directly[/{theme['info']}]")
1652
+ show_dict = {"name": show_name, "id": 0}
1653
+ proc = _resolve_and_play(show_dict, None, 1, show_name, "anime", player, quality, info_only, download)
1654
+ if proc is None and not info_only and not download:
1655
+ # allanime has no playable source for this movie (e.g. its obfuscated
1656
+ # streamer link can't be decoded, or it only offers iframe embeds with
1657
+ # no direct mp4). Fall back to the webstream scraper via the movie's
1658
+ # TMDB id so the film still plays.
1659
+ tmdb_id = _anime_movie_tmdb_id(show_name, year=(show.get("startDate") or {}).get("year"))
1660
+ if tmdb_id:
1661
+ _print_info(f"allanime has no direct stream — using [bold]webstream[/bold] for the movie.")
1662
+ proc = _resolve_and_play(
1663
+ {"name": show_name, "id": tmdb_id}, None, None, show_name,
1664
+ "webstream", player, quality, info_only, download,
1665
+ )
1666
+ if proc is not None and not info_only and not download:
1667
+ try:
1668
+ proc.wait()
1669
+ except AttributeError:
1670
+ pass
1671
+ return
1672
+
1613
1673
  console.clear()
1614
1674
  console.print(Panel(f"[bold {theme['accent']}]{show_name}[/bold {theme['accent']}]", border_style=theme["border"]))
1615
1675
 
1616
1676
  from .history import get_history as _get_history
1617
1677
  if not ep_str:
1618
- last = _get_history(show_name)
1619
- if last and last.get("episode"):
1620
- # Suggest the next unwatched episode (last watched + 1).
1621
- resume_ep = last["episode"] + 1
1622
- try:
1623
- answer = Prompt.ask(
1624
- f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
1625
- default="y",
1626
- show_default=False,
1627
- ).strip().lower()
1628
- resume = answer in ("", "y", "yes")
1629
- except (EOFError, KeyboardInterrupt):
1630
- resume = True
1631
- if resume:
1632
- ep_str = str(resume_ep)
1678
+ entries = _get_history(show_name)
1679
+ if entries:
1680
+ last = entries[0]
1681
+ if last.get("episode"):
1682
+ resume_ep = last["episode"] + 1
1683
+ try:
1684
+ answer = Prompt.ask(
1685
+ f" Resume from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
1686
+ default="y",
1687
+ show_default=False,
1688
+ ).strip().lower()
1689
+ resume = answer in ("", "y", "yes")
1690
+ except (EOFError, KeyboardInterrupt):
1691
+ resume = True
1692
+ if resume:
1693
+ ep_str = str(resume_ep)
1633
1694
 
1634
1695
  from .scrapers.anime import _find_show, _allanime_episodes
1635
1696
  import requests as _req
@@ -1654,6 +1715,85 @@ def anime(query, season, ep_str, download, player, quality, info_only, prefer_su
1654
1715
  _run_anime_flow(show_name, episodes_detail, season, ep_str, player, quality, info_only, download, translation=translation)
1655
1716
 
1656
1717
 
1718
+ def _anime_movie_tmdb_id(show_name, year=None):
1719
+ """Best-effort lookup of a movie's TMDB id by title, for webstream fallback.
1720
+
1721
+ allanime only carries the anime's name, not its TMDB id, so we search TMDB
1722
+ for the closest movie match and return its id. When the AniList year is
1723
+ known it is used as a tie-breaker.
1724
+
1725
+ The keyless 2embed proxy (EmbedClient) returns the same real TMDB ids that
1726
+ vixsrc/vidlink use, so it is safe to fall back to it when no TMDB API key
1727
+ is configured — the returned id is genuine, not a guessed/renamed one. The
1728
+ final safety net is webstream itself: it verifies the id against vixsrc's
1729
+ playlist and falls back to vidlink, raising ScraperNoStreamError if neither
1730
+ serves the film. We additionally keep the vixsrc API check below as a
1731
+ pre-filter so we don't hand the player a dead id."""
1732
+ try:
1733
+ tmdb = _get_tmdb()
1734
+ results = tmdb.search_movie(show_name).get("results", [])
1735
+ except Exception:
1736
+ results = []
1737
+ if not results:
1738
+ # Try a normalized title (drop collection suffixes) — vixsrc indexes the
1739
+ # canonical TMDB entry, not the "...: The Movie" / " Movie" rename.
1740
+ norm = _normalize_movie_title(show_name)
1741
+ if norm and norm.lower() != show_name.lower():
1742
+ try:
1743
+ results = tmdb.search_movie(norm).get("results", [])
1744
+ except Exception:
1745
+ results = []
1746
+ if not results:
1747
+ return None
1748
+ q = show_name.lower().strip()
1749
+ nq = _normalize_movie_title(show_name).lower()
1750
+ scored = []
1751
+ for r in results:
1752
+ title = (r.get("title") or r.get("name") or "").lower().strip()
1753
+ if title != q and title != nq and q not in title and nq not in title and title not in q:
1754
+ continue
1755
+ score = 0
1756
+ if title in (q, nq):
1757
+ score = 3
1758
+ elif title.startswith(q) or q.startswith(title):
1759
+ score = 2
1760
+ elif q in title or nq in title or title in q:
1761
+ score = 1
1762
+ ryear = (r.get("release_date") or "")[:4]
1763
+ if year and ryear and str(year) == str(ryear):
1764
+ score += 2
1765
+ scored.append((score, r.get("popularity") or 0, r))
1766
+ if not scored:
1767
+ return None
1768
+ scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
1769
+ # Return the best-scored TMDB id. Do NOT pre-filter against vixsrc's API:
1770
+ # vixsrc's movie id space diverges from TMDB for many anime films (the top
1771
+ # pick may 404 on vixsrc yet play fine via vidlink), and webstream already
1772
+ # verifies the id end-to-end (vixsrc playlist + vidlink fallback) and raises
1773
+ # ScraperNoStreamError if neither source serves it. Letting webstream do the
1774
+ # verification keeps the correct film instead of falling back to a wrong one
1775
+ # that merely happens to be indexed by vixsrc.
1776
+ for _, _, r in scored:
1777
+ mid = r.get("id")
1778
+ if mid:
1779
+ return mid
1780
+ return None
1781
+
1782
+
1783
+ def _normalize_movie_title(name):
1784
+ """Strip collection/rename suffixes so the canonical TMDB id is found.
1785
+
1786
+ e.g. "A Silent Voice: The Movie" -> "A Silent Voice". vixsrc indexes the
1787
+ base title, not the renamed TMDB entry."""
1788
+ import re
1789
+ n = (name or "").strip()
1790
+ n = re.sub(r"\s*:\s*the\s+movie$", "", n, flags=re.I)
1791
+ n = re.sub(r"\s*:\s*the\s+motion\s+picture$", "", n, flags=re.I)
1792
+ n = re.sub(r"\s*:\s*movie$", "", n, flags=re.I)
1793
+ n = re.sub(r"\s+movie$", "", n, flags=re.I)
1794
+ return n
1795
+
1796
+
1657
1797
  def _run_anime_flow(show_name, episodes_detail, season=None, ep_str=None, player=None, quality=None, info_only=False, download=False, translation=None):
1658
1798
  """Drive the episode picker from allanime's *available* episodes (not TMDB's
1659
1799
  full list), so we never offer an episode the anime source doesn't have."""
@@ -1803,39 +1943,111 @@ def update():
1803
1943
  # ---------------------------------------------------------------------------
1804
1944
 
1805
1945
 
1806
- @cli.command()
1946
+ class HistoryGroup(click.Group):
1947
+ def resolve_command(self, ctx, args):
1948
+ if not args:
1949
+ return super().resolve_command(ctx, args)
1950
+ parent = super()
1951
+ cmd = parent.get_command(ctx, args[0])
1952
+ if cmd is not None:
1953
+ return super().resolve_command(ctx, args)
1954
+ cmd = parent.get_command(ctx, "list")
1955
+ return "list", cmd, args
1956
+
1957
+
1958
+ @cli.group(cls=HistoryGroup, invoke_without_command=True)
1959
+ @click.pass_context
1960
+ def history(ctx):
1961
+ """View or manage watch history."""
1962
+ if ctx.invoked_subcommand is None:
1963
+ ctx.invoke(history_list, query=None)
1964
+
1965
+
1966
+ @history.command("list")
1807
1967
  @click.argument("query", nargs=-1, required=False)
1808
- @click.option("--clear", is_flag=True, help="Clear all watch history")
1809
1968
  @_handle_errors
1810
- def history(query, clear):
1969
+ def history_list(query):
1811
1970
  """Show watch history and resume from last episode."""
1812
- from .history import clear_history, get_history, list_history
1813
-
1814
- if clear:
1815
- clear_history()
1816
- _print_success("Watch history cleared.")
1817
- return
1971
+ from .history import get_history, list_history
1818
1972
 
1819
1973
  q = " ".join(query) if query else None
1820
1974
  if q:
1821
- entry = get_history(q)
1822
- if not entry:
1823
- _print_info(f"No history found for \"{q}\".")
1824
- return
1825
- _print_info(f"Last watched [bold]{q}[/bold]: S{entry.get('season', 1)}E{entry.get('episode')}")
1826
- _play_with_menu(
1827
- {"name": q, "id": 0},
1828
- entry.get("season", 1),
1829
- entry.get("episode"),
1830
- None,
1831
- "",
1832
- entry.get("scraper") or "anime",
1833
- None,
1834
- entry.get("quality"),
1835
- False,
1836
- False,
1837
- translation=entry.get("translation"),
1838
- )
1975
+ entries = get_history(q)
1976
+ if not entries:
1977
+ tmdb = _get_tmdb()
1978
+ results = tmdb.search_tv(q).get("results", [])
1979
+ if not results:
1980
+ results = tmdb.search_movie(q).get("results", [])
1981
+ if results:
1982
+ title_key = "name" if results[0].get("name") else "title"
1983
+ matched = results[0].get(title_key, "")
1984
+ if matched:
1985
+ entries = get_history(matched)
1986
+ if not entries:
1987
+ from .anilist import search_anime
1988
+ anilist_results = search_anime(q)
1989
+ if anilist_results:
1990
+ def _title(m):
1991
+ return m.get("title", {}).get("english") or m.get("title", {}).get("romaji") or m.get("title", {}).get("native", "?")
1992
+ matched = _title(anilist_results[0])
1993
+ if matched:
1994
+ entries = get_history(matched)
1995
+ if not entries:
1996
+ _print_info(f"No history found for \"{q}\".")
1997
+ return
1998
+
1999
+ table = Table(border_style="dim")
2000
+ table.add_column("#", style="cyan")
2001
+ table.add_column("Episode", style="green")
2002
+ table.add_column("Date", style="yellow")
2003
+ import datetime
2004
+ for i, e in enumerate(entries, 1):
2005
+ ts = e.get("timestamp", 0)
2006
+ date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
2007
+ season = e.get("season")
2008
+ episode = e.get("episode")
2009
+ if season is not None and episode is not None:
2010
+ ep_label = f"S{season}E{episode}"
2011
+ else:
2012
+ ep_label = "[dim]—[/dim]"
2013
+ table.add_row(str(i), ep_label, date_label)
2014
+
2015
+ console.print(table)
2016
+
2017
+ last = entries[0]
2018
+ resume_ep = (last.get("episode") or 0) + 1
2019
+ try:
2020
+ answer = Prompt.ask(
2021
+ f" Continue from [bold]S{last.get('season', 1)}E{resume_ep}[/bold]? [dim](y/n)[/dim]",
2022
+ default="y",
2023
+ show_default=False,
2024
+ ).strip().lower()
2025
+ if answer in ("", "y", "yes"):
2026
+ show_name = q
2027
+ show_id = 0
2028
+ tmdb = _get_tmdb()
2029
+ try:
2030
+ results = tmdb.search_tv(q).get("results", [])
2031
+ if results:
2032
+ show_name = results[0].get("name", q)
2033
+ show_id = results[0]["id"]
2034
+ except Exception:
2035
+ pass
2036
+ _play_with_menu(
2037
+ {"name": show_name, "id": show_id},
2038
+ last.get("season", 1),
2039
+ resume_ep,
2040
+ None,
2041
+ "",
2042
+ last.get("scraper") or load_config().get("default_scraper", "webstream"),
2043
+ None,
2044
+ last.get("quality"),
2045
+ False,
2046
+ False,
2047
+ translation=last.get("translation"),
2048
+ )
2049
+ except (EOFError, KeyboardInterrupt):
2050
+ pass
1839
2051
  return
1840
2052
 
1841
2053
  entries = list_history()
@@ -1849,16 +2061,42 @@ def history(query, clear):
1849
2061
  table.add_column("Last watched", style="yellow")
1850
2062
 
1851
2063
  import datetime
1852
- for name, e in entries:
2064
+ for e in entries:
1853
2065
  ts = e.get("timestamp", 0)
1854
2066
  date_label = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else ""
1855
- table.add_row(name, f"S{e.get('season', 1)}E{e.get('episode')}", date_label)
2067
+ season = e.get("season")
2068
+ episode = e.get("episode")
2069
+ if season is not None and episode is not None:
2070
+ ep_label = f"S{season}E{episode}"
2071
+ else:
2072
+ ep_label = "[dim]—[/dim]"
2073
+ table.add_row(e.get("title", "?"), ep_label, date_label)
1856
2074
 
1857
2075
  console.print(table)
1858
2076
 
1859
- if _prompt(" Clear all history?", default="n").strip().lower() in ("y", "yes"):
1860
- clear_history()
1861
- _print_success("Watch history cleared.")
2077
+
2078
+ @history.command("clear")
2079
+ @_handle_errors
2080
+ def history_clear():
2081
+ """Clear all watch history."""
2082
+ from .history import clear_history
2083
+ clear_history()
2084
+ _print_success("Watch history cleared.")
2085
+
2086
+
2087
+ @history.command("toggle")
2088
+ @click.argument("state", type=click.Choice(["on", "off"]), required=False)
2089
+ @_handle_errors
2090
+ def history_toggle(state):
2091
+ """Enable or disable watch history tracking."""
2092
+ cfg = load_config()
2093
+ if state:
2094
+ cfg["history_enabled"] = (state == "on")
2095
+ else:
2096
+ cfg["history_enabled"] = not cfg.get("history_enabled", True)
2097
+ save_config(cfg)
2098
+ status = "enabled" if cfg["history_enabled"] else "disabled"
2099
+ _print_success(f"Watch history tracking {status}.")
1862
2100
 
1863
2101
 
1864
2102
  # ---------------------------------------------------------------------------
@@ -27,6 +27,7 @@ DEFAULTS = {
27
27
  "default_scraper": "webstream",
28
28
  "default_player": "auto",
29
29
  "theme": "cinnamon",
30
+ "history_enabled": True,
30
31
  "scrapers": {},
31
32
  }
32
33
 
@@ -25,9 +25,17 @@ def _db():
25
25
  def _load():
26
26
  try:
27
27
  with open(_db(), encoding="utf-8") as f:
28
- return json.load(f)
28
+ data = json.load(f)
29
29
  except (FileNotFoundError, json.JSONDecodeError):
30
- return {}
30
+ return []
31
+ if isinstance(data, dict):
32
+ entries = []
33
+ for title, entry in data.items():
34
+ entry["title"] = title
35
+ entries.append(entry)
36
+ _save(entries)
37
+ return entries
38
+ return data
31
39
 
32
40
 
33
41
  def _save(h):
@@ -36,31 +44,36 @@ def _save(h):
36
44
 
37
45
 
38
46
  def set_history(title, season, episode, scraper=None, translation=None, quality=None):
47
+ from .config import load_config
48
+ if not load_config().get("history_enabled", True):
49
+ return
39
50
  h = _load()
40
- h[title] = {
51
+ h.append({
52
+ "title": title,
41
53
  "season": season,
42
54
  "episode": episode,
43
55
  "timestamp": time.time(),
44
56
  "scraper": scraper,
45
57
  "translation": translation,
46
58
  "quality": quality,
47
- }
59
+ })
48
60
  _save(h)
49
61
 
50
62
 
51
63
  def get_history(title):
52
- return _load().get(title)
64
+ h = _load()
65
+ return [e for e in h if e.get("title") == title]
53
66
 
54
67
 
55
68
  def list_history():
56
69
  h = _load()
57
- return sorted(h.items(), key=lambda kv: kv[1].get("timestamp", 0), reverse=True)
70
+ return sorted(h, key=lambda e: e.get("timestamp", 0), reverse=True)
58
71
 
59
72
 
60
73
  def clear_history(title=None):
61
74
  h = _load()
62
75
  if title:
63
- h.pop(title, None)
76
+ h = [e for e in h if e.get("title") != title]
64
77
  else:
65
- h.clear()
66
- _save(h)
78
+ h = []
79
+ _save(h)