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.
@@ -1,5 +1,4 @@
1
1
  import os
2
- import json
3
2
  import re
4
3
  import threading
5
4
  import time
@@ -13,27 +12,21 @@ console = Console()
13
12
 
14
13
  class QueueManager:
15
14
  def __init__(self):
16
- self.config_dir = Path.home() / ".weeb-cli"
17
- self.queue_file = self.config_dir / "download_queue.json"
18
- self.queue = []
19
- self.active_downloads = 0
15
+ self._db = None
20
16
  self.lock = threading.Lock()
21
17
  self.running = False
22
18
  self.worker_thread = None
23
- self._load_queue()
24
-
25
- def _load_queue(self):
26
- if self.queue_file.exists():
27
- try:
28
- with open(self.queue_file, 'r', encoding='utf-8') as f:
29
- self.queue = json.load(f)
30
- except:
31
- self.queue = []
32
-
33
- def _save_queue(self):
34
- with self.lock:
35
- with open(self.queue_file, 'w', encoding='utf-8') as f:
36
- json.dump(self.queue, f, indent=2, ensure_ascii=False)
19
+
20
+ @property
21
+ def db(self):
22
+ if self._db is None:
23
+ from weeb_cli.services.database import db
24
+ self._db = db
25
+ return self._db
26
+
27
+ @property
28
+ def queue(self):
29
+ return self.db.get_queue()
37
30
 
38
31
  def start_queue(self):
39
32
  if self.running:
@@ -61,13 +54,14 @@ class QueueManager:
61
54
  def resume_incomplete(self):
62
55
  for item in self.queue:
63
56
  if item["status"] == "processing":
64
- item["status"] = "pending"
65
- self._save_queue()
57
+ self.db.update_queue_item(item["episode_id"], status="pending")
66
58
  self.start_queue()
67
59
 
68
60
  def cancel_incomplete(self):
69
- self.queue = [item for item in self.queue if item["status"] not in ["pending", "processing"]]
70
- self._save_queue()
61
+ self.db.clear_completed_queue()
62
+ for item in self.queue:
63
+ if item["status"] in ["pending", "processing"]:
64
+ self.db.update_queue_item(item["episode_id"], status="cancelled")
71
65
 
72
66
  def is_downloading(self, slug, episode_id=None):
73
67
  for item in self.queue:
@@ -83,7 +77,7 @@ class QueueManager:
83
77
  ep_id = ep.get("id")
84
78
  if self.is_downloading(slug, ep_id):
85
79
  continue
86
-
80
+
87
81
  item = {
88
82
  "anime_title": anime_title,
89
83
  "episode_number": ep.get("number") or ep.get("ep_num"),
@@ -94,10 +88,8 @@ class QueueManager:
94
88
  "progress": 0,
95
89
  "eta": "?"
96
90
  }
97
- if not any(x['episode_id'] == item['episode_id'] and x['status'] in ['pending', 'processing'] for x in self.queue):
98
- self.queue.append(item)
91
+ if self.db.add_to_queue(item):
99
92
  added += 1
100
- self._save_queue()
101
93
  return added
102
94
 
103
95
  def _sanitize_filename(self, name):
@@ -107,14 +99,13 @@ class QueueManager:
107
99
  while self.running:
108
100
  max_workers = config.get("max_concurrent_downloads", 3)
109
101
 
110
- with self.lock:
111
- active_count = len([x for x in self.queue if x["status"] == "processing"])
112
- pending = [x for x in self.queue if x["status"] == "pending"]
102
+ queue = self.queue
103
+ active_count = len([x for x in queue if x["status"] == "processing"])
104
+ pending = [x for x in queue if x["status"] == "pending"]
113
105
 
114
106
  if active_count < max_workers and pending:
115
107
  to_start = pending[0]
116
- to_start["status"] = "processing"
117
- self._save_queue()
108
+ self.db.update_queue_item(to_start["episode_id"], status="processing")
118
109
 
119
110
  t = threading.Thread(target=self._run_task, args=(to_start,))
120
111
  t.start()
@@ -126,16 +117,25 @@ class QueueManager:
126
117
  time.sleep(1)
127
118
 
128
119
  def _run_task(self, item):
