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/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
+ [![Version](https://img.shields.io/badge/version-0.2.19-blue)](#)
33
+ [![Python](https://img.shields.io/badge/python-%3E%3D3.10-blue)](#)
34
+ [![License](https://img.shields.io/badge/license-CC%20BY--NC%204.0-green)](#)
35
+ [![Platform](https://img.shields.io/badge/platform-windows%20%7C%20macos%20%7C%20linux%20%7C%20termux-lightgrey)](#)
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)