weeb-cli 1.0.0__py3-none-any.whl → 2.0.0__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.
weeb_cli/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.0.0"
1
+ __version__ = "2.0.0"
@@ -1,29 +1,79 @@
1
1
  import questionary
2
+ from pathlib import Path
2
3
  from rich.console import Console
3
4
  from rich.live import Live
4
5
  from rich.table import Table
5
6
  from weeb_cli.services.downloader import queue_manager
7
+ from weeb_cli.services.local_library import local_library
8
+ from weeb_cli.services.player import player
9
+ from weeb_cli.services.progress import progress_tracker
6
10
  from weeb_cli.i18n import i18n
7
11
  from weeb_cli.ui.header import show_header
8
12
  import time
9
13
 
10
14
  console = Console()
11
15
 
16
+ AUTOCOMPLETE_STYLE = questionary.Style([
17
+ ('qmark', 'fg:cyan'),
18
+ ('question', 'fg:white bold'),
19
+ ('answer', 'fg:cyan bold'),
20
+ ('pointer', 'fg:cyan bold'),
21
+ ('highlighted', 'fg:white bg:ansiblack bold'),
22
+ ('selected', 'fg:white'),
23
+ ])
24
+
12
25
  def show_downloads():
26
+ local_library.smart_index_all()
27
+
13
28
  while True:
14
29
  console.clear()
15
30
  show_header(i18n.get("downloads.title"))
16
31
 
17
- pending = queue_manager.get_pending_count()
18
- is_running = queue_manager.is_running()
32
+ sources = local_library.get_all_sources()
33
+ active_queue = [i for i in queue_manager.queue if i["status"] in ["pending", "processing"]]
19
34
 
20
- if pending > 0:
35
+ choices = []
36
+
37
+ indexed_count = len(local_library.get_indexed_anime())
38
+ if indexed_count > 0:
39
+ choices.append(questionary.Choice(
40
+ f"⌕ {i18n.get('downloads.search_all')} ({indexed_count} anime)",
41
+ value={"type": "search_all"}
42
+ ))
43
+
44
+ for source in sources:
45
+ if source["available"]:
46
+ library = local_library.scan_library(source["path"])
47
+ count = len(library)
48
+ if count > 0:
49
+ choices.append(questionary.Choice(
50
+ f"● {source['name']} ({count} anime)",
51
+ value={"type": "source", "data": source}
52
+ ))
53
+ else:
54
+ indexed = [a for a in local_library.get_indexed_anime() if a["source_path"] == source["path"]]
55
+ count = len(indexed)
56
+ if count > 0:
57
+ choices.append(questionary.Choice(
58
+ f"○ {source['name']} ({count} anime) - {i18n.get('downloads.offline')}",
59
+ value={"type": "offline", "data": source}
60
+ ))
61
+
62
+ if active_queue:
63
+ is_running = queue_manager.is_running()
21
64
  status = i18n.get("downloads.queue_running") if is_running else i18n.get("downloads.queue_stopped")
22
- console.print(f"[cyan]{i18n.t('downloads.pending_count', count=pending)}[/cyan] - {status}\n")
65
+ choices.append(questionary.Choice(
66
+ f"{i18n.get('downloads.active_downloads')} ({len(active_queue)}) - {status}",
67
+ value={"type": "active"}
68
+ ))
23
69
 
24
- queue = queue_manager.queue
70
+ if queue_manager.queue:
71
+ choices.append(questionary.Choice(
72
+ i18n.get("downloads.manage_queue"),
73
+ value={"type": "manage"}
74
+ ))
25
75
 
26
- if not queue:
76
+ if not choices:
27
77
  console.print(f"[dim]{i18n.get('downloads.empty')}[/dim]")
28
78
  try:
29
79
  input(i18n.get("common.continue_key"))
@@ -31,6 +81,288 @@ def show_downloads():
31
81
  pass
32
82
  return
33
83
 
