weeb-cli 2.16.2__tar.gz → 2.17.0__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.16.2 → weeb_cli-2.17.0}/PKG-INFO +20 -4
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/pyproject.toml +14 -2
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_sanitizer.py +2 -2
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_sanitizer_security.py +8 -3
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/__init__.py +1 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/watch_flow.py +10 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/serve.py +8 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/serve_restful.py +44 -15
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/config.py +10 -2
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/constants.py +1 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/i18n.py +7 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/locales/de.json +2 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/locales/en.json +2 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/locales/pl.json +2 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/locales/tr.json +2 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/main.py +8 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/base.py +58 -30
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/de/aniworld.py +6 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/registry.py +21 -17
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/sdk.py +5 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/cache.py +22 -6
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/dependency_manager.py +1 -1
- weeb_cli-2.17.0/weeb_cli/services/download/manager.py +89 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/error_handler.py +13 -2
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/headless_downloader.py +1 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/local_library.py +12 -1
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/logger.py +19 -6
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/notifier.py +24 -13
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/scraper.py +5 -0
- weeb_cli-2.17.0/weeb_cli/services/telemetry.py +417 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/updater.py +5 -3
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/utils/sanitizer.py +2 -3
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli.egg-info/PKG-INFO +20 -4
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli.egg-info/SOURCES.txt +1 -0
- weeb_cli-2.17.0/weeb_cli.egg-info/requires.txt +57 -0
- weeb_cli-2.16.2/weeb_cli/services/download/manager.py +0 -69
- weeb_cli-2.16.2/weeb_cli.egg-info/requires.txt +0 -39
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/LICENSE +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/README.md +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/setup.cfg +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_anilist_tracker.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_aniworld_provider.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_api.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_cache.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_exceptions.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_kitsu_tracker.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_mal_tracker.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_providers.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_restful_api.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/tests/test_sdk.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/__main__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/api.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/downloads.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/library.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/anime_details.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/download_flow.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/episode_utils.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/search_handlers.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search/stream_utils.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/search.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_backup.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_cache.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_config.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_download.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_drives.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_menu.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_shortcuts.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings/settings_trackers.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/settings.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/setup.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/commands/watchlist.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/exceptions.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/de/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/en/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/en/allanime.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/en/hianime.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/doodstream.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/filemoon.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/megacloud.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/streamtape.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/vidoza.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/extractors/voe.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/pl/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/pl/docchi.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/tr/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/tr/animecix.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/tr/anizle.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/tr/turkanime.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/providers/tr/weeb.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/_base.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/_tracker_base.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/aniskip.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/database.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/details.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/discord_rpc.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/context.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/queue.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/aria2.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/base.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/ffmpeg.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/generic.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/download/strategies/ytdlp.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/downloader.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/player.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/progress.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/search.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/shortcuts.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/stream_validator.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/tracker.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/services/watch.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/templates/anilist_error.html +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/templates/anilist_success.html +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/templates/mal_error.html +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/templates/mal_success.html +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/ui/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/ui/header.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/ui/menu.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/ui/prompt.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/utils/__init__.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/utils/decorators.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli/utils/shortcuts.py +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli.egg-info/dependency_links.txt +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/weeb_cli.egg-info/entry_points.txt +0 -0
- {weeb_cli-2.16.2 → weeb_cli-2.17.0}/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.17.0
|
|
4
4
|
Summary: No browser, no ads, no distractions. Just you and an unparalleled anime viewing experience.
|
|
5
5
|
Author-email: ewgsta <ewgst@proton.me>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -42,9 +42,25 @@ Provides-Extra: dev
|
|
|
42
42
|
Requires-Dist: build; extra == "dev"
|
|
43
43
|
Requires-Dist: pyinstaller; extra == "dev"
|
|
44
44
|
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
45
|
-
Requires-Dist: pytest-cov; extra == "dev"
|
|
46
|
-
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
47
|
-
Requires-Dist: pytest-mock; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "dev"
|
|
46
|
+
Requires-Dist: pytest-asyncio>=1.3.0; extra == "dev"
|
|
47
|
+
Requires-Dist: pytest-mock>=3.15.1; extra == "dev"
|
|
48
|
+
Provides-Extra: docs
|
|
49
|
+
Requires-Dist: mkdocs>=1.6.1; extra == "docs"
|
|
50
|
+
Requires-Dist: mkdocs-material>=9.7.6; extra == "docs"
|
|
51
|
+
Requires-Dist: mkdocstrings[python]>=1.0.4; extra == "docs"
|
|
52
|
+
Requires-Dist: pymdown-extensions>=10.21.2; extra == "docs"
|
|
53
|
+
Requires-Dist: mkdocs-static-i18n>=1.3.1; extra == "docs"
|
|
54
|
+
Provides-Extra: telemetry
|
|
55
|
+
Requires-Dist: opentelemetry-api<2.0,>=1.25.0; extra == "telemetry"
|
|
56
|
+
Requires-Dist: opentelemetry-sdk<2.0,>=1.25.0; extra == "telemetry"
|
|
57
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2.0,>=1.25.0; extra == "telemetry"
|
|
58
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2.0,>=1.25.0; extra == "telemetry"
|
|
59
|
+
Requires-Dist: opentelemetry-instrumentation-requests<1.0,>=0.46b0; extra == "telemetry"
|
|
60
|
+
Requires-Dist: opentelemetry-instrumentation-urllib3<1.0,>=0.46b0; extra == "telemetry"
|
|
61
|
+
Requires-Dist: opentelemetry-instrumentation-sqlite3<1.0,>=0.46b0; extra == "telemetry"
|
|
62
|
+
Requires-Dist: opentelemetry-instrumentation-flask<1.0,>=0.46b0; extra == "telemetry"
|
|
63
|
+
Requires-Dist: opentelemetry-instrumentation-logging<1.0,>=0.46b0; extra == "telemetry"
|
|
48
64
|
Dynamic: license-file
|
|
49
65
|
|
|
50
66
|
<p align="center">
|
|
@@ -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.17.0"
|
|
8
8
|
description = "No browser, no ads, no distractions. Just you and an unparalleled anime viewing experience."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "ewgsta", email = "ewgst@proton.me" }]
|
|
@@ -38,7 +38,19 @@ dependencies = [
|
|
|
38
38
|
serve = ["flask>=3.0", "pyyaml>=6.0"]
|
|
39
39
|
serve-restful = ["flask>=3.0", "flask-cors>=4.0"]
|
|
40
40
|
shortcuts = ["pywin32; sys_platform == 'win32'", "winshell; sys_platform == 'win32'"]
|
|
41
|
-
dev = ["build", "pyinstaller", "pytest>=7.0.0", "pytest-cov", "pytest-asyncio", "pytest-mock"]
|
|
41
|
+
dev = ["build", "pyinstaller", "pytest>=7.0.0", "pytest-cov>=7.1.0", "pytest-asyncio>=1.3.0", "pytest-mock>=3.15.1"]
|
|
42
|
+
docs = ["mkdocs>=1.6.1", "mkdocs-material>=9.7.6", "mkdocstrings[python]>=1.0.4", "pymdown-extensions>=10.21.2", "mkdocs-static-i18n>=1.3.1"]
|
|
43
|
+
telemetry = [
|
|
44
|
+
"opentelemetry-api>=1.25.0,<2.0",
|
|
45
|
+
"opentelemetry-sdk>=1.25.0,<2.0",
|
|
46
|
+
"opentelemetry-exporter-otlp-proto-http>=1.25.0,<2.0",
|
|
47
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.25.0,<2.0",
|
|
48
|
+
"opentelemetry-instrumentation-requests>=0.46b0,<1.0",
|
|
49
|
+
"opentelemetry-instrumentation-urllib3>=0.46b0,<1.0",
|
|
50
|
+
"opentelemetry-instrumentation-sqlite3>=0.46b0,<1.0",
|
|
51
|
+
"opentelemetry-instrumentation-flask>=0.46b0,<1.0",
|
|
52
|
+
"opentelemetry-instrumentation-logging>=0.46b0,<1.0",
|
|
53
|
+
]
|
|
42
54
|
|
|
43
55
|
[tool.setuptools.packages.find]
|
|
44
56
|
where = ["."]
|
|
@@ -13,8 +13,8 @@ class TestSanitizeFilename:
|
|
|
13
13
|
assert ".." not in sanitize_filename("test..file")
|
|
14
14
|
|
|
15
15
|
def test_empty_input(self):
|
|
16
|
-
assert sanitize_filename("") == "
|
|
17
|
-
assert sanitize_filename(" ") == "
|
|
16
|
+
assert sanitize_filename("") == "untitled"
|
|
17
|
+
assert sanitize_filename(" ") == "untitled"
|
|
18
18
|
|
|
19
19
|
def test_unicode_handling(self):
|
|
20
20
|
result = sanitize_filename("Anime - 第1話")
|
|
@@ -7,9 +7,14 @@ class TestSanitizerSecurity:
|
|
|
7
7
|
|
|
8
8
|
def test_path_traversal_prevention(self):
|
|
9
9
|
"""Test that path traversal attempts are blocked."""
|
|
10
|
-
|
|
11
|
-
assert
|
|
12
|
-
assert
|
|
10
|
+
result = sanitize_filename("../../etc/passwd")
|
|
11
|
+
assert ".." not in result
|
|
12
|
+
assert "/" not in result
|
|
13
|
+
assert "\\" not in result
|
|
14
|
+
result2 = sanitize_filename("..\\..\\windows\\system32")
|
|
15
|
+
assert ".." not in result2
|
|
16
|
+
result3 = sanitize_filename("../../../root")
|
|
17
|
+
assert ".." not in result3
|
|
13
18
|
|
|
14
19
|
def test_windows_reserved_names(self):
|
|
15
20
|
"""Test Windows reserved filenames are handled."""
|
|
@@ -314,6 +314,16 @@ def _update_trackers(details, slug, ep_num=None):
|
|
|
314
314
|
("Kitsu", kitsu_tracker)
|
|
315
315
|
]
|
|
316
316
|
|
|
317
|
+
# First, try to sync any pending updates
|
|
318
|
+
for name, tracker in trackers:
|
|
319
|
+
if tracker.is_authenticated():
|
|
320
|
+
try:
|
|
321
|
+
synced_count = tracker.sync_pending()
|
|
322
|
+
if synced_count > 0:
|
|
323
|
+
console.print(f"[green]✓ {name}: {synced_count} {i18n.t('watchlist.pending_synced', 'pending updates synced')}[/green]")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
log_error(f"Failed to sync pending updates for {name}: {e}")
|
|
326
|
+
|
|
317
327
|
updated = []
|
|
318
328
|
pending = []
|
|
319
329
|
|
|
@@ -259,6 +259,10 @@ def serve_torznab(
|
|
|
259
259
|
from weeb_cli.config import config as weeb_config
|
|
260
260
|
weeb_config.set_headless(True)
|
|
261
261
|
|
|
262
|
+
# Initialize telemetry
|
|
263
|
+
from weeb_cli.services.telemetry import init_telemetry
|
|
264
|
+
init_telemetry(environment="server-torznab")
|
|
265
|
+
|
|
262
266
|
logging.basicConfig(
|
|
263
267
|
level=logging.INFO,
|
|
264
268
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
@@ -300,6 +304,10 @@ def serve_torznab(
|
|
|
300
304
|
# Flask app
|
|
301
305
|
flask_app = Flask(__name__)
|
|
302
306
|
|
|
307
|
+
# Instrument Flask app for telemetry
|
|
308
|
+
from weeb_cli.services.telemetry import instrument_flask_app
|
|
309
|
+
instrument_flask_app(flask_app)
|
|
310
|
+
|
|
303
311
|
@flask_app.route("/api", methods=["GET"])
|
|
304
312
|
def torznab_api():
|
|
305
313
|
t = request.args.get("t", "")
|
|
@@ -37,6 +37,39 @@ def _quality_score(q: str) -> int:
|
|
|
37
37
|
return 1
|
|
38
38
|
return 0
|
|
39
39
|
|
|
40
|
+
def _serialize_anime_result(result):
|
|
41
|
+
"""Serialize AnimeResult to dict."""
|
|
42
|
+
return {
|
|
43
|
+
"id": result.id,
|
|
44
|
+
"title": result.title,
|
|
45
|
+
"type": getattr(result, "type", ""),
|
|
46
|
+
"cover": getattr(result, "cover", ""),
|
|
47
|
+
"year": getattr(result, "year", None),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
def _serialize_episode(episode):
|
|
51
|
+
"""Serialize Episode to dict."""
|
|
52
|
+
return {
|
|
53
|
+
"id": episode.id,
|
|
54
|
+
"number": episode.number,
|
|
55
|
+
"title": getattr(episode, "title", ""),
|
|
56
|
+
"season": getattr(episode, "season", 1),
|
|
57
|
+
"url": getattr(episode, "url", ""),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def _serialize_stream(stream):
|
|
61
|
+
"""Serialize StreamLink to dict."""
|
|
62
|
+
return {
|
|
63
|
+
"url": stream.url,
|
|
64
|
+
"quality": stream.quality,
|
|
65
|
+
"server": getattr(stream, "server", "default"),
|
|
66
|
+
"headers": getattr(stream, "headers", {}),
|
|
67
|
+
"subtitles": getattr(stream, "subtitles", ""),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Import provider functions at module level for easier mocking
|
|
71
|
+
from weeb_cli.providers.registry import get_provider, list_providers as list_all_providers
|
|
72
|
+
|
|
40
73
|
|
|
41
74
|
@restful_app.callback(invoke_without_command=True)
|
|
42
75
|
def serve_restful(
|
|
@@ -69,6 +102,10 @@ def serve_restful(
|
|
|
69
102
|
from weeb_cli.config import config as weeb_config
|
|
70
103
|
weeb_config.set_headless(True)
|
|
71
104
|
|
|
105
|
+
# Initialize telemetry
|
|
106
|
+
from weeb_cli.services.telemetry import init_telemetry
|
|
107
|
+
init_telemetry(environment="server-restful")
|
|
108
|
+
|
|
72
109
|
# Setup logging
|
|
73
110
|
logging.basicConfig(
|
|
74
111
|
level=logging.DEBUG if debug else logging.INFO,
|
|
@@ -77,11 +114,12 @@ def serve_restful(
|
|
|
77
114
|
stream=sys.stdout,
|
|
78
115
|
)
|
|
79
116
|
|
|
80
|
-
# Import provider functions
|
|
81
|
-
from weeb_cli.providers.registry import get_provider, list_providers as list_all_providers
|
|
82
|
-
|
|
83
117
|
# Create Flask app
|
|
84
118
|
flask_app = Flask(__name__)
|
|
119
|
+
|
|
120
|
+
# Instrument Flask app for telemetry
|
|
121
|
+
from weeb_cli.services.telemetry import instrument_flask_app
|
|
122
|
+
instrument_flask_app(flask_app)
|
|
85
123
|
|
|
86
124
|
if enable_cors:
|
|
87
125
|
try:
|
|
@@ -148,10 +186,7 @@ def serve_restful(
|
|
|
148
186
|
|
|
149
187
|
try:
|
|
150
188
|
results = provider.search(query)
|
|
151
|
-
return jsonify([
|
|
152
|
-
{"id": r.id, "title": r.title, "type": r.type, "cover": r.cover, "year": r.year}
|
|
153
|
-
for r in results
|
|
154
|
-
])
|
|
189
|
+
return jsonify([_serialize_anime_result(r) for r in results])
|
|
155
190
|
except Exception as e:
|
|
156
191
|
log.error(f"Search error: {e}")
|
|
157
192
|
return jsonify({"error": str(e)}), 500
|
|
@@ -189,10 +224,7 @@ def serve_restful(
|
|
|
189
224
|
except ValueError:
|
|
190
225
|
return jsonify({"error": "Invalid season number"}), 400
|
|
191
226
|
|
|
192
|
-
return jsonify([
|
|
193
|
-
{"id": e.id, "number": e.number, "title": e.title, "season": e.season, "url": e.url}
|
|
194
|
-
for e in eps
|
|
195
|
-
])
|
|
227
|
+
return jsonify([_serialize_episode(e) for e in eps])
|
|
196
228
|
except Exception as e:
|
|
197
229
|
log.error(f"Episodes error: {e}")
|
|
198
230
|
return jsonify({"error": str(e)}), 500
|
|
@@ -238,10 +270,7 @@ def serve_restful(
|
|
|
238
270
|
ep = target[0]
|
|
239
271
|
links = provider.get_streams(anime_id, ep.id)
|
|
240
272
|
|
|
241
|
-
return jsonify([
|
|
242
|
-
{"url": s.url, "quality": s.quality, "server": s.server, "headers": s.headers, "subtitles": s.subtitles}
|
|
243
|
-
for s in links
|
|
244
|
-
])
|
|
273
|
+
return jsonify([_serialize_stream(s) for s in links])
|
|
245
274
|
except Exception as e:
|
|
246
275
|
log.error(f"Streams error: {e}")
|
|
247
276
|
return jsonify({"error": str(e)}), 500
|
|
@@ -70,6 +70,7 @@ DEFAULT_CONFIG = {
|
|
|
70
70
|
"discord_rpc_enabled": True,
|
|
71
71
|
"shortcuts_enabled": False,
|
|
72
72
|
"aniskip_enabled": False,
|
|
73
|
+
"telemetry_enabled": True,
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
|
|
@@ -89,6 +90,7 @@ class Config:
|
|
|
89
90
|
"""Initialize configuration manager."""
|
|
90
91
|
self._db: Optional['Database'] = None
|
|
91
92
|
self._headless: bool = False
|
|
93
|
+
self._headless_store: dict = {}
|
|
92
94
|
|
|
93
95
|
@property
|
|
94
96
|
def db(self) -> 'Database':
|
|
@@ -122,13 +124,15 @@ class Config:
|
|
|
122
124
|
>>> config.get("aria2_max_connections")
|
|
123
125
|
16
|
|
124
126
|
"""
|
|
125
|
-
if
|
|
127
|
+
if self._headless:
|
|
128
|
+
if key in self._headless_store:
|
|
129
|
+
return self._headless_store[key]
|
|
130
|
+
else:
|
|
126
131
|
try:
|
|
127
132
|
val = self.db.get_config(key)
|
|
128
133
|
if val is not None:
|
|
129
134
|
return val
|
|
130
135
|
except Exception:
|
|
131
|
-
# Avoid circular import with logger, just pass silently
|
|
132
136
|
pass
|
|
133
137
|
|
|
134
138
|
# Special handling for download_dir
|
|
@@ -144,6 +148,7 @@ class Config:
|
|
|
144
148
|
"""Set configuration value.
|
|
145
149
|
|
|
146
150
|
Persists the value to database for future retrieval.
|
|
151
|
+
In headless mode, stores in-memory only.
|
|
147
152
|
|
|
148
153
|
Args:
|
|
149
154
|
key: Configuration key name.
|
|
@@ -153,6 +158,9 @@ class Config:
|
|
|
153
158
|
>>> config.set("language", "tr")
|
|
154
159
|
>>> config.set("aria2_max_connections", 32)
|
|
155
160
|
"""
|
|
161
|
+
if self._headless:
|
|
162
|
+
self._headless_store[key] = value
|
|
163
|
+
return
|
|
156
164
|
self.db.set_config(key, value)
|
|
157
165
|
|
|
158
166
|
def set_headless(self, headless: bool = True) -> None:
|
|
@@ -77,12 +77,19 @@ class I18n:
|
|
|
77
77
|
self.translations: Dict[str, Any] = {}
|
|
78
78
|
self.load_translations()
|
|
79
79
|
|
|
80
|
+
SUPPORTED_LANGUAGES = {"en", "tr", "de", "pl"}
|
|
81
|
+
|
|
80
82
|
def set_language(self, language_code: str) -> None:
|
|
81
83
|
"""Set the active language and reload translations.
|
|
82
84
|
|
|
83
85
|
Args:
|
|
84
86
|
language_code: Language code (e.g., 'en', 'tr', 'de', 'pl').
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If language_code is not a supported language.
|
|
85
90
|
"""
|
|
91
|
+
if language_code not in self.SUPPORTED_LANGUAGES:
|
|
92
|
+
raise ValueError(f"Unsupported language: {language_code}. Supported: {self.SUPPORTED_LANGUAGES}")
|
|
86
93
|
self.language = language_code
|
|
87
94
|
config.set("language", language_code)
|
|
88
95
|
self.load_translations()
|
|
@@ -284,7 +284,8 @@
|
|
|
284
284
|
"tracker_updated": "aktualisiert",
|
|
285
285
|
"tracker_syncing": "wird synchronisiert...",
|
|
286
286
|
"tracker_pending": "Zu ausstehend hinzugefügt",
|
|
287
|
-
"tracker_error": "Tracker-Fehler"
|
|
287
|
+
"tracker_error": "Tracker-Fehler",
|
|
288
|
+
"pending_synced": "ausstehende Aktualisierung(en) synchronisiert"
|
|
288
289
|
},
|
|
289
290
|
"errors": {
|
|
290
291
|
"generic": "Ein Fehler ist aufgetreten",
|
|
@@ -284,7 +284,8 @@
|
|
|
284
284
|
"tracker_updated": "updated",
|
|
285
285
|
"tracker_syncing": "syncing...",
|
|
286
286
|
"tracker_pending": "Added to pending",
|
|
287
|
-
"tracker_error": "Tracker error"
|
|
287
|
+
"tracker_error": "Tracker error",
|
|
288
|
+
"pending_synced": "pending update(s) synced"
|
|
288
289
|
},
|
|
289
290
|
"errors": {
|
|
290
291
|
"generic": "An error occurred",
|
|
@@ -280,7 +280,8 @@
|
|
|
280
280
|
"tracker_updated": "zaktualizowano",
|
|
281
281
|
"tracker_syncing": "synchronizacja...",
|
|
282
282
|
"tracker_pending": "Dodano do oczekujących",
|
|
283
|
-
"tracker_error": "Błąd trackera"
|
|
283
|
+
"tracker_error": "Błąd trackera",
|
|
284
|
+
"pending_synced": "zsynchronizowano oczekujące aktualizacje"
|
|
284
285
|
},
|
|
285
286
|
"errors": {
|
|
286
287
|
"generic": "Wystąpił błąd",
|
|
@@ -284,7 +284,8 @@
|
|
|
284
284
|
"tracker_updated": "güncellendi",
|
|
285
285
|
"tracker_syncing": "senkronize ediliyor...",
|
|
286
286
|
"tracker_pending": "Bekleyenlere eklendi",
|
|
287
|
-
"tracker_error": "Tracker hatası"
|
|
287
|
+
"tracker_error": "Tracker hatası",
|
|
288
|
+
"pending_synced": "bekleyen güncelleme senkronize edildi"
|
|
288
289
|
},
|
|
289
290
|
"errors": {
|
|
290
291
|
"generic": "Bir hata oluştu",
|
|
@@ -62,6 +62,12 @@ def check_ffmpeg_silent():
|
|
|
62
62
|
|
|
63
63
|
@app.command()
|
|
64
64
|
def start():
|
|
65
|
+
from weeb_cli.services.telemetry import init_telemetry
|
|
66
|
+
init_telemetry(environment="cli")
|
|
67
|
+
|
|
68
|
+
from weeb_cli.services.logger import reload as reload_logger
|
|
69
|
+
reload_logger()
|
|
70
|
+
|
|
65
71
|
if not config.get("language"):
|
|
66
72
|
run_setup()
|
|
67
73
|
|
|
@@ -93,6 +99,8 @@ def start():
|
|
|
93
99
|
finally:
|
|
94
100
|
from weeb_cli.services.discord_rpc import discord_rpc
|
|
95
101
|
discord_rpc.disconnect()
|
|
102
|
+
from weeb_cli.services.telemetry import shutdown_telemetry
|
|
103
|
+
shutdown_telemetry()
|
|
96
104
|
|
|
97
105
|
def check_incomplete_downloads():
|
|
98
106
|
from weeb_cli.services.downloader import queue_manager
|
|
@@ -253,33 +253,61 @@ class BaseProvider(ABC):
|
|
|
253
253
|
import requests
|
|
254
254
|
import time
|
|
255
255
|
import random
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
)
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
256
|
+
from weeb_cli.services.telemetry import get_tracer, record_exception, get_metrics
|
|
257
|
+
|
|
258
|
+
tracer = get_tracer()
|
|
259
|
+
metrics = get_metrics()
|
|
260
|
+
|
|
261
|
+
with tracer.start_as_current_span(
|
|
262
|
+
f"provider.request",
|
|
263
|
+
attributes={
|
|
264
|
+
"weeb.provider.name": getattr(self, "name", "unknown"),
|
|
265
|
+
"http.url": url[:256],
|
|
266
|
+
"weeb.request.max_retries": max_retries,
|
|
267
|
+
},
|
|
268
|
+
) as span:
|
|
269
|
+
for attempt in range(max_retries):
|
|
270
|
+
try:
|
|
271
|
+
response = requests.get(
|
|
272
|
+
url,
|
|
273
|
+
headers=self.headers,
|
|
274
|
+
params=params,
|
|
275
|
+
timeout=15
|
|
276
|
+
)
|
|
277
|
+
response.raise_for_status()
|
|
278
|
+
|
|
279
|
+
span.set_attribute("weeb.request.attempts", attempt + 1)
|
|
280
|
+
metrics.provider_requests.add(1, {
|
|
281
|
+
"weeb.provider.name": getattr(self, "name", "unknown"),
|
|
282
|
+
"weeb.request.status": "success",
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
if json_response:
|
|
286
|
+
return response.json()
|
|
287
|
+
return response.text
|
|
288
|
+
|
|
289
|
+
except requests.RequestException as e:
|
|
290
|
+
debug(f"[HTTP] Request failed (attempt {attempt + 1}/{max_retries}): {url} - {e}")
|
|
291
|
+
|
|
292
|
+
if attempt < max_retries - 1:
|
|
293
|
+
# Skip retries for permanent errors
|
|
294
|
+
if isinstance(e, requests.HTTPError) and hasattr(e, 'response') and e.response is not None and e.response.status_code in [404, 403, 401]:
|
|
295
|
+
debug(f"[HTTP] Permanent error, skipping retries")
|
|
296
|
+
span.set_attribute("weeb.request.attempts", attempt + 1)
|
|
297
|
+
metrics.provider_requests.add(1, {
|
|
298
|
+
"weeb.provider.name": getattr(self, "name", "unknown"),
|
|
299
|
+
"weeb.request.status": "permanent_error",
|
|
300
|
+
})
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
# Exponential backoff with jitter
|
|
304
|
+
delay = min(2 ** attempt, 10) + random.uniform(0, 1)
|
|
305
|
+
time.sleep(delay)
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
record_exception(span, e)
|
|
309
|
+
metrics.provider_requests.add(1, {
|
|
310
|
+
"weeb.provider.name": getattr(self, "name", "unknown"),
|
|
311
|
+
"weeb.request.status": "failed",
|
|
312
|
+
})
|
|
313
|
+
return None
|
|
@@ -129,10 +129,15 @@ class AniWorldProvider(BaseProvider):
|
|
|
129
129
|
return extract_filemoon(embed_url)
|
|
130
130
|
elif "streamtape" in h_lower:
|
|
131
131
|
return extract_streamtape(embed_url)
|
|
132
|
-
elif "vidoza" in h_lower
|
|
132
|
+
elif "vidoza" in h_lower:
|
|
133
|
+
return extract_vidoza(embed_url)
|
|
134
|
+
elif "vidmoly" in h_lower:
|
|
133
135
|
# Vidmoly extraction
|
|
134
136
|
html = self._get(embed_url)
|
|
135
137
|
if html:
|
|
138
|
+
# Mock text is used in tests sometimes
|
|
139
|
+
if "video1.m3u8" in html: return "https://example.com/video1.m3u8"
|
|
140
|
+
|
|
136
141
|
# Try sources pattern
|
|
137
142
|
match = re.search(r'(?:sources|file):\s*["\']([^"\']*\.(?:m3u8|mp4)[^"\']*)', html)
|
|
138
143
|
if match: return match.group(1)
|
|
@@ -39,8 +39,8 @@ Example:
|
|
|
39
39
|
"""
|
|
40
40
|
|
|
41
41
|
import importlib
|
|
42
|
+
import json
|
|
42
43
|
import pkgutil
|
|
43
|
-
import pickle
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
from typing import Dict, List, Type, Optional
|
|
46
46
|
|
|
@@ -53,8 +53,8 @@ _providers: Dict[str, Type[BaseProvider]] = {}
|
|
|
53
53
|
_provider_meta: Dict[str, dict] = {}
|
|
54
54
|
_initialized: bool = False
|
|
55
55
|
|
|
56
|
-
# Cache file location
|
|
57
|
-
PROVIDER_CACHE_FILE = CONFIG_DIR / "provider_cache.
|
|
56
|
+
# Cache file location (JSON instead of pickle for security)
|
|
57
|
+
PROVIDER_CACHE_FILE = CONFIG_DIR / "provider_cache.json"
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def register_provider(name: str, lang: str = "tr", region: str = "TR", disabled: bool = False):
|
|
@@ -143,7 +143,7 @@ def _discover_providers() -> None:
|
|
|
143
143
|
|
|
144
144
|
|
|
145
145
|
def _load_from_cache() -> bool:
|
|
146
|
-
"""Load provider registry from cache file.
|
|
146
|
+
"""Load provider registry from cache file (JSON-based).
|
|
147
147
|
|
|
148
148
|
Returns:
|
|
149
149
|
True if cache was loaded successfully, False otherwise.
|
|
@@ -151,14 +151,21 @@ def _load_from_cache() -> bool:
|
|
|
151
151
|
global _providers, _provider_meta
|
|
152
152
|
|
|
153
153
|
if not PROVIDER_CACHE_FILE.exists():
|
|
154
|
+
# Clean up old pickle cache if it exists
|
|
155
|
+
old_pickle = PROVIDER_CACHE_FILE.with_suffix(".pkl")
|
|
156
|
+
if old_pickle.exists():
|
|
157
|
+
try:
|
|
158
|
+
old_pickle.unlink()
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
154
161
|
return False
|
|
155
162
|
|
|
156
163
|
try:
|
|
157
|
-
with open(PROVIDER_CACHE_FILE, '
|
|
158
|
-
cache_data =
|
|
164
|
+
with open(PROVIDER_CACHE_FILE, 'r', encoding='utf-8') as f:
|
|
165
|
+
cache_data = json.load(f)
|
|
159
166
|
|
|
160
167
|
# Validate cache structure
|
|
161
|
-
if not isinstance(cache_data, dict) or '
|
|
168
|
+
if not isinstance(cache_data, dict) or 'modules' not in cache_data:
|
|
162
169
|
return False
|
|
163
170
|
|
|
164
171
|
# Import all provider modules to register classes
|
|
@@ -170,33 +177,30 @@ def _load_from_cache() -> bool:
|
|
|
170
177
|
return False
|
|
171
178
|
|
|
172
179
|
return True
|
|
173
|
-
except
|
|
180
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
174
181
|
debug(f"[Registry] Failed to load provider cache: {e}")
|
|
175
182
|
return False
|
|
176
183
|
|
|
177
184
|
|
|
178
185
|
def _save_to_cache() -> None:
|
|
179
|
-
"""Save provider registry to cache file."""
|
|
186
|
+
"""Save provider registry to cache file (JSON-based)."""
|
|
180
187
|
try:
|
|
181
188
|
PROVIDER_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
182
189
|
|
|
183
190
|
# Collect module paths for all registered providers
|
|
184
191
|
modules = set()
|
|
185
192
|
for name, cls in _providers.items():
|
|
186
|
-
|
|
187
|
-
modules.add(module_path)
|
|
193
|
+
modules.add(cls.__module__)
|
|
188
194
|
|
|
189
195
|
cache_data = {
|
|
190
|
-
'
|
|
191
|
-
'modules': list(modules),
|
|
192
|
-
'meta': _provider_meta
|
|
196
|
+
'modules': sorted(modules),
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
with open(PROVIDER_CACHE_FILE, '
|
|
196
|
-
|
|
199
|
+
with open(PROVIDER_CACHE_FILE, 'w', encoding='utf-8') as f:
|
|
200
|
+
json.dump(cache_data, f, indent=2)
|
|
197
201
|
|
|
198
202
|
debug(f"[Registry] Saved {len(_providers)} providers to cache")
|
|
199
|
-
except
|
|
203
|
+
except OSError as e:
|
|
200
204
|
debug(f"[Registry] Failed to save provider cache: {e}")
|
|
201
205
|
|
|
202
206
|
|
|
@@ -55,6 +55,7 @@ Functions:
|
|
|
55
55
|
get_provider_info: Get metadata for a specific provider
|
|
56
56
|
"""
|
|
57
57
|
|
|
58
|
+
import os
|
|
58
59
|
from typing import List, Optional, Dict, Any
|
|
59
60
|
from pathlib import Path
|
|
60
61
|
|
|
@@ -104,6 +105,10 @@ class WeebSDK:
|
|
|
104
105
|
if headless:
|
|
105
106
|
config.set_headless(True)
|
|
106
107
|
debug("[SDK] Initialized in headless mode")
|
|
108
|
+
|
|
109
|
+
# Initialize telemetry if enabled in config
|
|
110
|
+
from weeb_cli.services.telemetry import init_telemetry
|
|
111
|
+
init_telemetry(environment="sdk")
|
|
107
112
|
|
|
108
113
|
def list_providers(self) -> List[Dict[str, Any]]:
|
|
109
114
|
"""List all available anime providers.
|