120
+ from weeb_cli.services.notifier import send_notification
121
+ from weeb_cli.services.logger import debug, error
122
+ from weeb_cli.i18n import i18n
123
+
124
+ debug(f"Starting download: {item['anime_title']} - Ep {item['episode_number']}")
125
+
129
126
  try:
130
127
  self._download_item(item)
131
- item["status"] = "completed"
132
- item["progress"] = 100
133
- item["eta"] = "-"
128
+ self.db.update_queue_item(item["episode_id"], status="completed", progress=100, eta="-")
129
+
130
+ debug(f"Download completed: {item['anime_title']} - Ep {item['episode_number']}")
131
+
132
+ title = i18n.get("downloads.notification_title", "Weeb CLI")
133
+ msg = i18n.t("downloads.notification_complete", anime=item['anime_title'], episode=item['episode_number'])
134
+ send_notification(title, msg)
135
+
134
136
  except Exception as e:
135
- item["status"] = "failed"
136
- item["error"] = str(e)
137
- item["eta"] = ""
138
- self._save_queue()
137
+ self.db.update_queue_item(item["episode_id"], status="failed", error=str(e), eta="")
138
+ error(f"Download failed: {item['anime_title']} - {str(e)}")
139
139
 
140
140
  def _download_item(self, item):
141
141
  from weeb_cli.services.watch import get_streams
@@ -200,6 +200,15 @@ class QueueManager:
200
200
  return node["url"]
201
201
  return None
202
202
 
203
+ def _update_progress(self, item, progress=None, eta=None):
204
+ updates = {}
205
+ if progress is not None:
206
+ updates["progress"] = progress
207
+ if eta is not None:
208
+ updates["eta"] = eta
209
+ if updates:
210
+ self.db.update_queue_item(item["episode_id"], **updates)
211
+
203
212
  def _download_aria2(self, url, path, item):
204
213
  aria2 = dependency_manager.check_dependency("aria2")
205
214
  conn = config.get("aria2_max_connections", 16)
@@ -227,11 +236,10 @@ class QueueManager:
227
236
  try:
228
237
  parts = line.split("ETA:")
229
238
  eta_part = parts[1].split("]")[0]
230
- item["eta"] = eta_part.strip()
231
239
 
232
240
  match = re.search(r'\((\d+)%\)', line)
233
- if match:
234
- item["progress"] = int(match.group(1))
241
+ progress = int(match.group(1)) if match else None
242
+ self._update_progress(item, progress=progress, eta=eta_part.strip())
235
243
  except:
236
244
  pass
237
245
 
@@ -258,16 +266,16 @@ class QueueManager:
258
266
  if "[download]" in line and "%" in line:
259
267
  try:
260
268
  p_str = line.split("%")[0].split()[-1]
261
- item["progress"] = float(p_str)
262
- if "ETA" in line:
263
- item["eta"] = line.split("ETA")[-1].strip()
269
+ progress = float(p_str)
270
+ eta = line.split("ETA")[-1].strip() if "ETA" in line else None
271
+ self._update_progress(item, progress=progress, eta=eta)
264
272
  except:
265
273
  pass
266
274
  if process.returncode != 0:
267
275
  raise Exception("yt-dlp failed")
268
276
 
269
277
  def _download_ffmpeg(self, url, path, item):
270
- item["eta"] = ""
278
+ self._update_progress(item, eta="")
271
279
  ffmpeg = dependency_manager.check_dependency("ffmpeg")