84
+ try:
85
+ action = questionary.select(
86
+ i18n.get("downloads.action_prompt"),
87
+ choices=choices,
88
+ pointer=">",
89
+ use_shortcuts=False
90
+ ).ask()
91
+
92
+ if action is None:
93
+ return
94
+
95
+ if action["type"] == "search_all":
96
+ search_all_sources()
97
+ elif action["type"] == "source":
98
+ library = local_library.scan_library(action["data"]["path"])
99
+ show_completed_library(library, action["data"]["name"])
100
+ elif action["type"] == "offline":
101
+ show_offline_library(action["data"])
102
+ elif action["type"] == "active":
103
+ show_queue_live()
104
+ elif action["type"] == "manage":
105
+ manage_queue()
106
+
107
+ except KeyboardInterrupt:
108
+ return
109
+
110
+ def fuzzy_match(query: str, text: str) -> float:
111
+ from difflib import SequenceMatcher
112
+ query = query.lower()
113
+ text = text.lower()
114
+
115
+ if query == text:
116
+ return 1.0
117
+ if query in text:
118
+ return 0.9
119
+
120
+ return SequenceMatcher(None, query, text).ratio()
121
+
122
+ def search_all_sources():
123
+ while True:
124
+ console.clear()
125
+ show_header(i18n.get("downloads.search_all"))
126
+
127
+ all_indexed = local_library.get_indexed_anime()
128
+
129
+ anime_map = {}
130
+ for anime in all_indexed:
131
+ progress = local_library.get_anime_progress(anime["title"])
132
+ watched = len(progress.get("completed", []))
133
+ total = anime["episode_count"]
134
+ available = local_library.is_source_available(anime["source_path"])
135
+
136
+ status_icon = "●" if available else "○"
137
+
138
+ if watched >= total and total > 0:
139
+ watch_status = " [✓]"
140
+ elif watched > 0:
141
+ watch_status = f" [{watched}/{total}]"
142
+ else:
143
+ watch_status = ""
144
+
145
+ label = f"{status_icon} {anime['title']} [{anime['source_name']}]{watch_status}"
146
+ anime_map[label] = anime
147
+
148
+ all_choices = list(anime_map.keys())
149
+
150
+ if not all_choices:
151
+ console.print(f"[dim]{i18n.get('downloads.no_indexed')}[/dim]")
152
+ time.sleep(1.5)
153
+ return
154
+
155
+ try:
156
+ selected_label = questionary.autocomplete(
157
+ i18n.get("downloads.search_anime"),
158
+ choices=all_choices,
159
+ match_middle=True,
160
+ style=AUTOCOMPLETE_STYLE,
161
+ ).ask()
162
+
163
+ if selected_label is None:
164
+ return
165
+
166
+ if selected_label in anime_map:
167
+ anime_info = anime_map[selected_label]
168
+
169
+ if not local_library.is_source_available(anime_info["source_path"]):
170
+ console.print(f"[yellow]{i18n.t('downloads.connect_drive', name=anime_info['source_name'])}[/yellow]")
171
+ time.sleep(1.5)
172
+ continue
173
+
174
+ anime_data = {
175
+ "title": anime_info["title"],
176
+ "path": anime_info["folder_path"],
177
+ "episode_count": anime_info["episode_count"],
178
+ "episodes": local_library._scan_anime_folder(Path(anime_info["folder_path"]))
179
+ }
180
+ show_anime_episodes(anime_data)
181
+
182
+ except KeyboardInterrupt:
183
+ return
184
+
185
+ def show_offline_library(source):
186
+ while True:
187
+ console.clear()
188
+ show_header(f"{source['name']} ({i18n.get('downloads.offline')})")
189
+
190
+ indexed = [a for a in local_library.get_indexed_anime() if a["source_path"] == source["path"]]
191
+
192
+ if not indexed:
193
+ console.print(f"[dim]{i18n.get('downloads.no_indexed')}[/dim]")
194
+ time.sleep(1.5)
195
+ return
196
+
197
+ ep_short = i18n.get("downloads.episode_short")
198
+ anime_map = {}
199
+ for anime in indexed:
200
+ progress = local_library.get_anime_progress(anime["title"])
201
+ watched = len(progress.get("completed", []))
202
+ total = anime["episode_count"]
203
+
204
+ if watched >= total and total > 0:
205
+ status = " [✓]"
206
+ elif watched > 0:
207
+ status = f" [{watched}/{total}]"
208
+ else:
209
+ status = ""
210
+
211
+ label = f"{anime['title']} [{total} {ep_short}]{status}"
212
+ anime_map[label] = anime
213
+
214
+ all_choices = list(anime_map.keys())
215
+
216
+ try:
217
+ selected_label = questionary.autocomplete(
218
+ i18n.get("downloads.search_anime"),
219
+ choices=all_choices,
220
+ match_middle=True,
221
+ style=AUTOCOMPLETE_STYLE,
222
+ ).ask()
223
+
224
+ if selected_label is None:
225
+ return
226
+
227
+ if selected_label in anime_map:
228
+ console.print(f"[yellow]{i18n.t('downloads.connect_drive', name=source['name'])}[/yellow]")
229
+ time.sleep(1.5)
230
+
231
+ except KeyboardInterrupt:
232
+ return
233
+
234
+ def show_completed_library(library, source_name=None):
235
+ while True:
236
+ console.clear()
237
+ title = source_name or i18n.get("downloads.completed_downloads")
238
+ show_header(title)
239
+
240
+ ep_short = i18n.get("downloads.episode_short")
241
+ anime_map = {}
242
+ for anime in library:
243
+ progress = local_library.get_anime_progress(anime["title"])
244
+ watched = len(progress.get("completed", []))
245
+ total = anime["episode_count"]
246
+
247
+ if watched >= total and total > 0:
248
+ status = " [✓]"
249
+ elif watched > 0:
250
+ status = f" [{watched}/{total}]"
251
+ else:
252
+ status = ""
253
+
254
+ label = f"{anime['title']} [{total} {ep_short}]{status}"
255
+ anime_map[label] = anime
256
+
257
+ all_choices = list(anime_map.keys())
258
+
259
+ choices = [
260
+ questionary.Choice(f"⌕ {i18n.get('downloads.search_anime')}", value="search"),
261
+ ]
262
+ for label in all_choices:
263
+ choices.append(questionary.Choice(label, value=label))
264
+
265
+ try:
266
+ selected = questionary.select(
267
+ i18n.get("downloads.action_prompt"),
268
+ choices=choices,
269
+ pointer=">",
270
+ use_shortcuts=False,
271
+ ).ask()
272
+
273
+ if selected is None:
274
+ return
275
+
276
+ if selected == "search":
277
+ search_result = questionary.autocomplete(
278
+ i18n.get("downloads.search_anime"),
279
+ choices=all_choices,
280
+ match_middle=True,
281
+ style=AUTOCOMPLETE_STYLE,
282
+ ).ask()
283
+
284
+ if search_result and search_result in anime_map:
285
+ show_anime_episodes(anime_map[search_result])
286
+ elif selected in anime_map:
287
+ show_anime_episodes(anime_map[selected])
288
+
289
+ except KeyboardInterrupt:
290
+ return
291
+
292
+ def show_anime_episodes(anime):
293
+ while True:
294
+ console.clear()
295
+ show_header(anime["title"])
296
+
297
+ progress = local_library.get_anime_progress(anime["title"])
298
+ completed_eps = set(progress.get("completed", []))
299
+ last_watched = progress.get("last_watched", 0)
300
+ next_ep = last_watched + 1
301
+
302
+ episodes = anime["episodes"]
303
+
304
+ choices = []
305
+ for ep in episodes:
306
+ num = ep["number"]
307
+ size = local_library.format_size(ep["size"])
308
+
309
+ prefix = " "
310
+ if num in completed_eps:
311
+ prefix = "✓ "
312
+ elif num == next_ep:
313
+ prefix = "● "
314
+
315
+ choices.append(questionary.Choice(
316
+ f"{prefix}{i18n.get('details.episode')} {num} ({size})",
317
+ value=ep
318
+ ))
319
+
320
+ try:
321
+ selected = questionary.select(
322
+ i18n.get("details.select_episode"),
323
+ choices=choices,
324
+ pointer=">",
325
+ use_shortcuts=False
326
+ ).ask()
327
+
328
+ if selected is None:
329
+ return
330
+
331
+ play_local_episode(anime, selected)
332
+
333
+ except KeyboardInterrupt:
334
+ return
335
+
336
+ def play_local_episode(anime, episode):
337
+ console.print(f"[green]{i18n.get('details.player_starting')}[/green]")
338
+
339
+ title = f"{anime['title']} - {i18n.get('details.episode')} {episode['number']}"
340
+ success = player.play(episode["path"], title=title)
341
+
342
+ if success:
343
+ try:
344
+ ans = questionary.confirm(i18n.get("details.mark_watched")).ask()
345
+ if ans:
346
+ local_library.mark_episode_watched(
347
+ anime["title"],
348
+ episode["number"],
349
+ anime["episode_count"]
350
+ )
351
+ except:
352
+ pass
353
+
354
+ def manage_queue():
355
+ while True:
356
+ console.clear()
357
+ show_header(i18n.get("downloads.manage_queue"))
358
+
359
+ pending = queue_manager.get_pending_count()
360
+ is_running = queue_manager.is_running()
361
+
362
+ if pending > 0:
363
+ status = i18n.get("downloads.queue_running") if is_running else i18n.get("downloads.queue_stopped")
364
+ console.print(f"[cyan]{i18n.t('downloads.pending_count', count=pending)}[/cyan] - {status}\n")
365
+
34
366
  opt_view = i18n.get("downloads.view_queue")
