cinnamon-cli 0.2.19__py3-none-any.whl
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/__init__.py +1 -0
- cinnamon/anilist.py +33 -0
- cinnamon/cli.py +1822 -0
- cinnamon/config.py +121 -0
- cinnamon/downloads.py +83 -0
- cinnamon/errors.py +145 -0
- cinnamon/history.py +66 -0
- cinnamon/player.py +550 -0
- cinnamon/scrapers/__init__.py +97 -0
- cinnamon/scrapers/anime.py +196 -0
- cinnamon/scrapers/base.py +34 -0
- cinnamon/scrapers/torrentio.py +123 -0
- cinnamon/scrapers/vidsrc.py +131 -0
- cinnamon/scrapers/webstream.py +282 -0
- cinnamon/tmdb.py +83 -0
- cinnamon/tui.py +543 -0
- cinnamon_cli-0.2.19.dist-info/METADATA +166 -0
- cinnamon_cli-0.2.19.dist-info/RECORD +22 -0
- cinnamon_cli-0.2.19.dist-info/WHEEL +5 -0
- cinnamon_cli-0.2.19.dist-info/entry_points.txt +2 -0
- cinnamon_cli-0.2.19.dist-info/licenses/LICENSE +38 -0
- cinnamon_cli-0.2.19.dist-info/top_level.txt +1 -0
cinnamon/tui.py
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
from textual import on
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.css.query import NoMatches
|
|
11
|
+
from textual.screen import ModalScreen, Screen
|
|
12
|
+
from textual.widgets import (
|
|
13
|
+
Button,
|
|
14
|
+
Footer,
|
|
15
|
+
Header,
|
|
16
|
+
Input,
|
|
17
|
+
Label,
|
|
18
|
+
ListItem,
|
|
19
|
+
ListView,
|
|
20
|
+
LoadingIndicator,
|
|
21
|
+
Static,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .config import load_config
|
|
25
|
+
from .errors import PlayerNotFoundError, ScraperError, TMDBNotFoundError
|
|
26
|
+
from .player import play
|
|
27
|
+
from .scrapers import get_scraper
|
|
28
|
+
from .tmdb import TMDBClient
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# CSS — opencode-inspired dark theme
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
CSS = """
|
|
35
|
+
Screen {
|
|
36
|
+
background: #0f0f1a;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Header {
|
|
40
|
+
background: #1a1a2e;
|
|
41
|
+
color: #7ec8e3;
|
|
42
|
+
text-style: bold;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Footer {
|
|
46
|
+
background: #1a1a2e;
|
|
47
|
+
color: #555577;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Footer > .footer--key {
|
|
51
|
+
color: #7ec8e3;
|
|
52
|
+
text-style: bold;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Footer > .footer--description {
|
|
56
|
+
color: #555577;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#search-input {
|
|
60
|
+
background: #1a1a2e;
|
|
61
|
+
color: #e0e0f0;
|
|
62
|
+
border: tall #2a2a4e;
|
|
63
|
+
padding: 0 2;
|
|
64
|
+
margin: 1 2 0 2;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#search-input:focus {
|
|
68
|
+
border: tall #7ec8e3;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#results-list {
|
|
72
|
+
background: #0f0f1a;
|
|
73
|
+
border: none;
|
|
74
|
+
margin: 0 2;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#results-list:focus {
|
|
78
|
+
border: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#results-list > ListItem {
|
|
82
|
+
background: #0f0f1a;
|
|
83
|
+
padding: 1 2;
|
|
84
|
+
margin: 0 0 1 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#results-list > ListItem:hover {
|
|
88
|
+
background: #1a1a3e;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#results-list > ListItem.--highlighted {
|
|
92
|
+
background: #1a2a4e;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#results-list > ListItem.--highlighted > Label {
|
|
96
|
+
color: #e0e0f0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#results-hint {
|
|
100
|
+
height: 1;
|
|
101
|
+
margin: 0 2;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#results-hint Label {
|
|
105
|
+
color: #444466;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ── show header ─────────────────────────────────── */
|
|
109
|
+
|
|
110
|
+
#show-header {
|
|
111
|
+
background: #1a1a2e;
|
|
112
|
+
height: 3;
|
|
113
|
+
margin: 1 2 0 2;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#show-title {
|
|
117
|
+
color: #e0e0f0;
|
|
118
|
+
text-style: bold;
|
|
119
|
+
padding: 1 2;
|
|
120
|
+
width: 1fr;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#show-year {
|
|
124
|
+
color: #555577;
|
|
125
|
+
padding: 1 2;
|
|
126
|
+
width: auto;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── season bar ──────────────────────────────────── */
|
|
130
|
+
|
|
131
|
+
#season-bar {
|
|
132
|
+
background: #0f0f1a;
|
|
133
|
+
height: 3;
|
|
134
|
+
margin: 0 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#season-bar Button {
|
|
138
|
+
background: #2a2a4e;
|
|
139
|
+
color: #8888aa;
|
|
140
|
+
border: none;
|
|
141
|
+
margin: 1 1 1 0;
|
|
142
|
+
padding: 0 2;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#season-bar Button:focus {
|
|
146
|
+
background: #3a3a6e;
|
|
147
|
+
color: #e0e0f0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#season-bar Button.-active {
|
|
151
|
+
background: #7ec8e3;
|
|
152
|
+
color: #0f0f1a;
|
|
153
|
+
text-style: bold;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ── episode list ────────────────────────────────── */
|
|
157
|
+
|
|
158
|
+
#episode-list {
|
|
159
|
+
background: #0f0f1a;
|
|
160
|
+
border: none;
|
|
161
|
+
margin: 0 2;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#episode-list > ListItem {
|
|
165
|
+
background: #0f0f1a;
|
|
166
|
+
padding: 1 2;
|
|
167
|
+
margin: 0 0 1 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#episode-list > ListItem:hover {
|
|
171
|
+
background: #1a1a3e;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#episode-list > ListItem.--highlighted {
|
|
175
|
+
background: #1a2a4e;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#episode-list > ListItem.--highlighted > Label {
|
|
179
|
+
color: #e0e0f0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── feedback panels ─────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
Static.error {
|
|
185
|
+
color: #ff6b6b;
|
|
186
|
+
background: #2a1a1a;
|
|
187
|
+
padding: 1 2;
|
|
188
|
+
margin: 1 2;
|
|
189
|
+
border: tall #ff6b6b;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
Static.success {
|
|
193
|
+
color: #69db7c;
|
|
194
|
+
padding: 1 2;
|
|
195
|
+
margin: 1 2;
|
|
196
|
+
border: tall #69db7c;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
Static.info {
|
|
200
|
+
color: #7ec8e3;
|
|
201
|
+
padding: 1 2;
|
|
202
|
+
margin: 1 2;
|
|
203
|
+
border: tall #2a2a4e;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
LoadingIndicator {
|
|
207
|
+
background: #0f0f1a;
|
|
208
|
+
color: #7ec8e3;
|
|
209
|
+
}
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# TMDB helpers (called from workers)
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _search_tmdb(query: str) -> list[dict]:
|
|
218
|
+
try:
|
|
219
|
+
return TMDBClient().search_tv(query).get("results", [])
|
|
220
|
+
except Exception:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _get_show_details(show_id: int) -> dict:
|
|
225
|
+
try:
|
|
226
|
+
return TMDBClient().get_tv_details(show_id)
|
|
227
|
+
except Exception:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _get_episodes(show_id: int, season: int) -> list[dict]:
|
|
232
|
+
try:
|
|
233
|
+
data = TMDBClient().get_season_details(show_id, season)
|
|
234
|
+
return data.get("episodes", [])
|
|
235
|
+
except Exception:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _resolve(show: str, show_id: int, season: int, episode: int) -> tuple[str, str]:
|
|
240
|
+
cfg = load_config()
|
|
241
|
+
name = cfg.get("default_scraper", "example")
|
|
242
|
+
s = get_scraper(name)
|
|
243
|
+
if not s:
|
|
244
|
+
return "", ""
|
|
245
|
+
try:
|
|
246
|
+
r = s.resolve({"show": show, "tv_id": show_id, "season": season, "episode": episode})
|
|
247
|
+
if r:
|
|
248
|
+
return r.m3u8_url, r.title
|
|
249
|
+
except ScraperError:
|
|
250
|
+
pass
|
|
251
|
+
return "", ""
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Search screen
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class SearchScreen(Screen):
|
|
260
|
+
BINDINGS = [
|
|
261
|
+
Binding("escape", "app.quit", "Quit"),
|
|
262
|
+
Binding("ctrl+c", "app.quit", "Quit"),
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
def compose(self) -> ComposeResult:
|
|
266
|
+
yield Header(show_clock=True)
|
|
267
|
+
yield Input(placeholder="Search for a TV show ...", id="search-input")
|
|
268
|
+
yield Horizontal(
|
|
269
|
+
Label("type to search | arrow keys to navigate | enter to select", id="results-hint"),
|
|
270
|
+
)
|
|
271
|
+
yield ListView(id="results-list")
|
|
272
|
+
yield Footer()
|
|
273
|
+
|
|
274
|
+
def on_mount(self) -> None:
|
|
275
|
+
self.query_one("#search-input", Input).focus()
|
|
276
|
+
|
|
277
|
+
@on(Input.Changed, "#search-input")
|
|
278
|
+
async def on_search(self, event: Input.Changed) -> None:
|
|
279
|
+
q = event.value.strip()
|
|
280
|
+
lst = self.query_one("#results-list", ListView)
|
|
281
|
+
lst.clear()
|
|
282
|
+
if len(q) < 2:
|
|
283
|
+
return
|
|
284
|
+
lst.loading = True
|
|
285
|
+
worker = self.run_worker(lambda: _search_tmdb(q), name="tmdb-search", thread=True)
|
|
286
|
+
results = await worker.wait()
|
|
287
|
+
lst.loading = False
|
|
288
|
+
if not results:
|
|
289
|
+
lst.append(ListItem(Label("[dim]No results[/dim]")))
|
|
290
|
+
return
|
|
291
|
+
for s in results[:20]:
|
|
292
|
+
name = s.get("name", "?")
|
|
293
|
+
year = (s.get("first_air_date") or "?")[:4]
|
|
294
|
+
lst.append(ListItem(Label(f"{name} [dim]({year})[/dim]")))
|
|
295
|
+
lst.index = 0
|
|
296
|
+
|
|
297
|
+
@on(ListView.Selected, "#results-list")
|
|
298
|
+
def on_select(self, event: ListView.Selected) -> None:
|
|
299
|
+
idx = self.query_one("#results-list", ListView).index
|
|
300
|
+
if idx is None:
|
|
301
|
+
return
|
|
302
|
+
results = self._last_results()
|
|
303
|
+
if idx < len(results):
|
|
304
|
+
show = results[idx]
|
|
305
|
+
self.app.push_screen(EpisodeScreen(show["id"], show.get("name", "?")))
|
|
306
|
+
|
|
307
|
+
def _last_results(self) -> list[dict]:
|
|
308
|
+
input_w = self.query_one("#search-input", Input)
|
|
309
|
+
q = input_w.value.strip()
|
|
310
|
+
if len(q) >= 2:
|
|
311
|
+
return _search_tmdb(q)
|
|
312
|
+
return []
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Episode screen
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class EpisodeScreen(Screen):
|
|
321
|
+
BINDINGS = [
|
|
322
|
+
Binding("escape", "go_back", "Back"),
|
|
323
|
+
Binding("ctrl+c", "app.quit", "Quit"),
|
|
324
|
+
Binding("left", "prev_season", "◀ Season"),
|
|
325
|
+
Binding("right", "next_season", "Season ▶"),
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
def __init__(self, show_id: int, show_name: str) -> None:
|
|
329
|
+
super().__init__()
|
|
330
|
+
self.show_id = show_id
|
|
331
|
+
self.show_name = show_name
|
|
332
|
+
self.seasons: list[int] = []
|
|
333
|
+
self.current_season = 1
|
|
334
|
+
|
|
335
|
+
def compose(self) -> ComposeResult:
|
|
336
|
+
yield Header(show_clock=True)
|
|
337
|
+
yield Horizontal(
|
|
338
|
+
Label(self.show_name, id="show-title"),
|
|
339
|
+
Label("", id="show-year"),
|
|
340
|
+
id="show-header",
|
|
341
|
+
)
|
|
342
|
+
yield Horizontal(id="season-bar")
|
|
343
|
+
yield ListView(id="episode-list")
|
|
344
|
+
yield Footer()
|
|
345
|
+
|
|
346
|
+
def on_mount(self) -> None:
|
|
347
|
+
self._refresh()
|
|
348
|
+
|
|
349
|
+
def _refresh(self) -> None:
|
|
350
|
+
details = _get_show_details(self.show_id)
|
|
351
|
+
total = details.get("number_of_seasons", 0)
|
|
352
|
+
year = (details.get("first_air_date") or "")[:4]
|
|
353
|
+
if year:
|
|
354
|
+
try:
|
|
355
|
+
self.query_one("#show-year", Label).update(year)
|
|
356
|
+
except NoMatches:
|
|
357
|
+
pass
|
|
358
|
+
if total < 1:
|
|
359
|
+
self._episode_error("No season data for this show.")
|
|
360
|
+
return
|
|
361
|
+
self.seasons = list(range(1, total + 1))
|
|
362
|
+
self.current_season = 1
|
|
363
|
+
self._build_seasons()
|
|
364
|
+
self._load(self.current_season)
|
|
365
|
+
|
|
366
|
+
def _build_seasons(self) -> None:
|
|
367
|
+
try:
|
|
368
|
+
bar = self.query_one("#season-bar", Horizontal)
|
|
369
|
+
except NoMatches:
|
|
370
|
+
return
|
|
371
|
+
bar.remove_children()
|
|
372
|
+
for s in self.seasons:
|
|
373
|
+
btn = Button(f"S{s}", variant="default")
|
|
374
|
+
btn.classes = "-active" if s == self.current_season else ""
|
|
375
|
+
bar.mount(btn)
|
|
376
|
+
# focus the active button
|
|
377
|
+
for btn in bar.children:
|
|
378
|
+
if isinstance(btn, Button) and "-active" in btn.classes:
|
|
379
|
+
btn.focus()
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
def _load(self, season: int) -> None:
|
|
383
|
+
self.current_season = season
|
|
384
|
+
try:
|
|
385
|
+
bar = self.query_one("#season-bar", Horizontal)
|
|
386
|
+
for btn in bar.children:
|
|
387
|
+
if isinstance(btn, Button):
|
|
388
|
+
btn.classes = btn.classes.replace("-active", "")
|
|
389
|
+
label = btn.label if hasattr(btn, "label") else str(btn.renderable)
|
|
390
|
+
if isinstance(label, str) and label == f"S{season}":
|
|
391
|
+
btn.classes += " -active"
|
|
392
|
+
except NoMatches:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
episodes = _get_episodes(self.show_id, season)
|
|
396
|
+
try:
|
|
397
|
+
lst = self.query_one("#episode-list", ListView)
|
|
398
|
+
except NoMatches:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
lst.clear()
|
|
402
|
+
if not episodes:
|
|
403
|
+
lst.append(ListItem(Label("[dim]No episodes yet[/dim]")))
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
for ep in episodes:
|
|
407
|
+
num = ep.get("episode_number", "?")
|
|
408
|
+
name = ep.get("name", f"Episode {num}")
|
|
409
|
+
date = ep.get("air_date", "")
|
|
410
|
+
suffix = f" [dim]({date})[/dim]" if date else ""
|
|
411
|
+
lst.append(ListItem(Label(f"E{num:02d} {name}{suffix}")))
|
|
412
|
+
|
|
413
|
+
lst.index = 0
|
|
414
|
+
lst.focus()
|
|
415
|
+
|
|
416
|
+
def _episode_error(self, msg: str) -> None:
|
|
417
|
+
try:
|
|
418
|
+
lst = self.query_one("#episode-list", ListView)
|
|
419
|
+
lst.clear()
|
|
420
|
+
lst.append(ListItem(Label(f"[red]{msg}[/red]")))
|
|
421
|
+
except NoMatches:
|
|
422
|
+
pass
|
|
423
|
+
|
|
424
|
+
# -- season button clicks --
|
|
425
|
+
@on(Button.Pressed, "#season-bar Button")
|
|
426
|
+
def on_season_press(self, event: Button.Pressed) -> None:
|
|
427
|
+
label = event.button.label
|
|
428
|
+
try:
|
|
429
|
+
num = int(str(label).replace("S", ""))
|
|
430
|
+
self._load(num)
|
|
431
|
+
except (ValueError, AttributeError):
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
# -- season keyboard nav --
|
|
435
|
+
def action_prev_season(self) -> None:
|
|
436
|
+
if self.current_season > 1:
|
|
437
|
+
self._load(self.current_season - 1)
|
|
438
|
+
|
|
439
|
+
def action_next_season(self) -> None:
|
|
440
|
+
if self.current_season < len(self.seasons):
|
|
441
|
+
self._load(self.current_season + 1)
|
|
442
|
+
|
|
443
|
+
def action_go_back(self) -> None:
|
|
444
|
+
self.app.pop_screen()
|
|
445
|
+
|
|
446
|
+
# -- episode selected --
|
|
447
|
+
@on(ListView.Selected, "#episode-list")
|
|
448
|
+
def on_episode_select(self, event: ListView.Selected) -> None:
|
|
449
|
+
episodes = _get_episodes(self.show_id, self.current_season)
|
|
450
|
+
idx = self.query_one("#episode-list", ListView).index
|
|
451
|
+
if idx is None or idx >= len(episodes):
|
|
452
|
+
return
|
|
453
|
+
ep = episodes[idx]
|
|
454
|
+
ep_num = ep.get("episode_number", 1)
|
|
455
|
+
ep_name = ep.get("name", f"E{ep_num}")
|
|
456
|
+
|
|
457
|
+
def resolve_then_play() -> str:
|
|
458
|
+
url, title = _resolve(self.show_name, self.show_id, self.current_season, ep_num)
|
|
459
|
+
if not url:
|
|
460
|
+
return ""
|
|
461
|
+
cfg = load_config()
|
|
462
|
+
player = cfg.get("default_player", "auto")
|
|
463
|
+
try:
|
|
464
|
+
play(url, title=title, player=player, season=self.current_season, episode=ep_num)
|
|
465
|
+
return f"Launched in {player.upper()}"
|
|
466
|
+
except PlayerNotFoundError as e:
|
|
467
|
+
return str(e)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
return f"Error: {e}"
|
|
470
|
+
|
|
471
|
+
self.app.push_screen(
|
|
472
|
+
PlaybackScreen(self.show_name, self.current_season, ep_num, ep_name, resolve_then_play)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
# Playback screen (modal overlay)
|
|
478
|
+
# ---------------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
class PlaybackScreen(ModalScreen):
|
|
482
|
+
def __init__(self, show: str, season: int, episode: int, ep_name: str, play_fn) -> None:
|
|
483
|
+
super().__init__()
|
|
484
|
+
self.show = show
|
|
485
|
+
self.season = season
|
|
486
|
+
self.episode = episode
|
|
487
|
+
self.ep_name = ep_name
|
|
488
|
+
self.play_fn = play_fn
|
|
489
|
+
|
|
490
|
+
def compose(self) -> ComposeResult:
|
|
491
|
+
yield Vertical(
|
|
492
|
+
Static(
|
|
493
|
+
f"\n {self.show} S{self.season:02d}E{self.episode:02d} - {self.ep_name}\n",
|
|
494
|
+
classes="info",
|
|
495
|
+
),
|
|
496
|
+
LoadingIndicator(),
|
|
497
|
+
id="box",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def on_mount(self) -> None:
|
|
501
|
+
self._go()
|
|
502
|
+
|
|
503
|
+
async def _go(self) -> None:
|
|
504
|
+
worker = self.run_worker(self.play_fn, name="resolve", thread=True)
|
|
505
|
+
result = await worker.wait()
|
|
506
|
+
try:
|
|
507
|
+
box = self.query_one("#box", Vertical)
|
|
508
|
+
box.remove_children()
|
|
509
|
+
if result:
|
|
510
|
+
box.mount(Static(f"\n {result}\n", classes="success"))
|
|
511
|
+
else:
|
|
512
|
+
box.mount(Static("\n No stream found.\n", classes="error"))
|
|
513
|
+
except NoMatches:
|
|
514
|
+
pass
|
|
515
|
+
await asyncio.sleep(1.5)
|
|
516
|
+
self.app.pop_screen()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# App
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class CinnamonTUI(App):
|
|
525
|
+
CSS = CSS
|
|
526
|
+
TITLE = "Cinnamon"
|
|
527
|
+
SUB_TITLE = "TV Stream Browser"
|
|
528
|
+
SCREENS = {"search": SearchScreen}
|
|
529
|
+
|
|
530
|
+
BINDINGS = [Binding("ctrl+c", "quit", "Quit"), Binding("escape", "back_or_quit", "Back")]
|
|
531
|
+
|
|
532
|
+
def on_mount(self) -> None:
|
|
533
|
+
self.push_screen("search")
|
|
534
|
+
|
|
535
|
+
def action_back_or_quit(self) -> None:
|
|
536
|
+
if self.screen_count > 1:
|
|
537
|
+
self.pop_screen()
|
|
538
|
+
else:
|
|
539
|
+
self.exit()
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def run_tui() -> None:
|
|
543
|
+
CinnamonTUI().run()
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cinnamon-cli
|
|
3
|
+
Version: 0.2.19
|
|
4
|
+
Summary: CLI to search TV shows, movies & anime via TMDB/AniList, scrape m3u8 links, and play in VLC/mpv
|
|
5
|
+
Author: pizza-droid
|
|
6
|
+
License: CC BY-NC 4.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/pizza-droid/cinnamon
|
|
8
|
+
Project-URL: Repository, https://github.com/pizza-droid/cinnamon
|
|
9
|
+
Project-URL: Issues, https://github.com/pizza-droid/cinnamon/issues
|
|
10
|
+
Keywords: tv,movies,anime,streaming,mpv,vlc,tmdb,anilist,cli
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: License :: Other/Proprietary License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: click>=8
|
|
20
|
+
Requires-Dist: requests>=2
|
|
21
|
+
Requires-Dist: rich>=13
|
|
22
|
+
Requires-Dist: questionary>=2
|
|
23
|
+
Requires-Dist: pycryptodome>=3
|
|
24
|
+
Provides-Extra: scrapers
|
|
25
|
+
Requires-Dist: playwright>=1.60; extra == "scrapers"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# cinnamon
|
|
29
|
+
|
|
30
|
+
> Watch TV shows, movies, and anime from your terminal. Search, pick a title, and it plays in mpv or VLC.
|
|
31
|
+
|
|
32
|
+
[](#)
|
|
33
|
+
[](#)
|
|
34
|
+
[](#)
|
|
35
|
+
[](#)
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
You'll need **Python 3.10+** and either [mpv](https://mpv.io) or [VLC](https://videolan.org).
|
|
40
|
+
|
|
41
|
+
### Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install https://github.com/pizza-droid/cinnamon/archive/refs/tags/v0.2.19.tar.gz
|
|
45
|
+
cinnamon setup
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The setup wizard asks for a TMDB API key ([free account](https://www.themoviedb.org/signup)), your preferred player, and a color theme.
|
|
49
|
+
|
|
50
|
+
### Get a player
|
|
51
|
+
> Please note that MacOS version has not been tested since we dont have access to a mac
|
|
52
|
+
|
|
53
|
+
| Platform | mpv | VLC |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| **Windows** | `scoop install mpv` or download from [mpv.io](https://mpv.io) | [videolan.org](https://videolan.org) |
|
|
56
|
+
| **macOS** | `brew install mpv` | `brew install --cask vlc` |
|
|
57
|
+
| **Linux** | `apt install mpv` (Debian/Ubuntu) | `apt install vlc` |
|
|
58
|
+
| **Termux** | `pkg install mpv` | `pkg install vlc` |
|
|
59
|
+
|
|
60
|
+
### Watch a show or movie
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
cinnamon search "Breaking Bad"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Pick a title, pick an episode (for TV) or just play (for movies), and it plays. Anime is detected automatically:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
cinnamon search "Chainsaw Man"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Movies work the same way — search returns both TV and movies, and you pick:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cinnamon search "Inception"
|
|
76
|
+
cinnamon watch --type movie --query "Inception"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Download episodes
|
|
80
|
+
|
|
81
|
+
Needs [yt-dlp](https://github.com/yt-dlp/yt-dlp):
|
|
82
|
+
|
|
83
|
+
| Platform | Command |
|
|
84
|
+
|---|---|
|
|
85
|
+
| Windows | `scoop install yt-dlp` |
|
|
86
|
+
| macOS | `brew install yt-dlp` |
|
|
87
|
+
| Linux | `pip install yt-dlp` |
|
|
88
|
+
| Termux | `pkg install yt-dlp` |
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cinnamon search "Breaking Bad" -d -e 1-5
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
| If you run this… | …this happens |
|
|
99
|
+
|---|---|
|
|
100
|
+
| `cinnamon anime <query>` | Search anime via AniList (no API key needed) |
|
|
101
|
+
| `cinnamon search <query>` | Find a show, pick an episode, watch it |
|
|
102
|
+
| `cinnamon watch --id 123 -s 2 -e 5` | Go straight to S2E5 without menus |
|
|
103
|
+
| `cinnamon play-url <url>` | Play any m3u8/mp4 link |
|
|
104
|
+
| `cinnamon resume` | Continue an interrupted download |
|
|
105
|
+
| `cinnamon history` | Show watch history and resume from the next unwatched episode |
|
|
106
|
+
| `cinnamon history --clear` | Clear all watch history |
|
|
107
|
+
| `cinnamon scrapers` | See available streaming sources |
|
|
108
|
+
| `cinnamon install <name>` | Add an optional scraper (vidsrc, torrentio) |
|
|
109
|
+
| `cinnamon update` | Check for and install the latest version |
|
|
110
|
+
| `cinnamon config show` | View your settings |
|
|
111
|
+
|
|
112
|
+
### Common flags
|
|
113
|
+
|
|
114
|
+
| Flag | What it does |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `-s <N>` | Season number |
|
|
117
|
+
| `-e <N>` or `-e <start-end>` | Episode or range (e.g. `-e 1-10`) |
|
|
118
|
+
| `-d` | Download instead of streaming |
|
|
119
|
+
| `--scraper <name>` | Force a specific scraper |
|
|
120
|
+
| `--player mpv` or `--player vlc` | Choose player |
|
|
121
|
+
| `-q 720p` | Pick quality (480p, 720p, 1080p, best, worst) |
|
|
122
|
+
| `--info-only` | Just print the stream URL |
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Scrapers (streaming sources)
|
|
127
|
+
|
|
128
|
+
**Built-in (work out of the box):**
|
|
129
|
+
|
|
130
|
+
| Name | For |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `webstream` | TV shows & movies from vixsrc.to and vidlink.pro (HLS) |
|
|
133
|
+
| `anime` | Anime from allanime.day via mp4upload |
|
|
134
|
+
|
|
135
|
+
**Optional (install with `cinnamon install <name>`):**
|
|
136
|
+
|
|
137
|
+
| Name | Needs |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `vidsrc` | `pip install "cinnamon[scrapers]"` then `playwright install chromium` |
|
|
140
|
+
| `torrentio` | `npm install` (torrent playback) |
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
cinnamon install torrentio
|
|
144
|
+
cinnamon config default-scraper torrentio
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
Settings are stored in `~/.config/cinnamon/config.json` (Linux/macOS/Termux) or `%APPDATA%/cinnamon/config.json` (Windows). Change them anytime:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
cinnamon config default-player mpv
|
|
155
|
+
cinnamon config default-scraper webstream
|
|
156
|
+
cinnamon config set-api-key YOUR_KEY
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Notes
|
|
162
|
+
|
|
163
|
+
This project is mostly vibe coded and our lazy ass didnt even write more than 300 lines.
|
|
164
|
+
feel free to give us feedback so we can improve this project and make it as good as possible.
|
|
165
|
+
|
|
166
|
+
inspired by [ani-cli](https://github.com/pystardust/ani-cli)
|