272
280
  cmd = [
273
281
  ffmpeg,
@@ -281,7 +289,7 @@ class QueueManager:
281
289
 
282
290
  def _download_generic(self, url, path, item):
283
291
  import requests
284
- item["eta"] = "..."
292
+ self._update_progress(item, eta="...")
285
293
  with requests.get(url, stream=True) as r:
286
294
  r.raise_for_status()
287
295
  total = int(r.headers.get('content-length', 0))
@@ -292,17 +300,20 @@ class QueueManager:
292
300
  for chunk in r.iter_content(chunk_size=8192):
293
301
  f.write(chunk)
294
302
  downloaded += len(chunk)
295
- item["progress"] = int((downloaded / total) * 100)
303
+ progress = int((downloaded / total) * 100)
296
304
 
297
305
  elapsed = time.time() - start_time
298
306
  if elapsed > 0:
299
307
  speed = downloaded / elapsed
300
308
  remaining = total - downloaded
301
309
  eta_s = remaining / speed
302
- item["eta"] = f"{int(eta_s)}s"
310
+ self._update_progress(item, progress=progress, eta=f"{int(eta_s)}s")
303
311
  else:
304
312
  with open(path, 'wb') as f:
305
313
  for chunk in r.iter_content(chunk_size=8192):
306
314
  f.write(chunk)
307
315
 
316
+ def clear_completed(self):
317
+ self.db.clear_completed_queue()
318
+
308
319
  queue_manager = QueueManager()
@@ -0,0 +1,263 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from typing import List, Dict, Optional
5
+ from weeb_cli.config import config
6
+ from weeb_cli.services.progress import progress_tracker
7
+
8
+ class LocalLibrary:
9
+ def __init__(self):
10
+ self._db = None
11
+
12
+ @property
13
+ def db(self):
14
+ if self._db is None:
15
+ from weeb_cli.services.database import db
16
+ self._db = db
17
+ return self._db
18
+
19
+ def get_all_sources(self) -> List[Dict]:
20
+ sources = []
21
+
22
+ download_dir = Path(config.get("download_dir"))
23
+ if download_dir.exists():
24
+ sources.append({
25
+ "path": str(download_dir),
26
+ "name": "İndirilenler",
27
+ "type": "local",
28
+ "available": True
29
+ })
30
+
31
+ for drive in self.db.get_external_drives():
32
+ path = Path(drive["path"])
33
+ sources.append({
34
+ "path": drive["path"],
35
+ "name": drive["name"],
36
+ "type": "external",
37
+ "available": path.exists()
38
+ })
39
+
40
+ return sources
41
+
42
+ def scan_library(self, source_path: str = None) -> List[Dict]:
43
+ if source_path:
44
+ return self._scan_folder(Path(source_path))
45
+
46
+ download_dir = Path(config.get("download_dir"))
47
+ return self._scan_folder(download_dir)
48
+
49
+ def scan_all_sources(self) -> List[Dict]:
50
+ all_anime = []
51
+ seen_titles = set()
52
+
53
+ for source in self.get_all_sources():
54
+ if not source["available"]:
55
+ continue
56
+
57
+ anime_list = self._scan_folder(Path(source["path"]))
58
+ for anime in anime_list:
59
+ anime["source"] = source["name"]
60
+ anime["source_path"] = source["path"]
61
+
62
+ key = anime["title"].lower()
63
+ if key not in seen_titles:
64
+ seen_titles.add(key)
65
+ all_anime.append(anime)
66
+
67
+ return sorted(all_anime, key=lambda x: x["title"].lower())
68
+
69
+ def _scan_folder(self, folder: Path) -> List[Dict]:
70
+ if not folder.exists():
71
+ return []
72
+
73
+ anime_list = []
74
+
75
+ for anime_folder in folder.iterdir():
76
+ if not anime_folder.is_dir():
77
+ continue
78
+
79
+ episodes = self._scan_anime_folder(anime_folder)
80
+ if episodes:
81
+ anime_list.append({
82
+ "title": anime_folder.name,
83
+ "path": str(anime_folder),
84
+ "episodes": episodes,
85
+ "episode_count": len(episodes)
86
+ })
87
+
88
+ return sorted(anime_list, key=lambda x: x["title"].lower())
89
+
90
+ def _scan_anime_folder(self, folder: Path) -> List[Dict]:
91
+ episodes = []
92
+ video_extensions = {'.mp4', '.mkv', '.avi', '.webm', '.m4v'}
93
+
94
+ for file in folder.iterdir():
95
+ if file.is_file() and file.suffix.lower() in video_extensions:
96
+ ep_num = self._extract_episode_number(file.name)
97
+ episodes.append({
98
+ "filename": file.name,
99
+ "path": str(file),
100
+ "number": ep_num,
101
+ "size": file.stat().st_size
102
+ })
103
+
104
+ return sorted(episodes, key=lambda x: x["number"])
105
+
106
+ def _extract_episode_number(self, filename: str) -> int:
107
+ patterns = [
108
+ r'S\d+B(\d+)',
109
+ r'[Ee]p?(\d+)',
110
+ r'[Bb]ölüm\s*(\d+)',
111
+ r'[Ee]pisode\s*(\d+)',
112
+ r'- (\d+)',
113
+ r'\[(\d+)\]',
114
+ r'(\d+)\.',
115
+ ]
116
+
117
+ for pattern in patterns:
118
+ match = re.search(pattern, filename, re.IGNORECASE)
119
+ if match:
120
+ return int(match.group(1))
121
+
122
+ return 0
123
+
124
+ def get_anime_progress(self, anime_title: str) -> Dict:
125
+ slug = self._title_to_slug(anime_title)
126
+ return progress_tracker.get_anime_progress(slug)
127
+
128
+ def mark_episode_watched(self, anime_title: str, ep_number: int, total_episodes: int):
129
+ slug = self._title_to_slug(anime_title)
130
+ progress_tracker.mark_watched(slug, ep_number, title=anime_title, total_episodes=total_episodes)
131
+
132
+ def _title_to_slug(self, title: str) -> str:
133
+ slug = title.lower()
134
+ slug = re.sub(r'[^a-z0-9\s-]', '', slug)
135
+ slug = re.sub(r'\s+', '-', slug)
136
+ return slug
137
+
138
+ def get_next_episode(self, anime_title: str, episodes: List[Dict]) -> Optional[Dict]:
139
+ progress = self.get_anime_progress(anime_title)
140
+ last_watched = progress.get("last_watched", 0)
141
+
142
+ for ep in episodes:
143
+ if ep["number"] > last_watched:
144
+ return ep
145
+
146
+ return episodes[0] if episodes else None
147
+
148
+ def format_size(self, size_bytes: int) -> str:
149
+ for unit in ['B', 'KB', 'MB', 'GB']:
150
+ if size_bytes < 1024:
151
+ return f"{size_bytes:.1f} {unit}"
152
+ size_bytes /= 1024
153
+ return f"{size_bytes:.1f} TB"
154
+
155
+ def add_external_drive(self, path: str, name: str = None):
156
+ self.db.add_external_drive(path, name)
157
+
158
+ def remove_external_drive(self, path: str):
159
+ self.db.remove_external_drive(path)
160
+
161
+ def rename_external_drive(self, path: str, name: str):
162
+ self.db.update_drive_name(path, name)
163
+
164
+ def get_external_drives(self):
165
+ return self.db.get_external_drives()
166
+
167
+ def index_source(self, source_path: str, source_name: str):
168
+ path = Path(source_path)
169
+ if not path.exists():
170
+ return 0
171
+
172
+ self.db.clear_source_index(source_path)
173
+
174
+ count = 0
175
+ for anime_folder in path.iterdir():
176
+ if not anime_folder.is_dir():
177
+ continue
178
+
179
+ episodes = self._scan_anime_folder(anime_folder)
180
+ if episodes:
181
+ self.db.index_anime(
182
+ title=anime_folder.name,
183
+ source_path=source_path,
184
+ source_name=source_name,
185
+ folder_path=str(anime_folder),
186
+ episode_count=len(episodes)
187
+ )
188
+ count += 1
189
+
190
+ return count
191
+
192
+ def smart_index_source(self, source_path: str, source_name: str):
193
+ path = Path(source_path)
194
+ if not path.exists():
195
+ return 0
196
+
197
+ indexed = {a["folder_path"]: a for a in self.db.get_all_indexed_anime() if a["source_path"] == source_path}
198
+
199
+ current_folders = set()
200
+ added = 0
201
+
202
+ for anime_folder in path.iterdir():
203
+ if not anime_folder.is_dir():
204
+ continue
205
+
206
+ folder_path = str(anime_folder)
207
+ current_folders.add(folder_path)
208
+
209
+ episodes = self._scan_anime_folder(anime_folder)
210
+ if not episodes:
211
+ continue
212
+
213
+ if folder_path in indexed:
214
+ if indexed[folder_path]["episode_count"] != len(episodes):
215
+ self.db.index_anime(
216
+ title=anime_folder.name,
217
+ source_path=source_path,
218
+ source_name=source_name,
219
+ folder_path=folder_path,
220
+ episode_count=len(episodes)
221
+ )
222
+ else:
223
+ self.db.index_anime(
224
+ title=anime_folder.name,
225
+ source_path=source_path,
226
+ source_name=source_name,
227
+ folder_path=folder_path,
228
+ episode_count=len(episodes)
229
+ )
230
+ added += 1
231
+
232
+ for folder_path in indexed:
233
+ if folder_path not in current_folders:
234
+ self.db.remove_indexed_anime(folder_path)
235
+
236
+ return added
237
+
238
+ def smart_index_all(self):
239
+ total = 0
240
+ for source in self.get_all_sources():
241
+ if source["available"]:
242
+ total += self.smart_index_source(source["path"], source["name"])
243
+ return total
244
+
245
+ def index_all_sources(self):
246
+ total = 0
247
+ for source in self.get_all_sources():
248
+ if source["available"]:
249
+ total += self.index_source(source["path"], source["name"])
250
+ return total
251
+
252
+ def get_indexed_anime(self) -> List[Dict]:
253
+ return self.db.get_all_indexed_anime()
254
+
255
+ def search_all_indexed(self, query: str) -> List[Dict]:
256
+ if not query:
257
+ return self.db.get_all_indexed_anime()
258
+ return self.db.search_indexed_anime(query)
259
+
260
+ def is_source_available(self, source_path: str) -> bool:
261
+ return Path(source_path).exists()
262
+
263
+ local_library = LocalLibrary()
@@ -0,0 +1,55 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from datetime import datetime
5
+ from weeb_cli.config import config
6
+
7
+ _logger = None
8
+
9
+ def get_logger():
10
+ global _logger
11
+ if _logger is None:
12
+ _logger = _setup_logger()
13
+ return _logger
14
+
15
+ def _setup_logger():
16
+ logger = logging.getLogger("weeb-cli")
17
+ logger.setLevel(logging.DEBUG)
18
+ logger.handlers = []
19
+
20
+ if config.get("debug_mode", False):
21
+ log_dir = Path.home() / ".weeb-cli" / "logs"
22
+ log_dir.mkdir(parents=True, exist_ok=True)
23
+
24
+ log_file = log_dir / f"debug_{datetime.now().strftime('%Y%m%d')}.log"
25
+
26
+ handler = logging.FileHandler(log_file, encoding='utf-8')
27
+ handler.setLevel(logging.DEBUG)
28
+
29
+ formatter = logging.Formatter(
30
+ '%(asctime)s | %(levelname)s | %(module)s:%(lineno)d | %(message)s',
31
+ datefmt='%H:%M:%S'
32
+ )
33
+ handler.setFormatter(formatter)
34
+ logger.addHandler(handler)
35
+ else:
36
+ logger.addHandler(logging.NullHandler())
37
+
38
+ return logger
39
+
40
+ def debug(msg, *args):
41
+ get_logger().debug(msg, *args)
42
+
43
+ def info(msg, *args):
44
+ get_logger().info(msg, *args)
45
+
46
+ def warning(msg, *args):
47
+ get_logger().warning(msg, *args)
48
+
49
+ def error(msg, *args):
50
+ get_logger().error(msg, *args)
51
+
52
+ def reload():
53
+ global _logger
54
+ _logger = None
55
+ get_logger()
@@ -0,0 +1,41 @@
1
+ import platform
2
+ import subprocess
3
+ import threading
4
+
5
+ def send_notification(title: str, message: str):
6
+ threading.Thread(target=_send_notification_sync, args=(title, message), daemon=True).start()
7
+
8
+ def _send_notification_sync(title: str, message: str):
9
+ system = platform.system()
10
+
11
+ try:
12
+ if system == "Windows":
13
+ _notify_windows(title, message)
14
+ elif system == "Darwin":
15
+ _notify_macos(title, message)
16
+ else:
17
+ _notify_linux(title, message)
18
+ except:
19
+ pass
20
+
21
+ def _notify_windows(title: str, message: str):
22
+ try:
23
+ from win10toast import ToastNotifier
24
+ toaster = ToastNotifier()
25
+ toaster.show_toast(title, message, duration=5, threaded=True)
26
+ return
27
+ except ImportError:
28
+ pass
29
+
30
+ try:
31
+ import ctypes
32
+ ctypes.windll.user32.MessageBoxW(0, message, title, 0x40)
33
+ except:
34
+ pass
35
+
36
+ def _notify_macos(title: str, message: str):
37
+ script = f'display notification "{message}" with title "{title}"'
38
+ subprocess.run(["osascript", "-e", script], capture_output=True)
39
+
40
+ def _notify_linux(title: str, message: str):
41
+ subprocess.run(["notify-send", title, message], capture_output=True)