35
367
  opt_start = i18n.get("downloads.start_queue")
36
368
  opt_stop = i18n.get("downloads.stop_queue")
@@ -66,8 +398,7 @@ def show_downloads():
66
398
  console.print(f"[yellow]{i18n.get('downloads.queue_stopped')}[/yellow]")
67
399
  time.sleep(0.5)
68
400
  elif action == opt_clear:
69
- queue_manager.queue = [i for i in queue_manager.queue if i["status"] in ["pending", "processing"]]
70
- queue_manager._save_queue()
401
+ queue_manager.clear_completed()
71
402
  console.print(f"[green]{i18n.get('downloads.cleared')}[/green]")
72
403
  time.sleep(0.5)
73
404
 
@@ -49,6 +49,7 @@ def open_settings():
49
49
  opt_lang = i18n.get("settings.language")
50
50
  opt_source = f"{i18n.get('settings.source')} [{display_source}]"
51
51
  opt_download = i18n.get("settings.download_settings")
52
+ opt_drives = i18n.get("settings.external_drives")
52
53
  opt_desc = f"{i18n.get('settings.show_description')} [{desc_state}]"
53
54
  opt_aria2 = f"{i18n.get('settings.aria2')} [{aria2_state}]"
54
55
  opt_ytdlp = f"{i18n.get('settings.ytdlp')} [{ytdlp_state}]"
