ani-cli-ar 1.9.2__tar.gz → 1.9.3__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.
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/PKG-INFO +1 -1
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/PKG-INFO +1 -1
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/SOURCES.txt +1 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/api.py +29 -4
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/app.py +94 -17
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/cli.py +5 -0
- ani_cli_ar-1.9.3/ani_cli_arabic/monitoring.py +355 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/player.py +2 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/provider_manager.py +46 -0
- ani_cli_ar-1.9.3/ani_cli_arabic/stats.py +114 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/version.py +1 -1
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/watch_together.py +70 -2
- ani_cli_ar-1.9.2/ani_cli_arabic/monitoring.py +0 -92
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/LICENSE +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/README.md +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/dependency_links.txt +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/entry_points.txt +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/requires.txt +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_ar.egg-info/top_level.txt +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/__init__.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/__main__.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/config.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/deps.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/discord_rpc.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/favorites.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/history.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/models.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/__init__.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/anikoto.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/animepahe.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/api_provider.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/base.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/gogoanime.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/miruro.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/scrapers/mkissa.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/settings.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/storage.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/ui.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/updater.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/ani_cli_arabic/utils.py +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/pyproject.toml +0 -0
- {ani_cli_ar-1.9.2 → ani_cli_ar-1.9.3}/setup.cfg +0 -0
|
@@ -284,7 +284,25 @@ class AnimeAPI:
|
|
|
284
284
|
except (requests.RequestException, ValueError, TypeError):
|
|
285
285
|
return []
|
|
286
286
|
|
|
287
|
-
def
|
|
287
|
+
def _report_api_error(self, error_msg: str, context: dict = None,
|
|
288
|
+
exception: BaseException = None, url: str = ""):
|
|
289
|
+
"""Report an Arabic-API failure to telemetry without blocking the caller."""
|
|
290
|
+
try:
|
|
291
|
+
import sys as _sys
|
|
292
|
+
from .monitoring import monitor
|
|
293
|
+
details = dict(context or {})
|
|
294
|
+
details.setdefault("translation_mode", "arabic_sub")
|
|
295
|
+
if url:
|
|
296
|
+
details["server_url"] = url
|
|
297
|
+
if exception is not None:
|
|
298
|
+
monitor.track_error(error_msg, details, exception=exception)
|
|
299
|
+
else:
|
|
300
|
+
monitor.track_error(error_msg, details, exc_info=_sys.exc_info())
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
def get_streaming_servers(self, anime_id: str, episode_num: str, anime_type: str = 'SERIES',
|
|
305
|
+
context: dict = None) -> Optional[Dict]:
|
|
288
306
|
endpoint = get_api_base() + "anime/load_servers.php"
|
|
289
307
|
payload = {
|
|
290
308
|
'UserId': '0',
|
|
@@ -298,10 +316,12 @@ class AnimeAPI:
|
|
|
298
316
|
response = requests.post(endpoint, data=payload, timeout=10)
|
|
299
317
|
response.raise_for_status()
|
|
300
318
|
return response.json()
|
|
301
|
-
except (requests.RequestException, ValueError, TypeError):
|
|
319
|
+
except (requests.RequestException, ValueError, TypeError) as e:
|
|
320
|
+
self._report_api_error("Arabic server list request failed", context,
|
|
321
|
+
exception=e, url=endpoint)
|
|
302
322
|
return None
|
|
303
323
|
|
|
304
|
-
def extract_mediafire_direct(self, mf_url: str) -> Optional[str]:
|
|
324
|
+
def extract_mediafire_direct(self, mf_url: str, context: dict = None) -> Optional[str]:
|
|
305
325
|
try:
|
|
306
326
|
headers = {
|
|
307
327
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
@@ -309,8 +329,13 @@ class AnimeAPI:
|
|
|
309
329
|
response = requests.get(mf_url, headers=headers, timeout=10)
|
|
310
330
|
response.raise_for_status()
|
|
311
331
|
match = re.search(r'(https://download[^"]+)', response.text)
|
|
332
|
+
if not match:
|
|
333
|
+
self._report_api_error("MediaFire page did not contain a download link",
|
|
334
|
+
context, url=mf_url)
|
|
312
335
|
return match.group(1) if match else None
|
|
313
|
-
except (requests.RequestException, AttributeError):
|
|
336
|
+
except (requests.RequestException, AttributeError) as e:
|
|
337
|
+
self._report_api_error("MediaFire direct link extraction failed", context,
|
|
338
|
+
exception=e, url=mf_url)
|
|
314
339
|
return None
|
|
315
340
|
|
|
316
341
|
def build_mediafire_url(self, server_id: str) -> str:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import sys
|
|
2
2
|
import atexit
|
|
3
3
|
import re
|
|
4
|
+
import time
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from rich.align import Align
|
|
6
7
|
from rich.panel import Panel
|
|
@@ -55,10 +56,17 @@ class AniCliArApp:
|
|
|
55
56
|
parser.add_argument('--dub', action='store_true', help="Override: use English Dubbed streams")
|
|
56
57
|
parser.add_argument('--provider', type=str, default=None,
|
|
57
58
|
help="Preferred English stream provider (overrides auto-fallback)")
|
|
59
|
+
parser.add_argument('--stats', action='store_true',
|
|
60
|
+
help="Show streaming history summary from telemetry/local history and exit")
|
|
58
61
|
parser.add_argument('query', nargs='*', help="Anime name to search for")
|
|
59
62
|
|
|
60
63
|
args = parser.parse_args()
|
|
61
|
-
|
|
64
|
+
|
|
65
|
+
if args.stats:
|
|
66
|
+
from .stats import render_stats
|
|
67
|
+
render_stats()
|
|
68
|
+
sys.exit(0)
|
|
69
|
+
|
|
62
70
|
if args.version:
|
|
63
71
|
from .version import __version__
|
|
64
72
|
print(f"ani-cli-arabic v{__version__}")
|
|
@@ -130,6 +138,7 @@ class AniCliArApp:
|
|
|
130
138
|
threading.Thread(target=connect_rpc, daemon=True).start()
|
|
131
139
|
|
|
132
140
|
threading.Thread(target=lambda: monitor.track_app_start(), daemon=True).start()
|
|
141
|
+
monitor.track_app_session()
|
|
133
142
|
|
|
134
143
|
def check_updates_bg():
|
|
135
144
|
try:
|
|
@@ -737,7 +746,9 @@ class AniCliArApp:
|
|
|
737
746
|
|
|
738
747
|
self.ui.console.print(Align.center(panel, vertical="middle", height=self.ui.console.height))
|
|
739
748
|
|
|
749
|
+
monitor.set_activity("watching", anime.title_en, None)
|
|
740
750
|
self.player.play(trailer_url, f"Trailer - {anime.title_en}")
|
|
751
|
+
monitor.set_activity("idle")
|
|
741
752
|
|
|
742
753
|
def handle_episode_selection(self, selected_anime, episodes, initial_idx=0):
|
|
743
754
|
current_idx = max(0, min(int(initial_idx or 0), len(episodes) - 1))
|
|
@@ -812,7 +823,10 @@ class AniCliArApp:
|
|
|
812
823
|
self.api.get_streaming_servers,
|
|
813
824
|
selected_anime.id,
|
|
814
825
|
selected_ep.number,
|
|
815
|
-
selected_anime.type
|
|
826
|
+
selected_anime.type,
|
|
827
|
+
{"anime": selected_anime.title_en,
|
|
828
|
+
"episode": str(selected_ep.display_num),
|
|
829
|
+
"provider": "arabic_api"}
|
|
816
830
|
)
|
|
817
831
|
|
|
818
832
|
if not server_data:
|
|
@@ -887,10 +901,20 @@ class AniCliArApp:
|
|
|
887
901
|
pm = ProviderManager(preferred_provider=preferred if preferred else None)
|
|
888
902
|
|
|
889
903
|
mode = "dub" if dub else "sub"
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
904
|
+
try:
|
|
905
|
+
url, headers, used_provider = asyncio.run(
|
|
906
|
+
pm.resolve_stream(anime_title, episode_num, mode=mode, language="english", provider=provider)
|
|
907
|
+
)
|
|
908
|
+
except Exception:
|
|
909
|
+
exc_type, exc_val, exc_tb = sys.exc_info()
|
|
910
|
+
monitor.track_error(
|
|
911
|
+
"English stream resolution failed",
|
|
912
|
+
{"anime": anime_title, "episode": str(episode_num), "provider": provider or "auto",
|
|
913
|
+
"mode": mode, "translation_mode": mode},
|
|
914
|
+
exc_info=(exc_type, exc_val, exc_tb),
|
|
915
|
+
)
|
|
916
|
+
return "", {}, ""
|
|
917
|
+
return url, headers, used_provider or ""
|
|
894
918
|
|
|
895
919
|
def _pick_default_download_quality_option(self, current_ep_data):
|
|
896
920
|
qualities = [
|
|
@@ -912,16 +936,20 @@ class AniCliArApp:
|
|
|
912
936
|
return None
|
|
913
937
|
|
|
914
938
|
def resolve_default_download_target(self, selected_anime, selected_ep, show_loading=False):
|
|
939
|
+
_ctx = {"anime": selected_anime.title_en,
|
|
940
|
+
"episode": str(selected_ep.display_num),
|
|
941
|
+
"provider": "arabic_api"}
|
|
915
942
|
if show_loading:
|
|
916
943
|
server_data = self.ui.run_with_loading(
|
|
917
944
|
"Loading servers...",
|
|
918
945
|
self.api.get_streaming_servers,
|
|
919
946
|
selected_anime.id,
|
|
920
947
|
selected_ep.number,
|
|
921
|
-
selected_anime.type
|
|
948
|
+
selected_anime.type,
|
|
949
|
+
_ctx
|
|
922
950
|
)
|
|
923
951
|
else:
|
|
924
|
-
server_data = self.api.get_streaming_servers(selected_anime.id, selected_ep.number, selected_anime.type)
|
|
952
|
+
server_data = self.api.get_streaming_servers(selected_anime.id, selected_ep.number, selected_anime.type, _ctx)
|
|
925
953
|
|
|
926
954
|
if not server_data:
|
|
927
955
|
return None, None, "No servers found for this episode."
|
|
@@ -940,10 +968,11 @@ class AniCliArApp:
|
|
|
940
968
|
direct_url = self.ui.run_with_loading(
|
|
941
969
|
"Extracting direct link...",
|
|
942
970
|
self.api.extract_mediafire_direct,
|
|
943
|
-
mediafire_url
|
|
971
|
+
mediafire_url,
|
|
972
|
+
_ctx
|
|
944
973
|
)
|
|
945
974
|
else:
|
|
946
|
-
direct_url = self.api.extract_mediafire_direct(mediafire_url)
|
|
975
|
+
direct_url = self.api.extract_mediafire_direct(mediafire_url, _ctx)
|
|
947
976
|
|
|
948
977
|
if not direct_url:
|
|
949
978
|
return None, None, "Failed to extract direct link from MediaFire."
|
|
@@ -1059,7 +1088,10 @@ class AniCliArApp:
|
|
|
1059
1088
|
direct_url = self.ui.run_with_loading(
|
|
1060
1089
|
"Extracting direct link...",
|
|
1061
1090
|
self.api.extract_mediafire_direct,
|
|
1062
|
-
self.api.build_mediafire_url(server_id)
|
|
1091
|
+
self.api.build_mediafire_url(server_id),
|
|
1092
|
+
{"anime": selected_anime.title_en,
|
|
1093
|
+
"episode": str(selected_ep.display_num),
|
|
1094
|
+
"provider": "arabic_api"}
|
|
1063
1095
|
)
|
|
1064
1096
|
|
|
1065
1097
|
if direct_url:
|
|
@@ -1110,8 +1142,6 @@ class AniCliArApp:
|
|
|
1110
1142
|
|
|
1111
1143
|
self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
|
|
1112
1144
|
|
|
1113
|
-
monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
|
|
1114
|
-
|
|
1115
1145
|
ipc_socket = None
|
|
1116
1146
|
rc_port = None
|
|
1117
1147
|
if self.watch_host is not None:
|
|
@@ -1121,7 +1151,20 @@ class AniCliArApp:
|
|
|
1121
1151
|
ipc_socket = self.watch_host.socket_path
|
|
1122
1152
|
self.watch_host.notify_load(selected_anime.title_en, selected_ep.display_num, "Arabic Sub")
|
|
1123
1153
|
|
|
1124
|
-
|
|
1154
|
+
watch_start = time.time()
|
|
1155
|
+
monitor.set_activity("watching", selected_anime.title_en, str(selected_ep.display_num))
|
|
1156
|
+
selected_player = self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})", player_type=player_type, ipc_socket=ipc_socket, rc_port=rc_port)
|
|
1157
|
+
watch_end = time.time()
|
|
1158
|
+
monitor.set_activity("idle")
|
|
1159
|
+
monitor.track_video_play(
|
|
1160
|
+
selected_anime.title_en,
|
|
1161
|
+
str(selected_ep.display_num),
|
|
1162
|
+
player=selected_player or player_type or "",
|
|
1163
|
+
provider="arabic_api",
|
|
1164
|
+
quality=quality_tag or "",
|
|
1165
|
+
watch_start=watch_start,
|
|
1166
|
+
watch_end=watch_end,
|
|
1167
|
+
)
|
|
1125
1168
|
if self.watch_host is not None:
|
|
1126
1169
|
self.watch_host.notify_stop()
|
|
1127
1170
|
self.ui.clear()
|
|
@@ -1129,6 +1172,17 @@ class AniCliArApp:
|
|
|
1129
1172
|
self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
|
|
1130
1173
|
return "watch"
|
|
1131
1174
|
else:
|
|
1175
|
+
monitor.track_error(
|
|
1176
|
+
"Failed to extract direct link from MediaFire",
|
|
1177
|
+
{
|
|
1178
|
+
"anime": selected_anime.title_en,
|
|
1179
|
+
"episode": str(selected_ep.display_num),
|
|
1180
|
+
"language": "Arabic",
|
|
1181
|
+
"provider": "arabic_api",
|
|
1182
|
+
"translation_mode": "arabic_sub",
|
|
1183
|
+
"server_url": "www.mediafire.com",
|
|
1184
|
+
},
|
|
1185
|
+
)
|
|
1132
1186
|
self.ui.render_message(
|
|
1133
1187
|
"✗ Error",
|
|
1134
1188
|
"Failed to extract direct link from MediaFire.",
|
|
@@ -1185,6 +1239,17 @@ class AniCliArApp:
|
|
|
1185
1239
|
)
|
|
1186
1240
|
|
|
1187
1241
|
if not (url_and_headers and url_and_headers[0]):
|
|
1242
|
+
monitor.track_error(
|
|
1243
|
+
"No working English stream found",
|
|
1244
|
+
{
|
|
1245
|
+
"anime": selected_anime.title_en,
|
|
1246
|
+
"episode": str(selected_ep.display_num),
|
|
1247
|
+
"provider": provider_choice or "auto",
|
|
1248
|
+
"quality": quality_name,
|
|
1249
|
+
"dub": dub,
|
|
1250
|
+
"translation_mode": "dub" if dub else "sub",
|
|
1251
|
+
},
|
|
1252
|
+
)
|
|
1188
1253
|
self.ui.render_message(
|
|
1189
1254
|
"Error",
|
|
1190
1255
|
f"No working English stream found via {label}. Please try another provider.",
|
|
@@ -1192,7 +1257,7 @@ class AniCliArApp:
|
|
|
1192
1257
|
)
|
|
1193
1258
|
return None
|
|
1194
1259
|
|
|
1195
|
-
direct_url, stream_headers = url_and_headers
|
|
1260
|
+
direct_url, stream_headers, provider_name = url_and_headers
|
|
1196
1261
|
player_type = self.watch_host.player_kind if self.watch_host else self.settings.get('player')
|
|
1197
1262
|
suffix = " [English Dub]" if dub else " [English Sub]"
|
|
1198
1263
|
|
|
@@ -1218,7 +1283,6 @@ class AniCliArApp:
|
|
|
1218
1283
|
self.ui.console.print(Align.center(watching_panel, vertical="middle", height=self.ui.console.height))
|
|
1219
1284
|
|
|
1220
1285
|
self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
|
|
1221
|
-
monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
|
|
1222
1286
|
|
|
1223
1287
|
ipc_socket = None
|
|
1224
1288
|
rc_port = None
|
|
@@ -1233,7 +1297,20 @@ class AniCliArApp:
|
|
|
1233
1297
|
"English Dub" if dub else "English Sub",
|
|
1234
1298
|
)
|
|
1235
1299
|
|
|
1236
|
-
|
|
1300
|
+
watch_start = time.time()
|
|
1301
|
+
monitor.set_activity("watching", selected_anime.title_en, str(selected_ep.display_num))
|
|
1302
|
+
selected_player = self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num}", player_type=player_type, headers=stream_headers, ipc_socket=ipc_socket, rc_port=rc_port)
|
|
1303
|
+
watch_end = time.time()
|
|
1304
|
+
monitor.set_activity("idle")
|
|
1305
|
+
monitor.track_video_play(
|
|
1306
|
+
selected_anime.title_en,
|
|
1307
|
+
str(selected_ep.display_num),
|
|
1308
|
+
player=selected_player or player_type or "",
|
|
1309
|
+
provider=provider_name or "",
|
|
1310
|
+
quality=quality_name or "",
|
|
1311
|
+
watch_start=watch_start,
|
|
1312
|
+
watch_end=watch_end,
|
|
1313
|
+
)
|
|
1237
1314
|
if self.watch_host is not None:
|
|
1238
1315
|
self.watch_host.notify_stop()
|
|
1239
1316
|
self.ui.clear()
|
|
@@ -581,6 +581,11 @@ def run_simple_cli(query=None, deps=None):
|
|
|
581
581
|
|
|
582
582
|
def main():
|
|
583
583
|
"""Entry point for running the CLI directly: python -m ani_cli_arabic.cli"""
|
|
584
|
+
if "--stats" in sys.argv:
|
|
585
|
+
from ani_cli_arabic.stats import render_stats
|
|
586
|
+
render_stats()
|
|
587
|
+
return
|
|
588
|
+
|
|
584
589
|
from ani_cli_arabic.api import AnimeAPI
|
|
585
590
|
from ani_cli_arabic.player import PlayerManager
|
|
586
591
|
from ani_cli_arabic.history import HistoryManager
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import getpass
|
|
3
|
+
import hashlib
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import traceback as _traceback
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
import requests
|
|
10
|
+
import time
|
|
11
|
+
import atexit
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from .api import _get_analytics_endpoint_config
|
|
14
|
+
from .config import CURRENT_VERSION
|
|
15
|
+
from .version import __version__
|
|
16
|
+
|
|
17
|
+
_MAX_TRACEBACK_LINES = 6
|
|
18
|
+
_HEARTBEAT_INTERVAL = 30
|
|
19
|
+
|
|
20
|
+
class MonitoringSystem:
|
|
21
|
+
_instance = None
|
|
22
|
+
|
|
23
|
+
def __new__(cls):
|
|
24
|
+
if cls._instance is None:
|
|
25
|
+
cls._instance = super(MonitoringSystem, cls).__new__(cls)
|
|
26
|
+
cls._instance._initialized = False
|
|
27
|
+
return cls._instance
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
if hasattr(self, '_initialized') and self._initialized:
|
|
31
|
+
return
|
|
32
|
+
self._initialized = True
|
|
33
|
+
self.user_fingerprint = self._generate_fingerprint()
|
|
34
|
+
self.current_activity = {
|
|
35
|
+
"status": "idle",
|
|
36
|
+
"current_anime": None,
|
|
37
|
+
"current_episode": None,
|
|
38
|
+
"watch_started_at": None,
|
|
39
|
+
}
|
|
40
|
+
self._activity_lock = threading.Lock()
|
|
41
|
+
self._session_start = None
|
|
42
|
+
self._heartbeat_started = False
|
|
43
|
+
|
|
44
|
+
def _generate_fingerprint(self) -> str:
|
|
45
|
+
try:
|
|
46
|
+
components = [
|
|
47
|
+
platform.node(),
|
|
48
|
+
platform.machine(),
|
|
49
|
+
platform.system(),
|
|
50
|
+
platform.release(),
|
|
51
|
+
platform.processor()
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
raw_str = "|".join(str(c) for c in components)
|
|
55
|
+
return hashlib.sha256(raw_str.encode()).hexdigest()[:16]
|
|
56
|
+
except Exception:
|
|
57
|
+
return "unknown_user"
|
|
58
|
+
|
|
59
|
+
def _send_data(self, action: str, details: dict, sync: bool = False):
|
|
60
|
+
"""Send analytics data only if user has opted in.
|
|
61
|
+
|
|
62
|
+
System/user context (os, user_name, app_version) is attached to every
|
|
63
|
+
payload. `sync=True` sends inline (used at exit where daemon threads
|
|
64
|
+
would be killed); both paths swallow errors so telemetry never blocks.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
from .settings import SettingsManager
|
|
68
|
+
settings = SettingsManager()
|
|
69
|
+
if not settings.get('analytics'):
|
|
70
|
+
return
|
|
71
|
+
except Exception:
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
enriched = dict(details or {})
|
|
75
|
+
for key, value in self._system_context().items():
|
|
76
|
+
if value is not None and str(value) != "":
|
|
77
|
+
enriched.setdefault(key, value)
|
|
78
|
+
|
|
79
|
+
def worker():
|
|
80
|
+
try:
|
|
81
|
+
endpoint_url, auth_secret = _get_analytics_endpoint_config()
|
|
82
|
+
|
|
83
|
+
payload = {
|
|
84
|
+
"fingerprint": self.user_fingerprint,
|
|
85
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
86
|
+
"action": action,
|
|
87
|
+
"details": enriched
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
headers = {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
'X-Auth-Key': auth_secret,
|
|
93
|
+
'User-Agent': 'AniCliAr-Monitor/1.0'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
requests.post(
|
|
97
|
+
f"{endpoint_url}/monitor",
|
|
98
|
+
json=payload,
|
|
99
|
+
headers=headers,
|
|
100
|
+
timeout=3
|
|
101
|
+
)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
if sync:
|
|
106
|
+
worker()
|
|
107
|
+
else:
|
|
108
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
109
|
+
thread.start()
|
|
110
|
+
|
|
111
|
+
def track_app_start(self):
|
|
112
|
+
self._send_data("app_start", {
|
|
113
|
+
"version": CURRENT_VERSION,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
def track_app_session(self):
|
|
117
|
+
"""Record the app session start and report 'app_session_end' on exit."""
|
|
118
|
+
try:
|
|
119
|
+
self._session_start = time.time()
|
|
120
|
+
atexit.register(self._send_session_end)
|
|
121
|
+
self._start_heartbeat()
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
|
|
125
|
+
def _start_heartbeat(self):
|
|
126
|
+
"""Start the live 'heartbeat' reporter thread (daemon)."""
|
|
127
|
+
if self._heartbeat_started:
|
|
128
|
+
return
|
|
129
|
+
self._heartbeat_started = True
|
|
130
|
+
|
|
131
|
+
def loop():
|
|
132
|
+
while True:
|
|
133
|
+
time.sleep(_HEARTBEAT_INTERVAL)
|
|
134
|
+
try:
|
|
135
|
+
self._send_heartbeat()
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
threading.Thread(target=loop, daemon=True).start()
|
|
140
|
+
|
|
141
|
+
def set_activity(self, status: str, anime: str = None, episode=None):
|
|
142
|
+
"""Update the global current-activity state used by heartbeats.
|
|
143
|
+
|
|
144
|
+
status is "watching" or "idle". While watching, anime/episode are the
|
|
145
|
+
currently playing title and its episode identifier.
|
|
146
|
+
"""
|
|
147
|
+
status = (status or "idle").lower()
|
|
148
|
+
if status not in ("idle", "watching"):
|
|
149
|
+
status = "idle"
|
|
150
|
+
with self._activity_lock:
|
|
151
|
+
self.current_activity["status"] = status
|
|
152
|
+
if status == "watching":
|
|
153
|
+
self.current_activity["current_anime"] = anime
|
|
154
|
+
self.current_activity["current_episode"] = episode
|
|
155
|
+
self.current_activity["watch_started_at"] = time.time()
|
|
156
|
+
else:
|
|
157
|
+
self.current_activity["current_anime"] = None
|
|
158
|
+
self.current_activity["current_episode"] = None
|
|
159
|
+
self.current_activity["watch_started_at"] = None
|
|
160
|
+
|
|
161
|
+
def _send_heartbeat(self):
|
|
162
|
+
with self._activity_lock:
|
|
163
|
+
activity = dict(self.current_activity)
|
|
164
|
+
start = self._session_start or time.time()
|
|
165
|
+
details = {
|
|
166
|
+
"session_start": datetime.fromtimestamp(start, timezone.utc).isoformat(),
|
|
167
|
+
"elapsed_session_seconds": round(time.time() - start, 3),
|
|
168
|
+
"status": activity.get("status", "idle"),
|
|
169
|
+
"current_anime": activity.get("current_anime"),
|
|
170
|
+
"current_episode": activity.get("current_episode"),
|
|
171
|
+
}
|
|
172
|
+
watch_started = activity.get("watch_started_at")
|
|
173
|
+
if watch_started:
|
|
174
|
+
details["watch_started_at"] = datetime.fromtimestamp(watch_started, timezone.utc).isoformat()
|
|
175
|
+
self._send_data("heartbeat", details)
|
|
176
|
+
|
|
177
|
+
def _send_session_end(self):
|
|
178
|
+
try:
|
|
179
|
+
end = time.time()
|
|
180
|
+
start = getattr(self, "_session_start", None) or end
|
|
181
|
+
self._send_data("app_session_end", {
|
|
182
|
+
"session_start": datetime.fromtimestamp(start, timezone.utc).isoformat(),
|
|
183
|
+
"session_end": datetime.fromtimestamp(end, timezone.utc).isoformat(),
|
|
184
|
+
"session_duration_seconds": round(end - start, 3),
|
|
185
|
+
}, sync=True)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
def track_video_play(self, anime_title: str, episode: str, mode: str = "stream", player: str = "", provider: str = "", quality: str = "",
|
|
190
|
+
watch_start: float = None, watch_end: float = None):
|
|
191
|
+
details = {
|
|
192
|
+
"anime": anime_title,
|
|
193
|
+
"episode": episode,
|
|
194
|
+
"mode": mode,
|
|
195
|
+
"player": player or "",
|
|
196
|
+
"provider": provider or "",
|
|
197
|
+
"quality": quality or ""
|
|
198
|
+
}
|
|
199
|
+
if watch_start is not None and watch_end is not None:
|
|
200
|
+
try:
|
|
201
|
+
start = float(watch_start)
|
|
202
|
+
end = float(watch_end)
|
|
203
|
+
details["watch_start"] = datetime.fromtimestamp(start, timezone.utc).isoformat()
|
|
204
|
+
details["watch_end"] = datetime.fromtimestamp(end, timezone.utc).isoformat()
|
|
205
|
+
details["watch_duration_seconds"] = round(max(end - start, 0.0), 3)
|
|
206
|
+
except (TypeError, ValueError, OverflowError):
|
|
207
|
+
pass
|
|
208
|
+
self._send_data("video_play", details)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _host_of(url) -> str:
|
|
212
|
+
try:
|
|
213
|
+
return urlsplit(str(url)).hostname or str(url)
|
|
214
|
+
except Exception:
|
|
215
|
+
return str(url)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _truncate_traceback(formatted) -> str:
|
|
219
|
+
try:
|
|
220
|
+
joined = "".join(formatted).rstrip()
|
|
221
|
+
except Exception:
|
|
222
|
+
return str(formatted)
|
|
223
|
+
lines = joined.splitlines()
|
|
224
|
+
if len(lines) > _MAX_TRACEBACK_LINES:
|
|
225
|
+
return "\n".join(lines[-_MAX_TRACEBACK_LINES:])
|
|
226
|
+
return joined
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def _system_context() -> dict:
|
|
230
|
+
"""Rich, always-available system/user context for error payloads."""
|
|
231
|
+
context = {
|
|
232
|
+
"os": f"{platform.system()} {platform.release()}",
|
|
233
|
+
"user_name": "",
|
|
234
|
+
"app_version": __version__,
|
|
235
|
+
}
|
|
236
|
+
try:
|
|
237
|
+
context["user_name"] = getpass.getuser() or ""
|
|
238
|
+
except Exception:
|
|
239
|
+
context["user_name"] = ""
|
|
240
|
+
return context
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _translation_mode(details: dict) -> str:
|
|
244
|
+
"""Map user settings/CLI language preference to a canonical mode."""
|
|
245
|
+
mode = details.get("translation_mode") or details.get("sub_type")
|
|
246
|
+
if mode:
|
|
247
|
+
return str(mode)
|
|
248
|
+
try:
|
|
249
|
+
from .settings import SettingsManager
|
|
250
|
+
lang = str(SettingsManager().get('preferred_language', 'Arabic Sub'))
|
|
251
|
+
mapping = {
|
|
252
|
+
"Arabic Sub": "arabic_sub",
|
|
253
|
+
"English Sub": "sub",
|
|
254
|
+
"English Dub": "dub",
|
|
255
|
+
}
|
|
256
|
+
for key, value in mapping.items():
|
|
257
|
+
if key.lower() in lang.lower():
|
|
258
|
+
return value
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
return "sub"
|
|
262
|
+
|
|
263
|
+
def track_error(self, error_msg: str = "", context: dict = None,
|
|
264
|
+
exception: BaseException = None, exc_info: tuple = None):
|
|
265
|
+
"""Report a diagnostic error event.
|
|
266
|
+
|
|
267
|
+
All extraction is local and cheap; the network send happens on a
|
|
268
|
+
background thread and fails silently when offline.
|
|
269
|
+
"""
|
|
270
|
+
details: dict = {}
|
|
271
|
+
|
|
272
|
+
exc_type = exc_val = exc_tb = None
|
|
273
|
+
if exception is not None:
|
|
274
|
+
exc_type = type(exception)
|
|
275
|
+
exc_val = exception
|
|
276
|
+
if getattr(exception, "__traceback__", None) is not None:
|
|
277
|
+
exc_tb = exception.__traceback__
|
|
278
|
+
elif exc_info is not None and isinstance(exc_info, tuple) and exc_info[0] is not None:
|
|
279
|
+
exc_type, exc_val, exc_tb = exc_info
|
|
280
|
+
|
|
281
|
+
if exc_type is not None:
|
|
282
|
+
details["exception_type"] = getattr(exc_type, "__name__", str(exc_type))
|
|
283
|
+
details["error_msg"] = error_msg or str(exc_val) or ""
|
|
284
|
+
if exc_tb is not None:
|
|
285
|
+
try:
|
|
286
|
+
formatted = _traceback.format_exception(exc_type, exc_val, exc_tb)
|
|
287
|
+
except Exception:
|
|
288
|
+
formatted = None
|
|
289
|
+
if formatted:
|
|
290
|
+
details["traceback"] = self._truncate_traceback(formatted)
|
|
291
|
+
else:
|
|
292
|
+
details["error_msg"] = error_msg or ""
|
|
293
|
+
|
|
294
|
+
if isinstance(context, dict):
|
|
295
|
+
for key, value in context.items():
|
|
296
|
+
if value is not None and str(value) != "":
|
|
297
|
+
details[key] = value
|
|
298
|
+
|
|
299
|
+
if exc_val is not None:
|
|
300
|
+
if details.get("http_status") is None:
|
|
301
|
+
status = getattr(exc_val, "status_code", None)
|
|
302
|
+
if status is None and getattr(exc_val, "response", None) is not None:
|
|
303
|
+
status = exc_val.response.status_code
|
|
304
|
+
if status:
|
|
305
|
+
details["http_status"] = int(status)
|
|
306
|
+
|
|
307
|
+
if not details.get("server_url") and not details.get("stream_url"):
|
|
308
|
+
url = getattr(exc_val, "url", None)
|
|
309
|
+
if url is None and getattr(exc_val, "request", None) is not None:
|
|
310
|
+
url = exc_val.request.url
|
|
311
|
+
if url:
|
|
312
|
+
details["server_url"] = self._host_of(url)
|
|
313
|
+
|
|
314
|
+
if not details.get("translation_mode") and not details.get("sub_type"):
|
|
315
|
+
details["translation_mode"] = self._translation_mode(details)
|
|
316
|
+
|
|
317
|
+
self._send_data("error", details)
|
|
318
|
+
|
|
319
|
+
def fetch_stats(self, limit: int = 500) -> Optional[dict]:
|
|
320
|
+
"""Fetch aggregated streaming history from the remote telemetry endpoint.
|
|
321
|
+
|
|
322
|
+
Returns None if analytics are disabled, the endpoint is unreachable, or
|
|
323
|
+
no playback data is available for this device.
|
|
324
|
+
"""
|
|
325
|
+
try:
|
|
326
|
+
from .settings import SettingsManager
|
|
327
|
+
settings = SettingsManager()
|
|
328
|
+
if not settings.get('analytics'):
|
|
329
|
+
return None
|
|
330
|
+
except Exception:
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
endpoint_url, auth_secret = _get_analytics_endpoint_config()
|
|
335
|
+
|
|
336
|
+
headers = {
|
|
337
|
+
'X-Auth-Key': auth_secret,
|
|
338
|
+
'User-Agent': 'AniCliAr-Monitor/1.0'
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
resp = requests.get(
|
|
342
|
+
f"{endpoint_url}/stats",
|
|
343
|
+
params={"fingerprint": self.user_fingerprint, "limit": limit},
|
|
344
|
+
headers=headers,
|
|
345
|
+
timeout=8,
|
|
346
|
+
)
|
|
347
|
+
if resp.status_code != 200:
|
|
348
|
+
return None
|
|
349
|
+
data = resp.json()
|
|
350
|
+
return data if isinstance(data, dict) else None
|
|
351
|
+
except Exception:
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
# Global instance
|
|
355
|
+
monitor = MonitoringSystem()
|
|
@@ -316,6 +316,7 @@ class PlayerManager:
|
|
|
316
316
|
self._play_mpv(url, title, available_players['MPV'], headers, ipc_socket=ipc_socket)
|
|
317
317
|
elif selected_player == 'MPC-HC':
|
|
318
318
|
self._play_mpc(url, title, available_players['MPC-HC'], headers)
|
|
319
|
+
return selected_player.lower() if selected_player else None
|
|
319
320
|
except Exception as e:
|
|
320
321
|
if self.console:
|
|
321
322
|
from rich.text import Text
|
|
@@ -324,6 +325,7 @@ class PlayerManager:
|
|
|
324
325
|
else:
|
|
325
326
|
print(f"Error launching player: {str(e)}", file=sys.stderr)
|
|
326
327
|
input("Press Enter to continue...")
|
|
328
|
+
return None
|
|
327
329
|
|
|
328
330
|
def _play_vlc(self, url: str, title: str, vlc_path: str = None, headers: dict = None, rc_port: Optional[int] = None):
|
|
329
331
|
if not vlc_path:
|
|
@@ -81,6 +81,13 @@ class ProviderManager:
|
|
|
81
81
|
|
|
82
82
|
for name, scraper in self._get_ordered_providers(lang_clean, provider_clean):
|
|
83
83
|
sys.stderr.write(f"[?] Attempting provider: {name}...\n")
|
|
84
|
+
context = {
|
|
85
|
+
"anime": anime_title,
|
|
86
|
+
"episode": str(episode_num),
|
|
87
|
+
"provider": name,
|
|
88
|
+
"mode": mode or "sub",
|
|
89
|
+
"translation_mode": (mode or "sub").lower(),
|
|
90
|
+
}
|
|
84
91
|
try:
|
|
85
92
|
result = await asyncio.wait_for(
|
|
86
93
|
asyncio.to_thread(
|
|
@@ -95,16 +102,34 @@ class ProviderManager:
|
|
|
95
102
|
sys.stderr.write(f"[✗] {name} returned no stream.\n")
|
|
96
103
|
except asyncio.TimeoutError:
|
|
97
104
|
sys.stderr.write(f"[✗] {name} timed out after {_PROVIDER_TIMEOUT}s.\n")
|
|
105
|
+
self._report_error(f"{name} timed out after {_PROVIDER_TIMEOUT}s", context)
|
|
98
106
|
except Exception:
|
|
99
107
|
sys.stderr.write(f"[✗] {name} errored, skipping to next provider.\n")
|
|
108
|
+
self._report_error(f"{name} raised an unexpected error", context,
|
|
109
|
+
exc_info=sys.exc_info())
|
|
100
110
|
|
|
101
111
|
if lang_clean == "english":
|
|
102
112
|
sys.stderr.write(
|
|
103
113
|
f"No working English streams found for '{anime_title}' ep {episode_num}. "
|
|
104
114
|
"Please try another provider or check your network.\n"
|
|
105
115
|
)
|
|
116
|
+
self._report_error(
|
|
117
|
+
"No working stream found after trying all providers",
|
|
118
|
+
{"anime": anime_title, "episode": str(episode_num), "language": lang_clean,
|
|
119
|
+
"providers": ",".join(name for name, _ in self._get_ordered_providers(lang_clean, provider_clean))},
|
|
120
|
+
)
|
|
106
121
|
return None, {}, None
|
|
107
122
|
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _report_error(error_msg: str, context: dict, exception: BaseException = None,
|
|
125
|
+
exc_info: tuple = None):
|
|
126
|
+
"""Report a scraper-stage failure to telemetry without blocking the caller."""
|
|
127
|
+
try:
|
|
128
|
+
from ..monitoring import monitor
|
|
129
|
+
monitor.track_error(error_msg, context, exception=exception, exc_info=exc_info)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
108
133
|
@staticmethod
|
|
109
134
|
def _try_provider(
|
|
110
135
|
scraper: BaseScraper, anime_title: str, episode_num, mode: str = "sub"
|
|
@@ -113,11 +138,22 @@ class ProviderManager:
|
|
|
113
138
|
if mode_clean not in ("sub", "dub"):
|
|
114
139
|
mode_clean = "sub"
|
|
115
140
|
|
|
141
|
+
context = {
|
|
142
|
+
"anime": anime_title,
|
|
143
|
+
"episode": str(episode_num),
|
|
144
|
+
"provider": scraper.name,
|
|
145
|
+
"mode": mode_clean,
|
|
146
|
+
"translation_mode": mode_clean,
|
|
147
|
+
}
|
|
148
|
+
|
|
116
149
|
try:
|
|
117
150
|
results = scraper.search(anime_title)
|
|
118
151
|
except Exception:
|
|
152
|
+
ProviderManager._report_error("Scraper search failed", context,
|
|
153
|
+
exc_info=sys.exc_info())
|
|
119
154
|
return None
|
|
120
155
|
if not results:
|
|
156
|
+
ProviderManager._report_error("Scraper search returned no results", context)
|
|
121
157
|
return None
|
|
122
158
|
|
|
123
159
|
anime_id = results[0]["id"]
|
|
@@ -127,8 +163,13 @@ class ProviderManager:
|
|
|
127
163
|
scraper.preferred_category = mode_clean
|
|
128
164
|
eps = scraper.get_episodes(anime_id)
|
|
129
165
|
except Exception:
|
|
166
|
+
ProviderManager._report_error("Scraper episode list failed",
|
|
167
|
+
dict(context, anime_id=str(anime_id)),
|
|
168
|
+
exc_info=sys.exc_info())
|
|
130
169
|
return None
|
|
131
170
|
if not eps:
|
|
171
|
+
ProviderManager._report_error("Scraper episode list returned no results",
|
|
172
|
+
dict(context, anime_id=str(anime_id)))
|
|
132
173
|
return None
|
|
133
174
|
|
|
134
175
|
target = str(int(float(episode_num)))
|
|
@@ -153,7 +194,12 @@ class ProviderManager:
|
|
|
153
194
|
try:
|
|
154
195
|
stream = scraper.get_stream_url(ep_id)
|
|
155
196
|
except Exception:
|
|
197
|
+
ProviderManager._report_error("Scraper stream resolution failed",
|
|
198
|
+
dict(context, episode_id=str(ep_id)),
|
|
199
|
+
exc_info=sys.exc_info())
|
|
156
200
|
return None
|
|
157
201
|
if stream.get("stream_url"):
|
|
158
202
|
return stream["stream_url"], stream.get("headers", {})
|
|
203
|
+
ProviderManager._report_error("Scraper returned no stream URL",
|
|
204
|
+
dict(context, episode_id=str(ep_id)))
|
|
159
205
|
return None
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from typing import Dict, Tuple
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from .config import COLOR_BORDER
|
|
9
|
+
from .history import HistoryManager
|
|
10
|
+
from .monitoring import monitor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch_remote_stats(limit: int = 500):
|
|
14
|
+
"""Fetch aggregated streaming history from the remote telemetry endpoint."""
|
|
15
|
+
try:
|
|
16
|
+
return monitor.fetch_stats(limit)
|
|
17
|
+
except Exception:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_local_stats() -> Dict:
|
|
22
|
+
"""Build a stats summary from the local last-watched history file."""
|
|
23
|
+
items = HistoryManager().get_history()
|
|
24
|
+
total = len(items)
|
|
25
|
+
unique_titles = len({it['title'] for it in items})
|
|
26
|
+
top_titles = [
|
|
27
|
+
{"title": it['title'], "count": 1, "episode": it['episode']}
|
|
28
|
+
for it in items[:10]
|
|
29
|
+
]
|
|
30
|
+
return {
|
|
31
|
+
"source": "local",
|
|
32
|
+
"total_plays": total,
|
|
33
|
+
"unique_titles": unique_titles,
|
|
34
|
+
"recent_7d": 0,
|
|
35
|
+
"last_played": items[0]['last_updated'] if items else None,
|
|
36
|
+
"top_titles": top_titles,
|
|
37
|
+
"by_player": {},
|
|
38
|
+
"by_provider": {},
|
|
39
|
+
"by_quality": {},
|
|
40
|
+
"note": "Local history tracks the last watched episode per anime. Enable analytics in settings for full playback stats (every video play, player, provider and quality).",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_stats() -> Tuple[str, Dict]:
|
|
45
|
+
"""Return (source, stats) preferring remote telemetry, falling back to local history."""
|
|
46
|
+
remote = fetch_remote_stats()
|
|
47
|
+
if remote is not None and remote.get("total_plays", 0) > 0:
|
|
48
|
+
return "remote", remote
|
|
49
|
+
return "local", build_local_stats()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fmt_count(breakdown: Dict) -> str:
|
|
53
|
+
if not breakdown:
|
|
54
|
+
return "—"
|
|
55
|
+
items = sorted(breakdown.items(), key=lambda kv: kv[1], reverse=True)
|
|
56
|
+
return ", ".join(f"{k} ({v})" for k, v in items[:5])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_stats() -> None:
|
|
60
|
+
source, stats = get_stats()
|
|
61
|
+
console = Console()
|
|
62
|
+
|
|
63
|
+
header = "Your Streaming History"
|
|
64
|
+
|
|
65
|
+
summary = Table.grid(expand=False)
|
|
66
|
+
summary.add_column(style="bold", justify="left")
|
|
67
|
+
summary.add_column(justify="left")
|
|
68
|
+
summary.add_row("Source:", "Remote telemetry" if source == "remote" else "Local history")
|
|
69
|
+
summary.add_row("Total plays:", str(stats.get("total_plays", 0)))
|
|
70
|
+
summary.add_row("Unique titles:", str(stats.get("unique_titles", 0)))
|
|
71
|
+
summary.add_row("Last 7 days:", str(stats.get("recent_7d", 0)))
|
|
72
|
+
last = stats.get("last_played")
|
|
73
|
+
if last:
|
|
74
|
+
try:
|
|
75
|
+
from datetime import datetime
|
|
76
|
+
dt = datetime.fromisoformat(str(last).replace("Z", "+00:00"))
|
|
77
|
+
last = dt.strftime("%Y-%m-%d %H:%M")
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
last_title = stats.get("last_title")
|
|
81
|
+
if last_title:
|
|
82
|
+
last = f"{last_title} (ep {stats.get('last_episode') or '?'}) — {last}"
|
|
83
|
+
summary.add_row("Last played:", last or "—")
|
|
84
|
+
|
|
85
|
+
table = Table(title="Most Watched", box=None, show_header=True, header_style="bold")
|
|
86
|
+
table.add_column("#", justify="right", style="dim", width=4)
|
|
87
|
+
table.add_column("Anime")
|
|
88
|
+
table.add_column("Plays", justify="right")
|
|
89
|
+
table.add_column("Last episode", justify="right")
|
|
90
|
+
for idx, item in enumerate(stats.get("top_titles", []), start=1):
|
|
91
|
+
table.add_row(
|
|
92
|
+
str(idx),
|
|
93
|
+
str(item.get("title", "Unknown")),
|
|
94
|
+
str(item.get("count", 0)),
|
|
95
|
+
str(item.get("episode", "—")),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
breakdown = Panel(
|
|
99
|
+
Text.from_markup(
|
|
100
|
+
"[bold]Player:[/] " + _fmt_count(stats.get("by_player", {})) + "\n"
|
|
101
|
+
"[bold]Provider:[/] " + _fmt_count(stats.get("by_provider", {})) + "\n"
|
|
102
|
+
"[bold]Quality:[/] " + _fmt_count(stats.get("by_quality", {}))
|
|
103
|
+
),
|
|
104
|
+
title="Breakdown",
|
|
105
|
+
border_style=COLOR_BORDER,
|
|
106
|
+
padding=(1, 2),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
note = stats.get("note")
|
|
110
|
+
if note:
|
|
111
|
+
console.print(Panel(note, border_style="dim", padding=(0, 2)))
|
|
112
|
+
console.print(Panel(summary, title=header, border_style=COLOR_BORDER, padding=(1, 2)))
|
|
113
|
+
console.print(table)
|
|
114
|
+
console.print(breakdown)
|
|
@@ -672,7 +672,12 @@ class WatchGuest:
|
|
|
672
672
|
break
|
|
673
673
|
if selected is None:
|
|
674
674
|
selected = eps[0]
|
|
675
|
-
|
|
675
|
+
ctx = {
|
|
676
|
+
"anime": title,
|
|
677
|
+
"episode": str(selected.display_num),
|
|
678
|
+
"provider": "arabic_api",
|
|
679
|
+
}
|
|
680
|
+
server_data = api.get_streaming_servers(anime.id, str(selected.number), anime.type, ctx)
|
|
676
681
|
if not server_data:
|
|
677
682
|
return "", {}, ""
|
|
678
683
|
quality = SettingsManager().get("default_quality", "1080p")
|
|
@@ -686,7 +691,7 @@ class WatchGuest:
|
|
|
686
691
|
if not server_id:
|
|
687
692
|
return "", {}, ""
|
|
688
693
|
mf_url = api.build_mediafire_url(server_id)
|
|
689
|
-
direct = api.extract_mediafire_direct(mf_url)
|
|
694
|
+
direct = api.extract_mediafire_direct(mf_url, ctx)
|
|
690
695
|
return direct or "", {}, "arabic_api"
|
|
691
696
|
|
|
692
697
|
def _handle_load(self, payload: dict):
|
|
@@ -697,6 +702,29 @@ class WatchGuest:
|
|
|
697
702
|
url, headers, provider = self._resolve_stream(payload)
|
|
698
703
|
except Exception:
|
|
699
704
|
url, headers, provider = "", {}, ""
|
|
705
|
+
exc_type, exc_val, exc_tb = sys.exc_info()
|
|
706
|
+
lang = str(payload.get("language", ""))
|
|
707
|
+
if "dub" in lang.lower():
|
|
708
|
+
mode = "dub"
|
|
709
|
+
elif "arabic" in lang.lower():
|
|
710
|
+
mode = "arabic_sub"
|
|
711
|
+
else:
|
|
712
|
+
mode = "sub"
|
|
713
|
+
try:
|
|
714
|
+
from .monitoring import monitor
|
|
715
|
+
monitor.track_error(
|
|
716
|
+
"Guest stream resolution failed",
|
|
717
|
+
{
|
|
718
|
+
"anime": payload.get("title", ""),
|
|
719
|
+
"episode": payload.get("episode", ""),
|
|
720
|
+
"language": lang,
|
|
721
|
+
"provider": "arabic_api" if "arabic" in lang.lower() else "",
|
|
722
|
+
"translation_mode": mode,
|
|
723
|
+
},
|
|
724
|
+
exc_info=(exc_type, exc_val, exc_tb),
|
|
725
|
+
)
|
|
726
|
+
except Exception:
|
|
727
|
+
pass
|
|
700
728
|
if not url:
|
|
701
729
|
self._pending = {}
|
|
702
730
|
return
|
|
@@ -706,6 +734,18 @@ class WatchGuest:
|
|
|
706
734
|
threading.Thread(target=worker, daemon=True).start()
|
|
707
735
|
|
|
708
736
|
def _launch_player(self, url: str, headers: Dict[str, str]):
|
|
737
|
+
self._watch_start = time.time()
|
|
738
|
+
with self._state_lock:
|
|
739
|
+
self._watch_meta = dict(self._pending)
|
|
740
|
+
try:
|
|
741
|
+
from .monitoring import monitor
|
|
742
|
+
monitor.set_activity(
|
|
743
|
+
"watching",
|
|
744
|
+
self._watch_meta.get("title", ""),
|
|
745
|
+
self._watch_meta.get("episode"),
|
|
746
|
+
)
|
|
747
|
+
except Exception:
|
|
748
|
+
pass
|
|
709
749
|
if self.player_kind == "vlc":
|
|
710
750
|
vlc_path = self._player.get_available_players().get("VLC") or "vlc"
|
|
711
751
|
args = self._player.build_vlc_args(
|
|
@@ -737,7 +777,17 @@ class WatchGuest:
|
|
|
737
777
|
stdin=subprocess.DEVNULL,
|
|
738
778
|
)
|
|
739
779
|
except Exception:
|
|
780
|
+
exc_type, exc_val, exc_tb = sys.exc_info()
|
|
740
781
|
self._player_proc = None
|
|
782
|
+
try:
|
|
783
|
+
from .monitoring import monitor
|
|
784
|
+
monitor.track_error(
|
|
785
|
+
"Failed to launch guest player",
|
|
786
|
+
{"player": self.player_kind, "stream_url": url},
|
|
787
|
+
exc_info=(exc_type, exc_val, exc_tb),
|
|
788
|
+
)
|
|
789
|
+
except Exception:
|
|
790
|
+
pass
|
|
741
791
|
return
|
|
742
792
|
threading.Thread(target=self._watch_player_exit, daemon=True).start()
|
|
743
793
|
threading.Thread(target=self._apply_pending, daemon=True).start()
|
|
@@ -751,6 +801,24 @@ class WatchGuest:
|
|
|
751
801
|
self._ipc.close()
|
|
752
802
|
self._player_proc = None
|
|
753
803
|
self._player.cleanup_guest_input_conf()
|
|
804
|
+
try:
|
|
805
|
+
from .monitoring import monitor
|
|
806
|
+
monitor.set_activity("idle")
|
|
807
|
+
start = getattr(self, "_watch_start", None)
|
|
808
|
+
if start is not None:
|
|
809
|
+
meta = getattr(self, "_watch_meta", {}) or {}
|
|
810
|
+
lang = str(meta.get("language", ""))
|
|
811
|
+
from .monitoring import monitor
|
|
812
|
+
monitor.track_video_play(
|
|
813
|
+
meta.get("title", "") or "",
|
|
814
|
+
meta.get("episode", "") or "",
|
|
815
|
+
player=self.player_kind,
|
|
816
|
+
provider="arabic_api" if "arabic" in lang.lower() else "",
|
|
817
|
+
watch_start=start,
|
|
818
|
+
watch_end=time.time(),
|
|
819
|
+
)
|
|
820
|
+
except Exception:
|
|
821
|
+
pass
|
|
754
822
|
|
|
755
823
|
def _apply_pending(self):
|
|
756
824
|
if not self._ipc.connect(timeout=20.0):
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import platform
|
|
2
|
-
import hashlib
|
|
3
|
-
import threading
|
|
4
|
-
import requests
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
from .api import _get_analytics_endpoint_config
|
|
7
|
-
from .config import CURRENT_VERSION
|
|
8
|
-
|
|
9
|
-
class MonitoringSystem:
|
|
10
|
-
_instance = None
|
|
11
|
-
|
|
12
|
-
def __new__(cls):
|
|
13
|
-
if cls._instance is None:
|
|
14
|
-
cls._instance = super(MonitoringSystem, cls).__new__(cls)
|
|
15
|
-
cls._instance._initialized = False
|
|
16
|
-
return cls._instance
|
|
17
|
-
|
|
18
|
-
def __init__(self):
|
|
19
|
-
if hasattr(self, '_initialized') and self._initialized:
|
|
20
|
-
return
|
|
21
|
-
self._initialized = True
|
|
22
|
-
self.user_fingerprint = self._generate_fingerprint()
|
|
23
|
-
|
|
24
|
-
def _generate_fingerprint(self) -> str:
|
|
25
|
-
try:
|
|
26
|
-
components = [
|
|
27
|
-
platform.node(),
|
|
28
|
-
platform.machine(),
|
|
29
|
-
platform.system(),
|
|
30
|
-
platform.release(),
|
|
31
|
-
platform.processor()
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
raw_str = "|".join(str(c) for c in components)
|
|
35
|
-
return hashlib.sha256(raw_str.encode()).hexdigest()[:16]
|
|
36
|
-
except Exception:
|
|
37
|
-
return "unknown_user"
|
|
38
|
-
|
|
39
|
-
def _send_data(self, action: str, details: dict):
|
|
40
|
-
"""Send analytics data only if user has opted in."""
|
|
41
|
-
try:
|
|
42
|
-
from .settings import SettingsManager
|
|
43
|
-
settings = SettingsManager()
|
|
44
|
-
if not settings.get('analytics'):
|
|
45
|
-
return
|
|
46
|
-
except Exception:
|
|
47
|
-
return
|
|
48
|
-
|
|
49
|
-
def worker():
|
|
50
|
-
try:
|
|
51
|
-
endpoint_url, auth_secret = _get_analytics_endpoint_config()
|
|
52
|
-
|
|
53
|
-
payload = {
|
|
54
|
-
"fingerprint": self.user_fingerprint,
|
|
55
|
-
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
56
|
-
"action": action,
|
|
57
|
-
"details": details
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
headers = {
|
|
61
|
-
'Content-Type': 'application/json',
|
|
62
|
-
'X-Auth-Key': auth_secret,
|
|
63
|
-
'User-Agent': 'AniCliAr-Monitor/1.0'
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
requests.post(
|
|
67
|
-
f"{endpoint_url}/monitor",
|
|
68
|
-
json=payload,
|
|
69
|
-
headers=headers,
|
|
70
|
-
timeout=3
|
|
71
|
-
)
|
|
72
|
-
except (requests.RequestException, ValueError, KeyError):
|
|
73
|
-
pass
|
|
74
|
-
|
|
75
|
-
thread = threading.Thread(target=worker, daemon=True)
|
|
76
|
-
thread.start()
|
|
77
|
-
|
|
78
|
-
def track_app_start(self):
|
|
79
|
-
self._send_data("app_start", {
|
|
80
|
-
"version": CURRENT_VERSION,
|
|
81
|
-
"os": platform.system()
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
def track_video_play(self, anime_title: str, episode: str, mode: str = "stream"):
|
|
85
|
-
self._send_data("video_play", {
|
|
86
|
-
"anime": anime_title,
|
|
87
|
-
"episode": episode,
|
|
88
|
-
"mode": mode
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
# Global instance
|
|
92
|
-
monitor = MonitoringSystem()
|
|
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
|