weeb-cli 2.5.0__tar.gz → 2.6.1__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.
- {weeb_cli-2.5.0/weeb_cli.egg-info → weeb_cli-2.6.1}/PKG-INFO +17 -3
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/README.md +16 -2
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/pyproject.toml +1 -1
- weeb_cli-2.6.1/tests/test_cache.py +90 -0
- weeb_cli-2.6.1/tests/test_exceptions.py +45 -0
- weeb_cli-2.6.1/tests/test_sanitizer.py +69 -0
- weeb_cli-2.6.1/weeb_cli/__init__.py +1 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/commands/search.py +12 -2
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/commands/settings.py +0 -3
- weeb_cli-2.6.1/weeb_cli/exceptions.py +44 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/allanime.py +47 -4
- weeb_cli-2.6.1/weeb_cli/services/cache.py +171 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/database.py +20 -1
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/downloader.py +81 -10
- weeb_cli-2.6.1/weeb_cli/services/error_handler.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/scraper.py +13 -1
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/watch.py +8 -1
- weeb_cli-2.6.1/weeb_cli/ui/__init__.py +1 -0
- weeb_cli-2.6.1/weeb_cli/utils/__init__.py +4 -0
- weeb_cli-2.6.1/weeb_cli/utils/sanitizer.py +79 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1/weeb_cli.egg-info}/PKG-INFO +17 -3
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli.egg-info/SOURCES.txt +9 -1
- weeb_cli-2.5.0/weeb_cli/__init__.py +0 -1
- weeb_cli-2.5.0/weeb_cli/ui/__init__.py +0 -1
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/LICENSE +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/setup.cfg +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/__main__.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/commands/downloads.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/commands/setup.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/commands/watchlist.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/config.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/i18n.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/locales/en.json +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/locales/tr.json +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/main.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/__init__.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/animecix.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/anizle.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/base.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/extractors/__init__.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/extractors/megacloud.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/hianime.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/registry.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/providers/turkanime.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/__init__.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/dependency_manager.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/details.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/discord_rpc.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/local_library.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/logger.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/notifier.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/player.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/progress.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/search.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/shortcuts.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/tracker.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/services/updater.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/ui/header.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/ui/menu.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli/ui/prompt.py +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli.egg-info/dependency_links.txt +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli.egg-info/entry_points.txt +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli.egg-info/requires.txt +0 -0
- {weeb_cli-2.5.0 → weeb_cli-2.6.1}/weeb_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weeb-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: Tarayıcı yok, reklam yok, dikkat dağıtıcı unsur yok. Sadece siz ve eşsiz bir anime izleme deneyimi.
|
|
5
5
|
Author-email: ewgsta <ewgst@proton.me>
|
|
6
6
|
License-Expression: CC-BY-NC-ND-4.0
|
|
@@ -44,6 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
<a href="https://github.com/ewgsta/weeb-cli/releases"><img src="https://img.shields.io/github/v/release/ewgsta/weeb-cli?style=flat-square" alt="Release"></a>
|
|
45
45
|
<a href="https://github.com/ewgsta/weeb-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-CC%20BY--NC--ND%204.0-blue?style=flat-square" alt="License"></a>
|
|
46
46
|
<a href="https://github.com/ewgsta/weeb-cli/stargazers"><img src="https://img.shields.io/github/stars/ewgsta/weeb-cli?style=flat-square" alt="Stars"></a>
|
|
47
|
+
<a href="https://github.com/ewgsta/weeb-cli/actions"><img src="https://img.shields.io/github/actions/workflow/status/ewgsta/weeb-cli/tests.yml?style=flat-square" alt="Tests"></a>
|
|
47
48
|
</p>
|
|
48
49
|
|
|
49
50
|
<p align="center">
|
|
@@ -178,14 +179,27 @@ Yapılandırma: `~/.weeb-cli/weeb.db` (SQLite)
|
|
|
178
179
|
- [x] Veritabanı yedekleme/geri yükleme
|
|
179
180
|
- [x] Klavye kısayolları
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
## Gelecek Planlar
|
|
183
|
+
|
|
184
|
+
### v2.6.0 (Planlanan)
|
|
185
|
+
- [ ] Async/await refactoring
|
|
186
|
+
- [ ] Download strategy pattern
|
|
187
|
+
- [ ] Token şifreleme
|
|
188
|
+
- [ ] Progress bar iyileştirmesi
|
|
189
|
+
- [ ] Plugin sistemi
|
|
190
|
+
|
|
191
|
+
### v2.7.0 (Planlanan)
|
|
182
192
|
- [ ] Anime önerileri
|
|
183
193
|
- [ ] Toplu işlemler
|
|
184
194
|
- [ ] İzleme istatistikleri (grafik)
|
|
185
195
|
- [ ] Tema desteği
|
|
186
196
|
- [ ] Altyazı indirme
|
|
187
|
-
|
|
197
|
+
|
|
198
|
+
### v3.0.0 (Uzun Vadeli)
|
|
199
|
+
- [ ] Web UI (opsiyonel)
|
|
200
|
+
- [ ] Torrent desteği
|
|
188
201
|
- [ ] Watch party
|
|
202
|
+
- [ ] Mobile app entegrasyonu
|
|
189
203
|
|
|
190
204
|
---
|
|
191
205
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
<a href="https://github.com/ewgsta/weeb-cli/releases"><img src="https://img.shields.io/github/v/release/ewgsta/weeb-cli?style=flat-square" alt="Release"></a>
|
|
13
13
|
<a href="https://github.com/ewgsta/weeb-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-CC%20BY--NC--ND%204.0-blue?style=flat-square" alt="License"></a>
|
|
14
14
|
<a href="https://github.com/ewgsta/weeb-cli/stargazers"><img src="https://img.shields.io/github/stars/ewgsta/weeb-cli?style=flat-square" alt="Stars"></a>
|
|
15
|
+
<a href="https://github.com/ewgsta/weeb-cli/actions"><img src="https://img.shields.io/github/actions/workflow/status/ewgsta/weeb-cli/tests.yml?style=flat-square" alt="Tests"></a>
|
|
15
16
|
</p>
|
|
16
17
|
|
|
17
18
|
<p align="center">
|
|
@@ -146,14 +147,27 @@ Yapılandırma: `~/.weeb-cli/weeb.db` (SQLite)
|
|
|
146
147
|
- [x] Veritabanı yedekleme/geri yükleme
|
|
147
148
|
- [x] Klavye kısayolları
|
|
148
149
|
|
|
149
|
-
|
|
150
|
+
## Gelecek Planlar
|
|
151
|
+
|
|
152
|
+
### v2.6.0 (Planlanan)
|
|
153
|
+
- [ ] Async/await refactoring
|
|
154
|
+
- [ ] Download strategy pattern
|
|
155
|
+
- [ ] Token şifreleme
|
|
156
|
+
- [ ] Progress bar iyileştirmesi
|
|
157
|
+
- [ ] Plugin sistemi
|
|
158
|
+
|
|
159
|
+
### v2.7.0 (Planlanan)
|
|
150
160
|
- [ ] Anime önerileri
|
|
151
161
|
- [ ] Toplu işlemler
|
|
152
162
|
- [ ] İzleme istatistikleri (grafik)
|
|
153
163
|
- [ ] Tema desteği
|
|
154
164
|
- [ ] Altyazı indirme
|
|
155
|
-
|
|
165
|
+
|
|
166
|
+
### v3.0.0 (Uzun Vadeli)
|
|
167
|
+
- [ ] Web UI (opsiyonel)
|
|
168
|
+
- [ ] Torrent desteği
|
|
156
169
|
- [ ] Watch party
|
|
170
|
+
- [ ] Mobile app entegrasyonu
|
|
157
171
|
|
|
158
172
|
---
|
|
159
173
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "weeb-cli"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.6.1"
|
|
8
8
|
description = "Tarayıcı yok, reklam yok, dikkat dağıtıcı unsur yok. Sadece siz ve eşsiz bir anime izleme deneyimi."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "ewgsta", email = "ewgst@proton.me" }]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for cache manager."""
|
|
2
|
+
import pytest
|
|
3
|
+
import time
|
|
4
|
+
from weeb_cli.services.cache import CacheManager, cached
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestCacheManager:
|
|
8
|
+
"""Test cache manager functionality."""
|
|
9
|
+
|
|
10
|
+
def test_set_and_get(self, temp_dir):
|
|
11
|
+
"""Test basic set and get operations."""
|
|
12
|
+
cache = CacheManager(temp_dir / "cache")
|
|
13
|
+
|
|
14
|
+
cache.set("test_key", "test_value")
|
|
15
|
+
result = cache.get("test_key")
|
|
16
|
+
|
|
17
|
+
assert result == "test_value"
|
|
18
|
+
|
|
19
|
+
def test_expiration(self, temp_dir):
|
|
20
|
+
"""Test cache expiration."""
|
|
21
|
+
cache = CacheManager(temp_dir / "cache")
|
|
22
|
+
|
|
23
|
+
cache.set("test_key", "test_value")
|
|
24
|
+
|
|
25
|
+
assert cache.get("test_key", max_age=10) == "test_value"
|
|
26
|
+
|
|
27
|
+
time.sleep(0.1)
|
|
28
|
+
assert cache.get("test_key", max_age=0) is None
|
|
29
|
+
|
|
30
|
+
def test_delete(self, temp_dir):
|
|
31
|
+
"""Test cache deletion."""
|
|
32
|
+
cache = CacheManager(temp_dir / "cache")
|
|
33
|
+
|
|
34
|
+
cache.set("test_key", "test_value")
|
|
35
|
+
cache.delete("test_key")
|
|
36
|
+
|
|
37
|
+
assert cache.get("test_key") is None
|
|
38
|
+
|
|
39
|
+
def test_clear(self, temp_dir):
|
|
40
|
+
"""Test clearing all cache."""
|
|
41
|
+
cache = CacheManager(temp_dir / "cache")
|
|
42
|
+
|
|
43
|
+
cache.set("key1", "value1")
|
|
44
|
+
cache.set("key2", "value2")
|
|
45
|
+
cache.clear()
|
|
46
|
+
|
|
47
|
+
assert cache.get("key1") is None
|
|
48
|
+
assert cache.get("key2") is None
|
|
49
|
+
|
|
50
|
+
def test_complex_values(self, temp_dir):
|
|
51
|
+
"""Test caching complex data structures."""
|
|
52
|
+
cache = CacheManager(temp_dir / "cache")
|
|
53
|
+
|
|
54
|
+
data = {
|
|
55
|
+
"list": [1, 2, 3],
|
|
56
|
+
"dict": {"nested": "value"},
|
|
57
|
+
"tuple": (1, 2, 3)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cache.set("complex", data)
|
|
61
|
+
result = cache.get("complex")
|
|
62
|
+
|
|
63
|
+
assert result == data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestCachedDecorator:
|
|
67
|
+
"""Test cached decorator."""
|
|
68
|
+
|
|
69
|
+
def test_function_caching(self, temp_dir):
|
|
70
|
+
"""Test that function results are cached."""
|
|
71
|
+
cache = CacheManager(temp_dir / "cache")
|
|
72
|
+
call_count = 0
|
|
73
|
+
|
|
74
|
+
@cached(max_age=10, cache_manager=cache)
|
|
75
|
+
def expensive_function(x):
|
|
76
|
+
nonlocal call_count
|
|
77
|
+
call_count += 1
|
|
78
|
+
return x * 2
|
|
79
|
+
|
|
80
|
+
result1 = expensive_function(5)
|
|
81
|
+
assert result1 == 10
|
|
82
|
+
assert call_count == 1
|
|
83
|
+
|
|
84
|
+
result2 = expensive_function(5)
|
|
85
|
+
assert result2 == 10
|
|
86
|
+
assert call_count == 1
|
|
87
|
+
|
|
88
|
+
result3 = expensive_function(10)
|
|
89
|
+
assert result3 == 20
|
|
90
|
+
assert call_count == 2
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tests for custom exceptions."""
|
|
2
|
+
import pytest
|
|
3
|
+
from weeb_cli.exceptions import (
|
|
4
|
+
WeebCLIError,
|
|
5
|
+
ProviderError,
|
|
6
|
+
DownloadError,
|
|
7
|
+
NetworkError,
|
|
8
|
+
AuthenticationError
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestExceptions:
|
|
13
|
+
"""Test custom exception hierarchy."""
|
|
14
|
+
|
|
15
|
+
def test_base_exception(self):
|
|
16
|
+
"""Test base exception."""
|
|
17
|
+
exc = WeebCLIError("Test message", "TEST_CODE")
|
|
18
|
+
assert str(exc) == "TEST_CODE: Test message"
|
|
19
|
+
assert exc.message == "Test message"
|
|
20
|
+
assert exc.code == "TEST_CODE"
|
|
21
|
+
|
|
22
|
+
def test_exception_without_code(self):
|
|
23
|
+
"""Test exception without error code."""
|
|
24
|
+
exc = WeebCLIError("Test message")
|
|
25
|
+
assert str(exc) == "Test message"
|
|
26
|
+
|
|
27
|
+
def test_provider_error(self):
|
|
28
|
+
"""Test provider error."""
|
|
29
|
+
exc = ProviderError("Provider failed", "PROVIDER_ERROR")
|
|
30
|
+
assert isinstance(exc, WeebCLIError)
|
|
31
|
+
|
|
32
|
+
def test_download_error(self):
|
|
33
|
+
"""Test download error."""
|
|
34
|
+
exc = DownloadError("Download failed", "DOWNLOAD_ERROR")
|
|
35
|
+
assert isinstance(exc, WeebCLIError)
|
|
36
|
+
|
|
37
|
+
def test_network_error(self):
|
|
38
|
+
"""Test network error."""
|
|
39
|
+
exc = NetworkError("Network failed", "NETWORK_ERROR")
|
|
40
|
+
assert isinstance(exc, WeebCLIError)
|
|
41
|
+
|
|
42
|
+
def test_authentication_error(self):
|
|
43
|
+
"""Test authentication error."""
|
|
44
|
+
exc = AuthenticationError("Auth failed", "AUTH_ERROR")
|
|
45
|
+
assert isinstance(exc, WeebCLIError)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Tests for sanitizer utilities."""
|
|
2
|
+
import pytest
|
|
3
|
+
from weeb_cli.utils.sanitizer import sanitize_filename, validate_url
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestSanitizeFilename:
|
|
7
|
+
"""Test filename sanitization."""
|
|
8
|
+
|
|
9
|
+
def test_basic_sanitization(self):
|
|
10
|
+
"""Test basic character removal."""
|
|
11
|
+
assert sanitize_filename("test<>file") == "testfile"
|
|
12
|
+
assert sanitize_filename('test"file') == "testfile"
|
|
13
|
+
assert sanitize_filename("test|file") == "testfile"
|
|
14
|
+
|
|
15
|
+
def test_path_traversal_prevention(self):
|
|
16
|
+
"""Test path traversal attack prevention."""
|
|
17
|
+
assert ".." not in sanitize_filename("../../../etc/passwd")
|
|
18
|
+
assert ".." not in sanitize_filename("test..file")
|
|
19
|
+
|
|
20
|
+
def test_empty_input(self):
|
|
21
|
+
"""Test empty input handling."""
|
|
22
|
+
assert sanitize_filename("") == "unnamed"
|
|
23
|
+
assert sanitize_filename(" ") == "unnamed"
|
|
24
|
+
|
|
25
|
+
def test_unicode_handling(self):
|
|
26
|
+
"""Test unicode character handling."""
|
|
27
|
+
result = sanitize_filename("Anime - 第1話")
|
|
28
|
+
assert result # Should not be empty
|
|
29
|
+
assert len(result) > 0
|
|
30
|
+
|
|
31
|
+
def test_length_limiting(self):
|
|
32
|
+
"""Test filename length limiting."""
|
|
33
|
+
long_name = "a" * 300
|
|
34
|
+
result = sanitize_filename(long_name, max_length=200)
|
|
35
|
+
assert len(result) <= 200
|
|
36
|
+
|
|
37
|
+
def test_windows_reserved_chars(self):
|
|
38
|
+
"""Test Windows reserved character removal."""
|
|
39
|
+
assert sanitize_filename("file:name") == "filename"
|
|
40
|
+
assert sanitize_filename("file*name") == "filename"
|
|
41
|
+
assert sanitize_filename("file?name") == "filename"
|
|
42
|
+
|
|
43
|
+
def test_leading_trailing_dots(self):
|
|
44
|
+
"""Test removal of leading/trailing dots and spaces."""
|
|
45
|
+
assert sanitize_filename("...file...") == "file"
|
|
46
|
+
assert sanitize_filename(" file ") == "file"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestValidateUrl:
|
|
50
|
+
"""Test URL validation."""
|
|
51
|
+
|
|
52
|
+
def test_valid_urls(self):
|
|
53
|
+
"""Test valid URL patterns."""
|
|
54
|
+
assert validate_url("https://example.com")
|
|
55
|
+
assert validate_url("http://example.com")
|
|
56
|
+
assert validate_url("https://example.com/path")
|
|
57
|
+
assert validate_url("https://example.com:8080")
|
|
58
|
+
|
|
59
|
+
def test_invalid_urls(self):
|
|
60
|
+
"""Test invalid URL patterns."""
|
|
61
|
+
assert not validate_url("")
|
|
62
|
+
assert not validate_url("not a url")
|
|
63
|
+
assert not validate_url("ftp://example.com")
|
|
64
|
+
assert not validate_url("javascript:alert(1)")
|
|
65
|
+
|
|
66
|
+
def test_localhost(self):
|
|
67
|
+
"""Test localhost URLs."""
|
|
68
|
+
assert validate_url("http://localhost")
|
|
69
|
+
assert validate_url("http://localhost:8080")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.6.1"
|
|
@@ -9,6 +9,7 @@ from weeb_cli.services.player import player
|
|
|
9
9
|
from weeb_cli.services.progress import progress_tracker
|
|
10
10
|
from weeb_cli.services.downloader import queue_manager
|
|
11
11
|
from weeb_cli.services.scraper import scraper
|
|
12
|
+
from weeb_cli.services.cache import get_cache
|
|
12
13
|
import time
|
|
13
14
|
|
|
14
15
|
console = Console()
|
|
@@ -57,8 +58,15 @@ def search_anime():
|
|
|
57
58
|
|
|
58
59
|
progress_tracker.add_search_history(query.strip())
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
cache = get_cache()
|
|
62
|
+
cache_key = f"search:{query.strip()}"
|
|
63
|
+
data = cache.get(cache_key, max_age=1800) # 30 min cache
|
|
64
|
+
|
|
65
|
+
if data is None:
|
|
66
|
+
with console.status(i18n.get("search.searching"), spinner="dots"):
|
|
67
|
+
data = search(query)
|
|
68
|
+
if data:
|
|
69
|
+
cache.set(cache_key, data)
|
|
62
70
|
|
|
63
71
|
if data is None:
|
|
64
72
|
time.sleep(1)
|
|
@@ -160,6 +168,8 @@ def show_anime_details(anime):
|
|
|
160
168
|
show_header(details.get("title", ""))
|
|
161
169
|
|
|
162
170
|
if show_desc and desc:
|
|
171
|
+
if len(desc) > 500:
|
|
172
|
+
desc = desc[:497] + "..."
|
|
163
173
|
console.print(f"\n[dim]{desc}[/dim]\n", justify="left")
|
|
164
174
|
|
|
165
175
|
try:
|
|
@@ -174,7 +174,6 @@ def change_language():
|
|
|
174
174
|
lang_code = langs[selected]
|
|
175
175
|
i18n.set_language(lang_code)
|
|
176
176
|
|
|
177
|
-
# Dil için varsayılan kaynağı ayarla
|
|
178
177
|
sources = scraper.get_sources_for_lang(lang_code)
|
|
179
178
|
if sources:
|
|
180
179
|
config.set("scraping_source", sources[0])
|
|
@@ -757,8 +756,6 @@ def restore_backup():
|
|
|
757
756
|
except KeyboardInterrupt:
|
|
758
757
|
pass
|
|
759
758
|
|
|
760
|
-
# dursun zaman dokunduğunda sana yine yakınlaştığımda bana öyle baktığındaaaaaaa sessizzceeee uyandığında sana yine dokunduğumda dursun zaman
|
|
761
|
-
|
|
762
759
|
|
|
763
760
|
def shortcuts_menu():
|
|
764
761
|
from weeb_cli.services.shortcuts import shortcut_manager, DEFAULT_SHORTCUTS
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Custom exception hierarchy for Weeb CLI."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WeebCLIError(Exception):
|
|
5
|
+
"""Base exception for all Weeb CLI errors."""
|
|
6
|
+
def __init__(self, message: str = "", code: str = ""):
|
|
7
|
+
self.message = message
|
|
8
|
+
self.code = code
|
|
9
|
+
super().__init__(f"{code}: {message}" if code else message)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProviderError(WeebCLIError):
|
|
13
|
+
"""Raised when a provider operation fails."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DownloadError(WeebCLIError):
|
|
18
|
+
"""Raised when a download operation fails."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NetworkError(WeebCLIError):
|
|
23
|
+
"""Raised when a network operation fails."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(WeebCLIError):
|
|
28
|
+
"""Raised when authentication fails."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DatabaseError(WeebCLIError):
|
|
33
|
+
"""Raised when a database operation fails."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ValidationError(WeebCLIError):
|
|
38
|
+
"""Raised when input validation fails."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DependencyError(WeebCLIError):
|
|
43
|
+
"""Raised when a required dependency is missing."""
|
|
44
|
+
pass
|
|
@@ -130,23 +130,54 @@ class AllAnimeProvider(BaseProvider):
|
|
|
130
130
|
|
|
131
131
|
results.append(AnimeResult(
|
|
132
132
|
id=anime_id,
|
|
133
|
-
title=
|
|
133
|
+
title=name,
|
|
134
134
|
type="series"
|
|
135
135
|
))
|
|
136
136
|
|
|
137
137
|
return results
|
|
138
138
|
|
|
139
139
|
def get_details(self, anime_id: str) -> Optional[AnimeDetails]:
|
|
140
|
-
|
|
140
|
+
gql = '''query ($showId: String!) {
|
|
141
|
+
show(_id: $showId) {
|
|
142
|
+
_id
|
|
143
|
+
name
|
|
144
|
+
description
|
|
145
|
+
thumbnail
|
|
146
|
+
availableEpisodesDetail
|
|
147
|
+
}
|
|
148
|
+
}'''
|
|
149
|
+
|
|
150
|
+
variables = {"showId": anime_id}
|
|
151
|
+
data = _graphql_request(gql, variables)
|
|
152
|
+
|
|
153
|
+
if not data or 'data' not in data:
|
|
154
|
+
return None
|
|
141
155
|
|
|
142
|
-
|
|
156
|
+
show = data.get('data', {}).get('show', {})
|
|
157
|
+
|
|
158
|
+
if not show:
|
|
143
159
|
return None
|
|
144
160
|
|
|
145
|
-
title = anime_id.replace('-', ' ').title()
|
|
161
|
+
title = show.get('name', anime_id.replace('-', ' ').title())
|
|
162
|
+
description = show.get('description')
|
|
163
|
+
thumbnail = show.get('thumbnail')
|
|
164
|
+
|
|
165
|
+
ep_detail = show.get('availableEpisodesDetail', {})
|
|
166
|
+
ep_list = ep_detail.get(self.mode, [])
|
|
167
|
+
|
|
168
|
+
episodes = []
|
|
169
|
+
for i, ep_num in enumerate(sorted(ep_list, key=lambda x: float(x) if x.replace('.', '').isdigit() else 0)):
|
|
170
|
+
episodes.append(Episode(
|
|
171
|
+
id=f"{anime_id}::ep={ep_num}",
|
|
172
|
+
number=i + 1,
|
|
173
|
+
title=f"Episode {ep_num}"
|
|
174
|
+
))
|
|
146
175
|
|
|
147
176
|
return AnimeDetails(
|
|
148
177
|
id=anime_id,
|
|
149
178
|
title=title,
|
|
179
|
+
description=description,
|
|
180
|
+
cover=thumbnail,
|
|
150
181
|
episodes=episodes,
|
|
151
182
|
total_episodes=len(episodes)
|
|
152
183
|
)
|
|
@@ -180,6 +211,10 @@ class AllAnimeProvider(BaseProvider):
|
|
|
180
211
|
return episodes
|
|
181
212
|
|
|
182
213
|
def get_streams(self, anime_id: str, episode_id: str) -> List[StreamLink]:
|
|
214
|
+
from weeb_cli.services.logger import debug, error
|
|
215
|
+
|
|
216
|
+
debug(f"[ALLANIME] get_streams: anime_id={anime_id}, episode_id={episode_id}")
|
|
217
|
+
|
|
183
218
|
if '::ep=' in episode_id:
|
|
184
219
|
parts = episode_id.split('::ep=')
|
|
185
220
|
show_id = parts[0]
|
|
@@ -188,6 +223,8 @@ class AllAnimeProvider(BaseProvider):
|
|
|
188
223
|
show_id = anime_id
|
|
189
224
|
ep_no = episode_id
|
|
190
225
|
|
|
226
|
+
debug(f"[ALLANIME] Parsed: show_id={show_id}, ep_no={ep_no}, mode={self.mode}")
|
|
227
|
+
|
|
191
228
|
gql = '''query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
|
192
229
|
episode(showId: $showId translationType: $translationType episodeString: $episodeString) {
|
|
193
230
|
episodeString
|
|
@@ -201,8 +238,14 @@ class AllAnimeProvider(BaseProvider):
|
|
|
201
238
|
"episodeString": ep_no
|
|
202
239
|
}
|
|
203
240
|
|
|
241
|
+
debug(f"[ALLANIME] GraphQL variables: {variables}")
|
|
242
|
+
|
|
204
243
|
data = _graphql_request(gql, variables)
|
|
244
|
+
|
|
245
|
+
debug(f"[ALLANIME] GraphQL response: {data}")
|
|
246
|
+
|
|
205
247
|
if not data or 'data' not in data:
|
|
248
|
+
error(f"[ALLANIME] No data in response")
|
|
206
249
|
return []
|
|
207
250
|
|
|
208
251
|
episode = data.get('data', {}).get('episode', {})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Simple file-based cache manager for Weeb CLI."""
|
|
2
|
+
import pickle
|
|
3
|
+
import time
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional, Callable
|
|
7
|
+
from functools import wraps
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CacheManager:
|
|
11
|
+
"""Simple file-based cache with TTL support."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, cache_dir: Path):
|
|
14
|
+
"""
|
|
15
|
+
Initialize cache manager.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
cache_dir: Directory to store cache files
|
|
19
|
+
"""
|
|
20
|
+
self.cache_dir = cache_dir
|
|
21
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
self._memory_cache = {}
|
|
23
|
+
|
|
24
|
+
def _get_cache_key(self, key: str) -> str:
|
|
25
|
+
"""Generate a safe cache key from input."""
|
|
26
|
+
return hashlib.md5(key.encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
def get(self, key: str, max_age: int = 3600) -> Optional[Any]:
|
|
29
|
+
"""
|
|
30
|
+
Get value from cache if not expired.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
key: Cache key
|
|
34
|
+
max_age: Maximum age in seconds (default: 1 hour)
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Cached value or None if expired/not found
|
|
38
|
+
"""
|
|
39
|
+
if key in self._memory_cache:
|
|
40
|
+
value, timestamp = self._memory_cache[key]
|
|
41
|
+
if time.time() - timestamp < max_age:
|
|
42
|
+
return value
|
|
43
|
+
else:
|
|
44
|
+
del self._memory_cache[key]
|
|
45
|
+
|
|
46
|
+
cache_key = self._get_cache_key(key)
|
|
47
|
+
cache_file = self.cache_dir / f"{cache_key}.cache"
|
|
48
|
+
|
|
49
|
+
if cache_file.exists():
|
|
50
|
+
age = time.time() - cache_file.stat().st_mtime
|
|
51
|
+
if age < max_age:
|
|
52
|
+
try:
|
|
53
|
+
with open(cache_file, 'rb') as f:
|
|
54
|
+
value = pickle.load(f)
|
|
55
|
+
self._memory_cache[key] = (value, time.time())
|
|
56
|
+
return value
|
|
57
|
+
except (pickle.PickleError, EOFError):
|
|
58
|
+
cache_file.unlink(missing_ok=True)
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def set(self, key: str, value: Any) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Store value in cache.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
key: Cache key
|
|
68
|
+
value: Value to cache
|
|
69
|
+
"""
|
|
70
|
+
self._memory_cache[key] = (value, time.time())
|
|
71
|
+
|
|
72
|
+
cache_key = self._get_cache_key(key)
|
|
73
|
+
cache_file = self.cache_dir / f"{cache_key}.cache"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with open(cache_file, 'wb') as f:
|
|
77
|
+
pickle.dump(value, f)
|
|
78
|
+
except (pickle.PickleError, OSError):
|
|
79
|
+
pass # Fail silently for cache writes
|
|
80
|
+
|
|
81
|
+
def delete(self, key: str) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Delete value from cache.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key: Cache key
|
|
87
|
+
"""
|
|
88
|
+
self._memory_cache.pop(key, None)
|
|
89
|
+
|
|
90
|
+
cache_key = self._get_cache_key(key)
|
|
91
|
+
cache_file = self.cache_dir / f"{cache_key}.cache"
|
|
92
|
+
cache_file.unlink(missing_ok=True)
|
|
93
|
+
|
|
94
|
+
def clear(self) -> None:
|
|
95
|
+
"""Clear all cache."""
|
|
96
|
+
self._memory_cache.clear()
|
|
97
|
+
|
|
98
|
+
for cache_file in self.cache_dir.glob("*.cache"):
|
|
99
|
+
cache_file.unlink(missing_ok=True)
|
|
100
|
+
|
|
101
|
+
def cleanup(self, max_age: int = 86400) -> int:
|
|
102
|
+
"""
|
|
103
|
+
Remove expired cache files.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
max_age: Maximum age in seconds (default: 24 hours)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Number of files removed
|
|
110
|
+
"""
|
|
111
|
+
removed = 0
|
|
112
|
+
cutoff = time.time() - max_age
|
|
113
|
+
|
|
114
|
+
for cache_file in self.cache_dir.glob("*.cache"):
|
|
115
|
+
if cache_file.stat().st_mtime < cutoff:
|
|
116
|
+
cache_file.unlink(missing_ok=True)
|
|
117
|
+
removed += 1
|
|
118
|
+
|
|
119
|
+
return removed
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cached(max_age: int = 3600, cache_manager: Optional[CacheManager] = None):
|
|
123
|
+
"""
|
|
124
|
+
Decorator to cache function results.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
max_age: Cache TTL in seconds
|
|
128
|
+
cache_manager: CacheManager instance (uses global if None)
|
|
129
|
+
|
|
130
|
+
Example:
|
|
131
|
+
@cached(max_age=1800)
|
|
132
|
+
def expensive_function(arg1, arg2):
|
|
133
|
+
pass
|
|
134
|
+
"""
|
|
135
|
+
def decorator(func: Callable) -> Callable:
|
|
136
|
+
@wraps(func)
|
|
137
|
+
def wrapper(*args, **kwargs):
|
|
138
|
+
key_parts = [func.__name__]
|
|
139
|
+
key_parts.extend(str(arg) for arg in args)
|
|
140
|
+
key_parts.extend(f"{k}={v}" for k, v in sorted(kwargs.items()))
|
|
141
|
+
cache_key = ":".join(key_parts)
|
|
142
|
+
|
|
143
|
+
cm = cache_manager or _get_global_cache()
|
|
144
|
+
|
|
145
|
+
result = cm.get(cache_key, max_age)
|
|
146
|
+
if result is not None:
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
result = func(*args, **kwargs)
|
|
150
|
+
cm.set(cache_key, result)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
return wrapper
|
|
154
|
+
return decorator
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_global_cache = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_global_cache() -> CacheManager:
|
|
161
|
+
"""Get or create global cache instance."""
|
|
162
|
+
global _global_cache
|
|
163
|
+
if _global_cache is None:
|
|
164
|
+
from weeb_cli.config import CONFIG_DIR
|
|
165
|
+
_global_cache = CacheManager(CONFIG_DIR / "cache")
|
|
166
|
+
return _global_cache
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_cache() -> CacheManager:
|
|
170
|
+
"""Get global cache instance."""
|
|
171
|
+
return _get_global_cache()
|
|
@@ -26,6 +26,8 @@ class Database:
|
|
|
26
26
|
|
|
27
27
|
def _init_db(self):
|
|
28
28
|
with self._conn() as conn:
|
|
29
|
+
conn.execute('PRAGMA journal_mode=WAL')
|
|
30
|
+
|
|
29
31
|
conn.executescript('''
|
|
30
32
|
CREATE TABLE IF NOT EXISTS config (
|
|
31
33
|
key TEXT PRIMARY KEY,
|
|
@@ -58,7 +60,9 @@ class Database:
|
|
|
58
60
|
progress INTEGER DEFAULT 0,
|
|
59
61
|
eta TEXT DEFAULT '?',
|
|
60
62
|
error TEXT,
|
|
61
|
-
added_at REAL
|
|
63
|
+
added_at REAL,
|
|
64
|
+
retry_count INTEGER DEFAULT 0,
|
|
65
|
+
speed TEXT
|
|
62
66
|
);
|
|
63
67
|
|
|
64
68
|
CREATE TABLE IF NOT EXISTS external_drives (
|
|
@@ -83,6 +87,21 @@ class Database:
|
|
|
83
87
|
CREATE INDEX IF NOT EXISTS idx_anime_title ON anime_index(title);
|
|
84
88
|
CREATE INDEX IF NOT EXISTS idx_anime_source ON anime_index(source_path);
|
|
85
89
|
''')
|
|
90
|
+
|
|
91
|
+
self._migrate_columns()
|
|
92
|
+
|
|
93
|
+
def _migrate_columns(self):
|
|
94
|
+
"""Add missing columns to existing tables."""
|
|
95
|
+
with self._conn() as conn:
|
|
96
|
+
try:
|
|
97
|
+
conn.execute('SELECT retry_count FROM download_queue LIMIT 1')
|
|
98
|
+
except:
|
|
99
|
+
conn.execute('ALTER TABLE download_queue ADD COLUMN retry_count INTEGER DEFAULT 0')
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
conn.execute('SELECT speed FROM download_queue LIMIT 1')
|
|
103
|
+
except:
|
|
104
|
+
conn.execute('ALTER TABLE download_queue ADD COLUMN speed TEXT')
|
|
86
105
|
|
|
87
106
|
def _migrate_from_json(self):
|
|
88
107
|
config_dir = Path.home() / ".weeb-cli"
|
|
@@ -7,6 +7,8 @@ from pathlib import Path
|
|
|
7
7
|
from rich.console import Console
|
|
8
8
|
from weeb_cli.config import config
|
|
9
9
|
from weeb_cli.services.dependency_manager import dependency_manager
|
|
10
|
+
from weeb_cli.utils.sanitizer import sanitize_filename
|
|
11
|
+
from weeb_cli.exceptions import DownloadError
|
|
10
12
|
|
|
11
13
|
console = Console()
|
|
12
14
|
|
|
@@ -110,7 +112,8 @@ class QueueManager:
|
|
|
110
112
|
return added
|
|
111
113
|
|
|
112
114
|
def _sanitize_filename(self, name):
|
|
113
|
-
|
|
115
|
+
"""Sanitize filename for safe file system operations."""
|
|
116
|
+
return sanitize_filename(name)
|
|
114
117
|
|
|
115
118
|
def _manage_queue(self):
|
|
116
119
|
while self.running:
|
|
@@ -203,8 +206,6 @@ class QueueManager:
|
|
|
203
206
|
filename = f"{safe_title} - S{season}B{ep_num}.mp4"
|
|
204
207
|
output_path = anime_dir / filename
|
|
205
208
|
|
|
206
|
-
debug(f"Getting streams for {item['slug']} - {item['episode_id']}")
|
|
207
|
-
|
|
208
209
|
stream_data = get_streams(item["slug"], item["episode_id"])
|
|
209
210
|
|
|
210
211
|
if not stream_data:
|
|
@@ -212,16 +213,89 @@ class QueueManager:
|
|
|
212
213
|
if scraper.last_error:
|
|
213
214
|
err_msg = f"{err_msg}: {scraper.last_error}"
|
|
214
215
|
log_error(f"Download failed - {err_msg}")
|
|
215
|
-
raise
|
|
216
|
+
raise DownloadError(err_msg, code="NO_STREAM_DATA")
|
|
217
|
+
|
|
218
|
+
if isinstance(stream_data, dict) and "data" in stream_data and "links" in stream_data["data"]:
|
|
219
|
+
links = stream_data["data"]["links"]
|
|
220
|
+
if not links:
|
|
221
|
+
raise DownloadError("Stream links boş", code="EMPTY_STREAM_LINKS")
|
|
222
|
+
|
|
223
|
+
debug(f"Found {len(links)} stream sources, trying each one...")
|
|
224
|
+
|
|
225
|
+
last_error = None
|
|
226
|
+
for idx, link in enumerate(links):
|
|
227
|
+
stream_url = link.get("url")
|
|
228
|
+
server_name = link.get("server", "unknown")
|
|
229
|
+
|
|
230
|
+
if not stream_url:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
debug(f"Trying source {idx + 1}/{len(links)}: {server_name} - {stream_url[:80]}...")
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
self._try_download(stream_url, output_path, item)
|
|
237
|
+
debug(f"Download successful with source: {server_name}")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
last_error = str(e)
|
|
242
|
+
log_error(f"Source {server_name} failed: {e}")
|
|
243
|
+
if idx < len(links) - 1:
|
|
244
|
+
debug(f"Trying next source...")
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
raise DownloadError(f"Tüm kaynaklar başarısız. Son hata: {last_error}", code="ALL_SOURCES_FAILED")
|
|
216
248
|
|
|
217
249
|
stream_url = self._extract_url(stream_data)
|
|
218
250
|
|
|
251
|
+
debug(f"Extracted stream URL: {stream_url}")
|
|
252
|
+
|
|
219
253
|
if not stream_url:
|
|
220
254
|
log_error(f"Download failed - Stream URL bulunamadı. Data: {stream_data}")
|
|
221
|
-
raise
|
|
255
|
+
raise DownloadError("Stream URL bulunamadı", code="NO_STREAM_URL")
|
|
222
256
|
|
|
223
257
|
debug(f"Stream URL found: {stream_url[:80]}...")
|
|
224
258
|
|
|
259
|
+
self._try_download(stream_url, output_path, item)
|
|
260
|
+
|
|
261
|
+
def _extract_all_urls(self, data):
|
|
262
|
+
"""Extract all available stream URLs with their server names."""
|
|
263
|
+
PRIORITY = ["ALUCARD", "AMATERASU", "SIBNET", "MP4UPLOAD", "UQLOAD"]
|
|
264
|
+
|
|
265
|
+
results = []
|
|
266
|
+
|
|
267
|
+
if isinstance(data, dict):
|
|
268
|
+
node = data
|
|
269
|
+
for _ in range(3):
|
|
270
|
+
if "data" in node and isinstance(node["data"], (dict, list)):
|
|
271
|
+
node = node["data"]
|
|
272
|
+
else:
|
|
273
|
+
break
|
|
274
|
+
|
|
275
|
+
sources = node if isinstance(node, list) else node.get("links") or node.get("sources")
|
|
276
|
+
if sources and isinstance(sources, list) and len(sources) > 0:
|
|
277
|
+
def get_priority(s):
|
|
278
|
+
server = (s.get("server") or "").upper()
|
|
279
|
+
for i, p in enumerate(PRIORITY):
|
|
280
|
+
if p in server:
|
|
281
|
+
return i
|
|
282
|
+
return 999
|
|
283
|
+
|
|
284
|
+
sorted_sources = sorted(sources, key=get_priority)
|
|
285
|
+
|
|
286
|
+
for src in sorted_sources:
|
|
287
|
+
url = src.get("url")
|
|
288
|
+
server = src.get("server", "unknown")
|
|
289
|
+
if url:
|
|
290
|
+
results.append((url, server))
|
|
291
|
+
|
|
292
|
+
elif isinstance(node, dict) and "url" in node:
|
|
293
|
+
results.append((node["url"], node.get("server", "unknown")))
|
|
294
|
+
|
|
295
|
+
return results
|
|
296
|
+
|
|
297
|
+
def _try_download(self, stream_url, output_path, item):
|
|
298
|
+
"""Try to download from a stream URL."""
|
|
225
299
|
is_hls = ".m3u8" in stream_url
|
|
226
300
|
|
|
227
301
|
if is_hls:
|
|
@@ -308,7 +382,6 @@ class QueueManager:
|
|
|
308
382
|
match = re.search(r'\((\d+)%\)', line)
|
|
309
383
|
progress = int(match.group(1)) if match else None
|
|
310
384
|
|
|
311
|
-
# Parse speed (e.g., "DL:1.2MiB")
|
|
312
385
|
speed = None
|
|
313
386
|
speed_match = re.search(r'DL:([\d.]+[KMG]?i?B)', line)
|
|
314
387
|
if speed_match:
|
|
@@ -319,7 +392,7 @@ class QueueManager:
|
|
|
319
392
|
pass
|
|
320
393
|
|
|
321
394
|
if process.returncode != 0:
|
|
322
|
-
raise
|
|
395
|
+
raise DownloadError("Aria2 download failed", code="ARIA2_FAILED")
|
|
323
396
|
|
|
324
397
|
def _download_ytdlp(self, url, path, item):
|
|
325
398
|
ytdlp = dependency_manager.check_dependency("yt-dlp")
|
|
@@ -344,7 +417,6 @@ class QueueManager:
|
|
|
344
417
|
progress = float(p_str)
|
|
345
418
|
eta = line.split("ETA")[-1].strip() if "ETA" in line else None
|
|
346
419
|
|
|
347
|
-
# Parse speed (e.g., "at 1.5MiB/s")
|
|
348
420
|
speed = None
|
|
349
421
|
speed_match = re.search(r'at\s+([\d.]+[KMG]?i?B/s)', line)
|
|
350
422
|
if speed_match:
|
|
@@ -354,7 +426,7 @@ class QueueManager:
|
|
|
354
426
|
except:
|
|
355
427
|
pass
|
|
356
428
|
if process.returncode != 0:
|
|
357
|
-
raise
|
|
429
|
+
raise DownloadError("yt-dlp download failed", code="YTDLP_FAILED")
|
|
358
430
|
|
|
359
431
|
def _download_ffmpeg(self, url, path, item):
|
|
360
432
|
self._update_progress(item, eta="")
|
|
@@ -390,7 +462,6 @@ class QueueManager:
|
|
|
390
462
|
remaining = total - downloaded
|
|
391
463
|
eta_s = remaining / speed_bytes if speed_bytes > 0 else 0
|
|
392
464
|
|
|
393
|
-
# Format speed
|
|
394
465
|
if speed_bytes >= 1024 * 1024:
|
|
395
466
|
speed_str = f"{speed_bytes / (1024*1024):.1f}MB/s"
|
|
396
467
|
elif speed_bytes >= 1024:
|
|
File without changes
|
|
@@ -68,16 +68,28 @@ class Scraper:
|
|
|
68
68
|
return []
|
|
69
69
|
|
|
70
70
|
def get_streams(self, anime_id: str, episode_id: str) -> List[StreamLink]:
|
|
71
|
+
from weeb_cli.services.logger import debug, error
|
|
72
|
+
|
|
71
73
|
self.last_error = None
|
|
74
|
+
debug(f"[SCRAPER] get_streams called: anime_id={anime_id}, episode_id={episode_id}")
|
|
75
|
+
debug(f"[SCRAPER] Current provider: {self._provider_name}")
|
|
76
|
+
|
|
72
77
|
if not self.provider:
|
|
78
|
+
error("[SCRAPER] No provider available!")
|
|
73
79
|
return []
|
|
80
|
+
|
|
74
81
|
try:
|
|
75
|
-
|
|
82
|
+
debug(f"[SCRAPER] Calling provider.get_streams()")
|
|
83
|
+
result = self.provider.get_streams(anime_id, episode_id)
|
|
84
|
+
debug(f"[SCRAPER] Provider returned {len(result) if result else 0} streams")
|
|
85
|
+
return result
|
|
76
86
|
except ProviderError as e:
|
|
77
87
|
self.last_error = e.code
|
|
88
|
+
error(f"[SCRAPER] ProviderError: {e.code} - {e.message}")
|
|
78
89
|
return []
|
|
79
90
|
except Exception as e:
|
|
80
91
|
self.last_error = str(e)
|
|
92
|
+
error(f"[SCRAPER] Exception: {str(e)}")
|
|
81
93
|
return []
|
|
82
94
|
|
|
83
95
|
def get_available_sources(self) -> List[dict]:
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
from weeb_cli.services.scraper import scraper
|
|
2
|
+
from weeb_cli.services.logger import debug
|
|
2
3
|
|
|
3
4
|
def get_streams(anime_id, episode_id):
|
|
5
|
+
debug(f"[WATCH] Getting streams for {anime_id} - {episode_id}")
|
|
4
6
|
streams = scraper.get_streams(anime_id, episode_id)
|
|
7
|
+
debug(f"[WATCH] Scraper returned {len(streams) if streams else 0} streams")
|
|
8
|
+
|
|
5
9
|
if not streams:
|
|
10
|
+
debug(f"[WATCH] No streams found, last_error: {scraper.last_error}")
|
|
6
11
|
return None
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
result = {
|
|
9
14
|
"data": {
|
|
10
15
|
"links": [
|
|
11
16
|
{
|
|
@@ -17,3 +22,5 @@ def get_streams(anime_id, episode_id):
|
|
|
17
22
|
]
|
|
18
23
|
}
|
|
19
24
|
}
|
|
25
|
+
debug(f"[WATCH] Returning {len(result['data']['links'])} links")
|
|
26
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Utility functions for sanitizing user input and filenames."""
|
|
2
|
+
import re
|
|
3
|
+
import unicodedata
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def sanitize_filename(name: str, max_length: int = 200) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Sanitize a filename to be safe for all operating systems.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
name: The filename to sanitize
|
|
13
|
+
max_length: Maximum length of the filename (default: 200)
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
A safe filename string
|
|
17
|
+
"""
|
|
18
|
+
if not name:
|
|
19
|
+
return "unnamed"
|
|
20
|
+
|
|
21
|
+
name = unicodedata.normalize('NFKD', name)
|
|
22
|
+
|
|
23
|
+
name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '', name)
|
|
24
|
+
|
|
25
|
+
name = name.replace('..', '')
|
|
26
|
+
|
|
27
|
+
name = name.strip('. ')
|
|
28
|
+
|
|
29
|
+
if not name:
|
|
30
|
+
return "unnamed"
|
|
31
|
+
|
|
32
|
+
if len(name) > max_length:
|
|
33
|
+
name = name[:max_length]
|
|
34
|
+
|
|
35
|
+
return name
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sanitize_path(path: str) -> Path:
|
|
39
|
+
"""
|
|
40
|
+
Sanitize a full path to prevent directory traversal.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path: The path to sanitize
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A safe Path object
|
|
47
|
+
"""
|
|
48
|
+
p = Path(path)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
resolved = p.resolve()
|
|
52
|
+
return resolved
|
|
53
|
+
except (OSError, RuntimeError):
|
|
54
|
+
raise ValueError(f"Invalid path: {path}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_url(url: str) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Validate if a URL is safe to use.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
url: The URL to validate
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
True if valid, False otherwise
|
|
66
|
+
"""
|
|
67
|
+
if not url:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
url_pattern = re.compile(
|
|
71
|
+
r'^https?://'
|
|
72
|
+
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
|
|
73
|
+
r'localhost|'
|
|
74
|
+
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
|
75
|
+
r'(?::\d+)?'
|
|
76
|
+
r'(?:/?|[/?]\S+)$', re.IGNORECASE
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return bool(url_pattern.match(url))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weeb-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.1
|
|
4
4
|
Summary: Tarayıcı yok, reklam yok, dikkat dağıtıcı unsur yok. Sadece siz ve eşsiz bir anime izleme deneyimi.
|
|
5
5
|
Author-email: ewgsta <ewgst@proton.me>
|
|
6
6
|
License-Expression: CC-BY-NC-ND-4.0
|
|
@@ -44,6 +44,7 @@ Dynamic: license-file
|
|
|
44
44
|
<a href="https://github.com/ewgsta/weeb-cli/releases"><img src="https://img.shields.io/github/v/release/ewgsta/weeb-cli?style=flat-square" alt="Release"></a>
|
|
45
45
|
<a href="https://github.com/ewgsta/weeb-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-CC%20BY--NC--ND%204.0-blue?style=flat-square" alt="License"></a>
|
|
46
46
|
<a href="https://github.com/ewgsta/weeb-cli/stargazers"><img src="https://img.shields.io/github/stars/ewgsta/weeb-cli?style=flat-square" alt="Stars"></a>
|
|
47
|
+
<a href="https://github.com/ewgsta/weeb-cli/actions"><img src="https://img.shields.io/github/actions/workflow/status/ewgsta/weeb-cli/tests.yml?style=flat-square" alt="Tests"></a>
|
|
47
48
|
</p>
|
|
48
49
|
|
|
49
50
|
<p align="center">
|
|
@@ -178,14 +179,27 @@ Yapılandırma: `~/.weeb-cli/weeb.db` (SQLite)
|
|
|
178
179
|
- [x] Veritabanı yedekleme/geri yükleme
|
|
179
180
|
- [x] Klavye kısayolları
|
|
180
181
|
|
|
181
|
-
|
|
182
|
+
## Gelecek Planlar
|
|
183
|
+
|
|
184
|
+
### v2.6.0 (Planlanan)
|
|
185
|
+
- [ ] Async/await refactoring
|
|
186
|
+
- [ ] Download strategy pattern
|
|
187
|
+
- [ ] Token şifreleme
|
|
188
|
+
- [ ] Progress bar iyileştirmesi
|
|
189
|
+
- [ ] Plugin sistemi
|
|
190
|
+
|
|
191
|
+
### v2.7.0 (Planlanan)
|
|
182
192
|
- [ ] Anime önerileri
|
|
183
193
|
- [ ] Toplu işlemler
|
|
184
194
|
- [ ] İzleme istatistikleri (grafik)
|
|
185
195
|
- [ ] Tema desteği
|
|
186
196
|
- [ ] Altyazı indirme
|
|
187
|
-
|
|
197
|
+
|
|
198
|
+
### v3.0.0 (Uzun Vadeli)
|
|
199
|
+
- [ ] Web UI (opsiyonel)
|
|
200
|
+
- [ ] Torrent desteği
|
|
188
201
|
- [ ] Watch party
|
|
202
|
+
- [ ] Mobile app entegrasyonu
|
|
189
203
|
|
|
190
204
|
---
|
|
191
205
|
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
LICENSE
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
|
+
tests/test_cache.py
|
|
5
|
+
tests/test_exceptions.py
|
|
6
|
+
tests/test_sanitizer.py
|
|
4
7
|
weeb_cli/__init__.py
|
|
5
8
|
weeb_cli/__main__.py
|
|
6
9
|
weeb_cli/config.py
|
|
10
|
+
weeb_cli/exceptions.py
|
|
7
11
|
weeb_cli/i18n.py
|
|
8
12
|
weeb_cli/main.py
|
|
9
13
|
weeb_cli.egg-info/PKG-INFO
|
|
@@ -30,11 +34,13 @@ weeb_cli/providers/turkanime.py
|
|
|
30
34
|
weeb_cli/providers/extractors/__init__.py
|
|
31
35
|
weeb_cli/providers/extractors/megacloud.py
|
|
32
36
|
weeb_cli/services/__init__.py
|
|
37
|
+
weeb_cli/services/cache.py
|
|
33
38
|
weeb_cli/services/database.py
|
|
34
39
|
weeb_cli/services/dependency_manager.py
|
|
35
40
|
weeb_cli/services/details.py
|
|
36
41
|
weeb_cli/services/discord_rpc.py
|
|
37
42
|
weeb_cli/services/downloader.py
|
|
43
|
+
weeb_cli/services/error_handler.py
|
|
38
44
|
weeb_cli/services/local_library.py
|
|
39
45
|
weeb_cli/services/logger.py
|
|
40
46
|
weeb_cli/services/notifier.py
|
|
@@ -49,4 +55,6 @@ weeb_cli/services/watch.py
|
|
|
49
55
|
weeb_cli/ui/__init__.py
|
|
50
56
|
weeb_cli/ui/header.py
|
|
51
57
|
weeb_cli/ui/menu.py
|
|
52
|
-
weeb_cli/ui/prompt.py
|
|
58
|
+
weeb_cli/ui/prompt.py
|
|
59
|
+
weeb_cli/utils/__init__.py
|
|
60
|
+
weeb_cli/utils/sanitizer.py
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.5.0"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Init UI package
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|