@@ -56,7 +57,7 @@ def open_settings():
56
57
  opt_aria2_conf = f" ↳ {i18n.get('settings.aria2_config')}"
57
58
  opt_ytdlp_conf = f" ↳ {i18n.get('settings.ytdlp_config')}"
58
59
 
59
- choices = [opt_lang, opt_source, opt_download, opt_desc, opt_aria2]
60
+ choices = [opt_lang, opt_source, opt_download, opt_drives, opt_desc, opt_aria2]
60
61
  if config.get("aria2_enabled"):
61
62
  choices.append(opt_aria2_conf)
62
63
 
@@ -85,6 +86,8 @@ def open_settings():
85
86
  change_source()
86
87
  elif answer == opt_download:
87
88
  download_settings_menu()
89
+ elif answer == opt_drives:
90
+ external_drives_menu()
88
91
  elif answer == opt_desc:
89
92
  toggle_description()
90
93
  elif answer == opt_aria2:
@@ -252,3 +255,126 @@ def ytdlp_settings_menu():
252
255
  return
253
256
  except KeyboardInterrupt:
254
257
  return
258
+
259
+
260
+ def external_drives_menu():
261
+ from weeb_cli.services.local_library import local_library
262
+ from pathlib import Path
263
+
264
+ while True:
265
+ console.clear()
266
+ show_header(i18n.get("settings.external_drives"))
267
+
268
+ drives = local_library.get_external_drives()
269
+
270
+ opt_add = i18n.get("settings.add_drive")
271
+
272
+ choices = [questionary.Choice(opt_add, value="add")]
273
+
274
+ for drive in drives:
275
+ path = Path(drive["path"])
276
+ status = "● " if path.exists() else "○ "
277
+ choices.append(questionary.Choice(
278
+ f"{status}{drive['name']} ({drive['path']})",
279
+ value=drive
280
+ ))
281
+
282
+ try:
283
+ sel = questionary.select(
284
+ i18n.get("settings.external_drives"),
285
+ choices=choices,
286
+ pointer=">",
287
+ use_shortcuts=False
288
+ ).ask()
289
+
290
+ if sel is None:
291
+ return
292
+
293
+ if sel == "add":
294
+ add_external_drive()
295
+ else:
296
+ manage_drive(sel)
297
+
298
+ except KeyboardInterrupt:
299
+ return
300
+
301
+ def add_external_drive():
302
+ from weeb_cli.services.local_library import local_library
303
+
304
+ try:
305
+ path = questionary.text(
306
+ i18n.get("settings.enter_drive_path"),
307
+ qmark=">"
308
+ ).ask()
309
+
310
+ if not path:
311
+ return
312
+
313
+ from pathlib import Path
314
+ if not Path(path).exists():
315
+ console.print(f"[yellow]{i18n.get('settings.drive_not_found')}[/yellow]")
316
+ time.sleep(1)
317
+ return
318
+
319
+ name = questionary.text(
320
+ i18n.get("settings.enter_drive_name"),
321
+ default=os.path.basename(path) or path,
322
+ qmark=">"
323
+ ).ask()
324
+
325
+ if name:
326
+ local_library.add_external_drive(path, name)
327
+ console.print(f"[green]{i18n.get('settings.drive_added')}[/green]")
328
+ time.sleep(0.5)
329
+
330
+ except KeyboardInterrupt:
331
+ pass
332
+
333
+ def manage_drive(drive):
334
+ from weeb_cli.services.local_library import local_library
335
+
336
+ while True:
337
+ console.clear()
338
+ show_header(drive["name"])
339
+
340
+ console.print(f"[dim]{drive['path']}[/dim]\n")
341
+
342
+ opt_rename = i18n.get("settings.rename_drive")
343
+ opt_remove = i18n.get("settings.remove_drive")
344
+
345
+ try:
346
+ sel = questionary.select(
347
+ i18n.get("downloads.action_prompt"),
348
+ choices=[opt_rename, opt_remove],
349
+ pointer=">",
350
+ use_shortcuts=False
351
+ ).ask()
352
+
353
+ if sel is None:
354
+ return
355
+
356
+ if sel == opt_rename:
357
+ new_name = questionary.text(
358
+ i18n.get("settings.enter_drive_name"),
359
+ default=drive["name"],
360
+ qmark=">"
361
+ ).ask()
362
+ if new_name:
363
+ local_library.rename_external_drive(drive["path"], new_name)
364
+ drive["name"] = new_name
365
+ console.print(f"[green]{i18n.get('settings.drive_renamed')}[/green]")
366
+ time.sleep(0.5)
367
+
368
+ elif sel == opt_remove:
369
+ confirm = questionary.confirm(
370
+ i18n.get("settings.confirm_remove"),
371
+ default=False
372
+ ).ask()
373
+ if confirm:
374
+ local_library.remove_external_drive(drive["path"])
375
+ console.print(f"[green]{i18n.get('settings.drive_removed')}[/green]")
376
+ time.sleep(0.5)
377
+ return
378
+
379
+ except KeyboardInterrupt:
380
+ return
weeb_cli/config.py CHANGED
@@ -1,10 +1,8 @@
1
- import json
2
1
  import os
3
2
  from pathlib import Path
4
3
 
5
4
  APP_NAME = "weeb-cli"
6
5
  CONFIG_DIR = Path.home() / f".{APP_NAME}"
7
- CONFIG_FILE = CONFIG_DIR / "config.json"
8
6
 
9
7
  DEFAULT_CONFIG = {
10
8
  "language": None,
@@ -15,36 +13,28 @@ DEFAULT_CONFIG = {
15
13
  "download_dir": os.path.join(os.getcwd(), "weeb-downloads"),
16
14
  "ytdlp_format": "bestvideo+bestaudio/best",
17
15
  "scraping_source": "animecix",
18
- "show_description": True
16
+ "show_description": True,
17
+ "debug_mode": False
19
18
  }
20
19
 
21
20
  class Config:
22
21
  def __init__(self):
23
- self._ensure_config_exists()
24
- self.data = self._load()
25
-
26
- def _ensure_config_exists(self):
27
- if not CONFIG_DIR.exists():
28
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
29
- if not CONFIG_FILE.exists():
30
- self._save(DEFAULT_CONFIG)
31
-
32
- def _load(self):
33
- try:
34
- with open(CONFIG_FILE, "r", encoding="utf-8") as f:
35
- return json.load(f)
36
- except (json.JSONDecodeError, FileNotFoundError):
37
- return DEFAULT_CONFIG.copy()
38
-
39
- def _save(self, data):
40
- with open(CONFIG_FILE, "w", encoding="utf-8") as f:
41
- json.dump(data, f, indent=2, ensure_ascii=False)
42
-
22
+ self._db = None
23
+
24
+ @property
25
+ def db(self):
26
+ if self._db is None:
27
+ from weeb_cli.services.database import db
28
+ self._db = db
29
+ return self._db
30
+
43
31
  def get(self, key, default=None):
44
- return self.data.get(key, default)
45
-
32
+ val = self.db.get_config(key)
33
+ if val is None:
34
+ return DEFAULT_CONFIG.get(key, default)
35
+ return val
36
+
46
37
  def set(self, key, value):
47
- self.data[key] = value
48
- self._save(self.data)
38
+ self.db.set_config(key, value)
49
39
 
50
40
  config = Config()
weeb_cli/locales/en.json CHANGED
@@ -48,7 +48,18 @@
48
48
  "source_changed": "Source changed to {source}.",
49
49
  "no_sources": "No sources available for this language.",
50
50
  "toggle_on": "{tool} enabled.",
51
- "toggle_off": "{tool} disabled."
51
+ "toggle_off": "{tool} disabled.",
52
+ "external_drives": "External Drives",
53
+ "add_drive": "Add Drive",
54
+ "enter_drive_path": "Enter drive path (e.g. D:\\Anime)",
55
+ "enter_drive_name": "Nickname for drive",
56
+ "drive_not_found": "Specified path not found.",
57
+ "drive_added": "Drive added.",
58
+ "rename_drive": "Rename",
59
+ "remove_drive": "Remove Drive",
60
+ "confirm_remove": "Are you sure you want to remove this drive?",
61
+ "drive_renamed": "Drive renamed.",
62
+ "drive_removed": "Drive removed."
52
63
  },
53
64
  "setup": {
54
65
  "welcome": "Welcome to Weeb CLI!",
@@ -139,7 +150,7 @@
139
150
  "no_streams": "No streams found"
140
151
  },
141
152
  "downloads": {
142
- "title": "Download History",
153
+ "title": "Downloads",
143
154
  "empty": "You haven't downloaded anything yet.",
144
155
  "status": "Status",
145
156
  "progress": "Progress",
@@ -163,6 +174,23 @@
163
174
  "queue_stopped": "Queue stopped.",
164
175
  "queue_running": "Running",
165
176
  "pending_count": "{count} download(s) pending",
166
- "cleared": "Cleared."
177
+ "cleared": "Cleared.",
178
+ "completed_downloads": "Completed Downloads",
179
+ "active_downloads": "Active Downloads",
180
+ "manage_queue": "Queue Management",
181
+ "select_anime": "Select Anime",
182
+ "search_library": "Search",
183
+ "search_anime": "Enter anime name",
184
+ "search_all": "Search All Sources",
185
+ "offline": "Offline",
186
+ "reindex": "Reindex",
187
+ "indexing": "Indexing...",
188
+ "indexed": "{count} anime indexed",
189
+ "no_indexed": "No indexed anime. Connect a drive and index first.",
190
+ "drive_not_connected": "Drive not connected",
191
+ "connect_drive": "Connect {name} drive",
192
+ "notification_title": "Weeb CLI",
193
+ "notification_complete": "{anime} - Episode {episode} downloaded",
194
+ "episode_short": "Ep"
167
195
  }
168
196
  }