From bdfc6a76baf2251ba7d23d8cb4444138c53a92dd Mon Sep 17 00:00:00 2001 From: sebdelsol Date: Wed, 20 Mar 2024 18:36:13 +0100 Subject: [PATCH] v1.4.12.41 beta 3 --- .vscode/settings.json | 1 + README.md | 3 +- build/changelog.md | 3 + dev/tools/release.py | 8 +- dev/tools/templater.py | 8 +- requirements.txt | 1 + resources/README_template.md | 1 + sfvip_all_config.py | 3 - src/mitm/addon/__init__.py | 101 +++++----- src/mitm/addon/all.py | 13 +- src/mitm/cache.py | 372 ++++++++++++++++++++++------------- src/mitm/epg/cache.py | 12 +- src/mitm/epg/update.py | 29 ++- src/mitm/proxies.py | 31 ++- src/mitm/utils.py | 29 +-- src/sfvip/__init__.py | 3 +- src/sfvip/accounts.py | 1 + src/sfvip/player/__init__.py | 6 +- src/sfvip/player/find_exe.py | 14 +- src/sfvip/proxies.py | 22 ++- src/sfvip/ui/hover.py | 9 +- src/sfvip/ui/infos.py | 10 +- src/sfvip/ui/progress.py | 1 + src/winapi/process.py | 21 ++ src/winapi/version.py | 5 +- translations/bulgarian.json | 12 +- translations/english.json | 6 +- translations/french.json | 12 +- translations/german.json | 10 +- translations/greek.json | 8 +- translations/italian.json | 8 +- translations/loc/texts.py | 6 +- translations/polish.json | 8 +- translations/russian.json | 10 +- translations/serbian.json | 6 +- translations/slovenian.json | 8 +- translations/spanish.json | 8 +- translations/turkish.json | 6 +- 38 files changed, 468 insertions(+), 347 deletions(-) create mode 100644 src/winapi/process.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 296b199d..c31a94eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "python.analysis.diagnosticMode": "workspace", // formatter & import sort "ruff.format.args": ["--line-length=115"], + "ruff.lint.args": ["--line-length=115"], "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": "explicit", diff --git a/README.md b/README.md index 48392913..264115c3 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ * Insert an _All_ category when missing so you can easily **search your entire catalog**. * Update ***[Mpv](https://mpv.io/)*** and **Sfvip Player** so you can enjoy their latest features. +* Cache MAC acccounts all categories to access it faster. * Translated in all **Sfvip Player** languages. * Support an **external EPG**[^1]. @@ -43,7 +44,7 @@ You'll find them in the app folder: # Build [![version](https://custom-icon-badges.demolab.com/badge/Build%201.4.12.41-informational?logo=github)](/build_config.py#L27) -[![Sloc](https://custom-icon-badges.demolab.com/badge/Sloc%208.3k-informational?logo=file-code)](https://api.codetabs.com/v1/loc/?github=sebdelsol/sfvip-all) +[![Sloc](https://custom-icon-badges.demolab.com/badge/Sloc%208.4k-informational?logo=file-code)](https://api.codetabs.com/v1/loc/?github=sebdelsol/sfvip-all) [![Ruff](https://custom-icon-badges.demolab.com/badge/Ruff-informational?logo=ruff-color)](https://docs.astral.sh/ruff/) [![Python](https://custom-icon-badges.demolab.com/badge/Python%203.11.8-linen?logo=python-color)](https://www.python.org/downloads/release/python-3118/) [![mitmproxy](https://custom-icon-badges.demolab.com/badge/Mitmproxy%2010.2.4-linen?logo=mitmproxy-black)](https://mitmproxy.org/) diff --git a/build/changelog.md b/build/changelog.md index 5fcfcf09..5c5dafb5 100644 --- a/build/changelog.md +++ b/build/changelog.md @@ -1,4 +1,7 @@ ## 1.4.12.41 +* Faster MAC accounts cache for all categories. +* MAC accounts cache handles partial update. +* All categories are added only when needed. * Tooltips added. ## 1.4.12.40 diff --git a/dev/tools/release.py b/dev/tools/release.py index f8bd4d19..dcbb5619 100644 --- a/dev/tools/release.py +++ b/dev/tools/release.py @@ -1,13 +1,7 @@ from pathlib import Path from typing import Optional -from github import ( - Auth, - BadCredentialsException, - Github, - GithubException, - UnknownObjectException, -) +from github import Auth, BadCredentialsException, Github, GithubException, UnknownObjectException from github.GitRelease import GitRelease from api_keys import GITHUB_TOKEN diff --git a/dev/tools/templater.py b/dev/tools/templater.py index 726be3bc..720dc129 100644 --- a/dev/tools/templater.py +++ b/dev/tools/templater.py @@ -13,13 +13,7 @@ from .scanner.file import ScanFile from .utils.color import Low, Ok, Title, Warn from .utils.dist import human_format, repr_size -from .utils.protocols import ( - CfgBuild, - CfgEnvironments, - CfgGithub, - CfgTemplate, - CfgTemplates, -) +from .utils.protocols import CfgBuild, CfgEnvironments, CfgGithub, CfgTemplate, CfgTemplates def _version_of(python_envs: PythonEnvs, name: str) -> Optional[str]: diff --git a/requirements.txt b/requirements.txt index e89a58fe..2915f62f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ # packages to run the app for both environments mitmproxy>=10.1.6 +msgspec>=0.18.6 tkinter-tooltip>=2.2.0 watchdog>=3.0.0 # for downloading the player and libmpv diff --git a/resources/README_template.md b/resources/README_template.md index e34a0fee..b3022b92 100644 --- a/resources/README_template.md +++ b/resources/README_template.md @@ -3,6 +3,7 @@ * Insert an _All_ category when missing so you can easily **search your entire catalog**. * Update ***[Mpv](https://mpv.io/)*** and **Sfvip Player** so you can enjoy their latest features. +* Cache MAC acccounts all categories to access it faster. * Translated in all **Sfvip Player** languages. * Support an **external EPG**[^1]. diff --git a/sfvip_all_config.py b/sfvip_all_config.py index 45bbf38e..2aee0bc3 100644 --- a/sfvip_all_config.py +++ b/sfvip_all_config.py @@ -25,6 +25,3 @@ class EPG: confidence: int = 30 requests_timeout: int = 5 prefer_internal: bool = True - - class AllCategory: - inject_in_live: bool = False diff --git a/src/mitm/addon/__init__.py b/src/mitm/addon/__init__.py index fc60751c..8c477716 100644 --- a/src/mitm/addon/__init__.py +++ b/src/mitm/addon/__init__.py @@ -8,7 +8,7 @@ from mitmproxy import http from mitmproxy.proxy.server_hooks import ServerConnectionHookData -from ..cache import AllUpdated, MACCache, UpdateCacheProgressT +from ..cache import AllCached, MACCache, UpdateCacheProgressT from ..epg import EPG, EpgCallbacks from ..utils import APItype, get_query_key, response_json from .all import AllCategoryName, AllPanels @@ -63,16 +63,20 @@ def set_epg_server(flow: http.HTTPFlow, epg: EPG, api: APItype) -> None: class ApiRequest: - _api = {"portal.php?": APItype.MAC, "player_api.php?": APItype.XC} + _api = { + "player_api.php": APItype.XC, + "stalker_portal": APItype.MAC, + "portal.php": APItype.MAC, + "load.php": APItype.MAC, + } def __init__(self, accounts_urls: set[str]) -> None: self.accounts_urls = accounts_urls - def __call__(self, flow: http.HTTPFlow) -> Optional[APItype]: + async def __call__(self, flow: http.HTTPFlow) -> Optional[APItype]: request = flow.request - for request_part, api in ApiRequest._api.items(): - if request_part in request.path: - return api + if api := ApiRequest._api.get(request.path_components[0]): + return api return APItype.M3U if request.url in self.accounts_urls else None @@ -135,7 +139,7 @@ def disconnect(self, data: ServerConnectionHookData) -> None: class AddonAllConfig(NamedTuple): all_name: AllCategoryName - all_updated: AllUpdated + all_cached: AllCached class SfVipAddOn: @@ -152,7 +156,7 @@ def __init__( timeout: int, ) -> None: self.api_request = ApiRequest(accounts_urls) - self.mac_cache = MACCache(roaming, update_progress, all_config.all_updated) + self.mac_cache = MACCache(roaming, update_progress, all_config.all_cached) self.epg = EPG(roaming, epg_callbacks, timeout) self.m3u_stream = M3UStream(self.epg) self.panels = AllPanels(all_config.all_name) @@ -176,56 +180,61 @@ def wait_running(self, timeout: int) -> bool: return self.epg.wait_running(timeout) async def request(self, flow: http.HTTPFlow) -> None: - logger.debug("REQUEST %s", flow.request.pretty_url) - if api := self.api_request(flow): + # logger.debug("REQUEST %s", flow.request.pretty_url) + if api := await self.api_request(flow): match api, get_query_key(flow, "action"): case APItype.MAC, "get_ordered_list": - self.mac_cache.load_response(flow) + await self.mac_cache.load_response(flow) case APItype.MAC, _: self.mac_cache.stop(flow) case APItype.XC, action if action: self.panels.serve_all(flow, action) - async def response(self, flow: http.HTTPFlow) -> None: - logger.debug("RESPONSE %s %s", flow.request.pretty_url, flow.response and flow.response.status_code) - if not self.m3u_stream.start(flow): - if flow.response and not flow.response.stream: - if api := self.api_request(flow): - match api, get_query_key(flow, "action"): - case APItype.M3U, _: - set_epg_server(flow, self.epg, api) - case APItype.MAC, "get_short_epg": - get_short_epg(flow, self.epg, api) - case APItype.MAC, "get_all_channels": - set_epg_server(flow, self.epg, api) - case APItype.MAC, "get_ordered_list": - self.mac_cache.save_response(flow) - case APItype.MAC, "get_categories": - self.mac_cache.inject_all_cached_category(flow) - case APItype.XC, "get_series_info": - fix_series_info(flow.response) - case APItype.XC, "get_live_streams": - set_epg_server(flow, self.epg, api) - case APItype.XC, "get_short_epg" if not get_query_key(flow, "category_id"): - get_short_epg(flow, self.epg, api) - case APItype.XC, action if action: - self.panels.inject_all(flow, action) + async def responseheaders(self, flow: http.HTTPFlow) -> None: + """all reponses are streamed except the api requests""" + # logger.debug("STREAM %s", flow.request.pretty_url) + if not await self.api_request(flow): + if flow.response: + flow.response.stream = True + async def response(self, flow: http.HTTPFlow) -> None: + # logger.debug("RESPONSE %s %s", flow.request.pretty_url, flow.response and flow.response.status_code) + if not flow.response: + return + if not flow.response.stream: + if api := await self.api_request(flow): + match api, get_query_key(flow, "action"): + case APItype.MAC, "get_ordered_list": + await self.mac_cache.save_response(flow) + case APItype.MAC, "get_short_epg": + get_short_epg(flow, self.epg, api) + case APItype.MAC, "get_all_channels": + set_epg_server(flow, self.epg, api) + case APItype.MAC, "get_categories": + self.mac_cache.inject_all_cached_category(flow) + case APItype.XC, "get_series_info": + fix_series_info(flow.response) + case APItype.XC, "get_live_streams": + set_epg_server(flow, self.epg, api) + case APItype.XC, "get_short_epg" if not get_query_key(flow, "category_id"): + get_short_epg(flow, self.epg, api) + case APItype.XC, action if action: + self.panels.inject_all(flow, action) + case APItype.M3U, _: + set_epg_server(flow, self.epg, api) + else: + self.m3u_stream.start(flow) + self.mac_cache.stop_all() + + # TODO progress for MAC Cache not hiding !! nee to call self.mac_cache.stop_all(), but where? async def error(self, flow: http.HTTPFlow): - logger.debug("ERROR %s", flow.request.pretty_url) + # logger.debug("ERROR %s", flow.request.pretty_url) if not self.m3u_stream.stop(flow): - if api := self.api_request(flow): + if api := await self.api_request(flow): match api, get_query_key(flow, "action"): case APItype.MAC, "get_ordered_list": self.mac_cache.stop(flow) - async def responseheaders(self, flow: http.HTTPFlow) -> None: - """all reponses are streamed except the api requests""" - logger.debug("STREAM %s", flow.request.pretty_url) - if not self.api_request(flow): - if flow.response: - flow.response.stream = True - - async def server_disconnected(self, data: ServerConnectionHookData) -> None: - logger.debug("DISCONNECT %s %s", data.server.peername, data.server.transport_protocol) + def server_disconnected(self, data: ServerConnectionHookData) -> None: + # logger.debug("DISCONNECT %s %s", data.server.peername, data.server.transport_protocol) self.m3u_stream.disconnect(data) diff --git a/src/mitm/addon/all.py b/src/mitm/addon/all.py index 29a214d1..5b84925a 100644 --- a/src/mitm/addon/all.py +++ b/src/mitm/addon/all.py @@ -13,8 +13,8 @@ class AllCategoryName(NamedTuple): live: Optional[str] - series: str - vod: str + series: Optional[str] + vod: Optional[str] @dataclass @@ -56,10 +56,11 @@ def _log(verb: str, panel: Panel, action: str) -> None: class AllPanels: def __init__(self, all_name: AllCategoryName) -> None: - panels = [ - _get_panel(PanelType.VOD, all_name.vod), - _get_panel(PanelType.SERIES, all_name.series, streams=False), - ] + panels = [] + if all_name.series: + panels.append(_get_panel(PanelType.SERIES, all_name.series, streams=False)) + if all_name.vod: + panels.append(_get_panel(PanelType.VOD, all_name.vod)) if all_name.live: panels.append(_get_panel(PanelType.LIVE, all_name.live)) self.category_panel = {panel.get_category: panel for panel in panels} diff --git a/src/mitm/cache.py b/src/mitm/cache.py index ed20c3f3..525bfca8 100644 --- a/src/mitm/cache.py +++ b/src/mitm/cache.py @@ -1,33 +1,22 @@ -import json import logging +import pickle import time -from contextlib import contextmanager from enum import Enum, auto from pathlib import Path -from typing import ( - IO, - Any, - Callable, - Iterator, - Literal, - NamedTuple, - Optional, - Self, - TypeVar, -) +from typing import IO, Any, Callable, Literal, NamedTuple, Optional, Self, TypeVar from mitmproxy import http from ..winapi import mutex from .cache_cleaner import CacheCleaner -from .utils import ProgressStep, get_int, get_query_key, response_json +from .utils import ProgressStep, content_json, get_int, get_query_key, json_encoder logger = logging.getLogger(__name__) MediaTypes = "vod", "series" ValidMediaTypes = Literal["vod", "series"] -class MACQuery(NamedTuple): +class MacQuery(NamedTuple): server: str type: ValidMediaTypes @@ -44,32 +33,8 @@ def get_from(cls, flow: http.HTTPFlow) -> Optional[Self]: ) return None - -class AllUpdated(NamedTuple): - today: str - one_day: str - several_days: str - fast_cached: str - all_names: dict[ValidMediaTypes, str] = {} - all_updates: dict[ValidMediaTypes, str] = {} - - def all_title(self, query: MACQuery, is_cached: bool) -> str: - all_name = self.all_names.get(query.type, "") - return f"{all_name} - {self.fast_cached}" if is_cached else all_name - - def _days_ago(self, path: Path) -> str: - timestamp = path.stat().st_mtime - days = int((time.time() - timestamp) / (3600 * 24)) - match days: - case 0: - return self.today - case 1: - return self.one_day - case _: - return self.several_days % days - - def update_all_title(self, query: MACQuery, path: Path) -> str: - return f"🔄 {self.all_updates.get(query.type)}\n({self._days_ago(path)})" + def __str__(self) -> str: + return f"{self.server}.{self.type}" class CacheProgressEvent(Enum): @@ -84,16 +49,14 @@ class CacheProgress(NamedTuple): UpdateCacheProgressT = Callable[[CacheProgress], None] - -DataT = list[dict[str, Any]] T = TypeVar("T") -def get_js(response: http.Response, wanted_type: type[T]) -> Optional[T]: +def get_js(content: Optional[bytes], wanted_type: type[T]) -> Optional[T]: if ( - (json_content := response_json(response)) - and isinstance(json_content, dict) - and (js := json_content.get("js")) + (json_ := content_json(content)) + and isinstance(json_, dict) + and (js := json_.get("js")) and isinstance(js, wanted_type) and js ): @@ -101,124 +64,265 @@ def get_js(response: http.Response, wanted_type: type[T]) -> Optional[T]: return None +def get_reponse_js(response: http.Response, wanted_type: type[T]) -> Optional[T]: + return get_js(response.content, wanted_type) + + def set_js(obj: Any) -> dict[str, Any]: return {"js": obj} -class MACContent: - def __init__(self, update_progress: UpdateCacheProgressT) -> None: - self.data: DataT = [] +def sanitize_filename(filename: str) -> str: + for char in "/?<>\\:*|": + filename = filename.replace(char, ".") + return filename + + +class MACCacheFile: + def __init__(self, cache_dir: Path, query: MacQuery) -> None: + self.cache_dir = cache_dir + self.query = query + self.file_path = self.cache_dir / sanitize_filename(str(self.query)) + self.mutex = mutex.SystemWideMutex(f"file lock for {self.file_path}") + + def open_and_do( + self, mode: Literal["rb", "wb"], do: Callable[[IO[bytes]], T], *exceptions: type[Exception] + ) -> Optional[T]: + with self.mutex: + try: + with self.file_path.open(mode) as file: + if file: + return do(file) + except (*exceptions, PermissionError, FileNotFoundError, OSError): + pass + return None + + +class MacCacheLoad(MACCacheFile): + def __init__(self, cache_dir: Path, query: MacQuery) -> None: + self.total: int = 0 + self.actual: int = 0 + self.content: bytes = b"" + super().__init__(cache_dir, query) + self._load() + + def _load(self) -> None: + def _load(file: IO[bytes]) -> None: + # pylint: disable=too-many-boolean-expressions + if ( + (total := pickle.load(file)) + and isinstance(total, int) + and (actual := pickle.load(file)) + and isinstance(actual, int) + and (content := pickle.load(file)) + and isinstance(content, bytes) + ): + self.total = total + self.actual = actual + self.content = content + logger.info("Load Cache from '%s' (%s out of %s)", file.name, self.actual, self.total) + + self.open_and_do("rb", _load, pickle.PickleError, TypeError, EOFError) + + @property + def valid(self) -> bool: + return bool(self.total) + + @property + def missing_percent(self) -> float: + return ((self.total - self.actual) / self.total) if self.total else 1 + + +class MacCacheSave(MACCacheFile): + def __init__(self, cache_dir: Path, query: MacQuery, update_progress: UpdateCacheProgressT) -> None: + self.total: int = 0 + self.valid: bool = True + self.contents: list[bytes] = [] + self.max_pages: float = 0 self.progress_step = ProgressStep(step=0.0005) self.update_progress = update_progress - - def extend(self, flow: http.HTTPFlow) -> Optional[dict]: - if flow.response and (js := get_js(flow.response, dict)): - data = js.get("data", []) - total = js.get("total_items", 0) - page = get_int(get_query_key(flow, "p")) - self.data.extend(data) - if page == 1: # page - self.progress_step.set_total(total) + logger.info("Start creating Cache for %s.%s", query.server, query.type) + super().__init__(cache_dir, query) + + async def update(self, response: http.Response, page: int) -> bool: + if not self.valid: + return False + if not (content := response.content): + logger.warning("No content for page %s for %s cache", page, str(self.query)) + self.valid = False + return False + if page == 1: + if ( + (js := get_js(content, dict)) + and (total := get_int(js.get("total_items"))) + and (max_page_items := get_int(js.get("max_page_items"))) + ): + self.contents = [] + self.total = total + self.max_pages = total / max_page_items + self.progress_step.set_total(self.max_pages) self.update_progress(CacheProgress(CacheProgressEvent.START)) - if self.progress_step and (progress := self.progress_step.progress(len(self.data))): - self.update_progress(CacheProgress(CacheProgressEvent.SHOW, progress)) - if len(self.data) >= total: - self.update_progress(CacheProgress(CacheProgressEvent.STOP)) - return set_js(dict(max_page_items=total, total_items=total, data=self.data)) - return None + else: + logger.warning("Wrong 1st page for %s cache", str(self.query)) + self.valid = False + return False + self.contents.append(content) + # if page != len(self.contents): + # logger.warning("Wrong page count for %s cache", str(self.query)) + # self.valid = False + # return False + if progress := self.progress_step.progress(page): + self.update_progress(CacheProgress(CacheProgressEvent.SHOW, progress)) + return page >= self.max_pages + + @staticmethod + def update_with_loaded(loaded: MacCacheLoad, all_data: list[dict]) -> list[dict]: + if ( + (js := get_js(loaded.content, dict)) + and (loaded_data := js.get("data")) + and isinstance(loaded_data, list) + and len(loaded_data) > len(all_data) + ): + loaded_ids = {id_: data for data in loaded_data if isinstance(data, dict) and (id_ := data.get("id"))} + all_ids = {id_: data for data in all_data if isinstance(data, dict) and (id_ := data.get("id"))} + loaded_ids |= all_ids + return list(loaded_ids.values()) + return all_data + + def save(self, loaded: Optional[MacCacheLoad]) -> bool: + def _save(file: IO[bytes]) -> bool: + if self.valid and self.total: + all_data: list[dict] = [] + for content in self.contents: + if (js := get_js(content, dict)) and (data := js.get("data")) and isinstance(data, list): + all_data.extend(data) + # update with loaded if not complete + if self.total != len(all_data) and loaded and loaded.valid: + all_data = self.update_with_loaded(loaded, all_data) + actual = len(all_data) + js = set_js(dict(max_page_items=actual, total_items=actual, data=all_data)) + content = json_encoder.encode(js) + pickle.dump(self.total, file) + pickle.dump(actual, file) + pickle.dump(content, file) + logger.info("Save Cache to '%s' (%s out of %s)", file.name, actual, self.total) + return True + return False + + self.update_progress(CacheProgress(CacheProgressEvent.STOP)) + return bool(self.open_and_do("wb", _save, pickle.PickleError, TypeError)) + + +class AllCached(NamedTuple): + complete: str + missing: str + today: str + one_day: str + several_days: str + fast_cached: str + all_names: dict[ValidMediaTypes, str] = {} + all_updates: dict[ValidMediaTypes, str] = {} + def title(self, loaded: MacCacheLoad) -> str: + if missing_percent := loaded.missing_percent: + missing_str = f"{max(1, min(round(missing_percent * 100), 99))}%" + missing_str = f"⚠️ {self.missing % missing_str}" + else: + missing_str = f"✔ {self.complete}" + return ( + f"{self.all_names.get(loaded.query.type, '')} - {self.fast_cached.capitalize()}" + f"\n{self._days_ago(loaded.file_path)} {missing_str}" + ) -def sanitize_filename(filename: str) -> str: - for char in "/?<>\\:*|": - filename = filename.replace(char, ".") - return filename + def _days_ago(self, path: Path) -> str: + timestamp = path.stat().st_mtime + days = int((time.time() - timestamp) / (3600 * 24)) + match days: + case 0: + return self.today + case 1: + return self.one_day + case _: + return self.several_days % days class MACCache(CacheCleaner): - encoding = "utf-8" cached_marker = "ListCached" cached_marker_bytes = cached_marker.encode() cached_all_category = "cached_all_category" - update_all_category = "*" + all_category = "*" clean_after_days = 15 suffixes = MediaTypes - def __init__(self, roaming: Path, update_progress: UpdateCacheProgressT, all_updated: AllUpdated) -> None: - self.data: DataT = [] - self.contents: dict[str, MACContent] = {} - self.update_progress = update_progress - self.all_updated = all_updated + def __init__(self, roaming: Path, update_progress: UpdateCacheProgressT, all_cached: AllCached) -> None: super().__init__(roaming, MACCache.clean_after_days, *MACCache.suffixes) + self.saved_queries: dict[MacQuery, MacCacheSave] = {} + self.loaded_queries: dict[MacQuery, MacCacheLoad] = {} + self.update_progress = update_progress + self.all_cached = all_cached - def file_path(self, query: MACQuery) -> Path: - return self.cache_dir / sanitize_filename(f"{query.server}.{query.type}") - - @contextmanager - def file(self, query: MACQuery, mode: Literal["r", "w"]) -> Iterator[IO[str] | None]: - path = self.file_path(query) - with mutex.SystemWideMutex(f"file lock for {path}"): - try: - with path.open(mode, encoding=MACCache.encoding) as file: - logger.info("%s '%s'", "Load cache from" if mode == "r" else "Save cache in", file.name) - yield file - except (PermissionError, FileNotFoundError): - yield None - - def save_response(self, flow: http.HTTPFlow) -> None: + async def save_response(self, flow: http.HTTPFlow) -> None: if ( - get_query_key(flow, "category") == MACCache.update_all_category - and (response := flow.response) + (response := flow.response) + and get_query_key(flow, "category") == MACCache.all_category + and (page := get_int(get_query_key(flow, "p"))) and MACCache.cached_marker_bytes not in response.headers - and (query := MACQuery.get_from(flow)) + and (query := MacQuery.get_from(flow)) ): - if query.server not in self.contents: - self.contents[query.server] = MACContent(self.update_progress) - if final := self.contents[query.server].extend(flow): - with self.file(query, "w") as file: - if file: - file.write(json.dumps(final)) - del self.contents[query.server] + if query not in self.saved_queries: + self.saved_queries[query] = MacCacheSave(self.cache_dir, query, self.update_progress) + if await self.saved_queries[query].update(flow.response, page): + self.save(query) + + def save(self, query: MacQuery) -> None: + self.saved_queries[query].save(self.loaded_queries.get(query)) + del self.saved_queries[query] def stop(self, flow: http.HTTPFlow) -> None: - if (server := flow.request.host_header) in self.contents: - self.update_progress(CacheProgress(CacheProgressEvent.STOP)) - del self.contents[server] - - def load_response(self, flow: http.HTTPFlow) -> None: - if get_query_key(flow, "category") == MACCache.cached_all_category and (query := MACQuery.get_from(flow)): - with self.file(query, "r") as file: - if file: - flow.response = http.Response.make( - content=file.read(), - headers={ - "Content-Type": "application/json", - MACCache.cached_marker: "", - }, - ) + if (query := MacQuery.get_from(flow)) in self.saved_queries: + self.save(query) + + def stop_all(self) -> None: + for query in self.saved_queries.copy(): + self.save(query) + + async def load_response(self, flow: http.HTTPFlow) -> None: + if ( + get_query_key(flow, "category") == MACCache.cached_all_category + and (query := MacQuery.get_from(flow)) + and (loaded := self.loaded_queries[query]) + and (loaded.valid) + ): + flow.response = http.Response.make( + content=loaded.content, + headers={ + "Content-Type": "application/json", + MACCache.cached_marker: "", + }, + ) def inject_all_cached_category(self, flow: http.HTTPFlow): # pylint: disable=too-many-boolean-expressions if ( - (query := MACQuery.get_from(flow)) - and (response := flow.response) - and (categories := get_js(response, list)) + (response := flow.response) + and (query := MacQuery.get_from(flow)) + and (categories := get_reponse_js(response, list)) and (all_category := categories[0]) and isinstance(all_category, dict) - and (all_category.get("id") == MACCache.update_all_category) + and (all_category.get("id") == MACCache.all_category) ): - path = self.file_path(query) - is_cached = path and path.is_file() - all_title = self.all_updated.all_title(query, is_cached=is_cached) - logger.info("Rename '%s' category for '%s'", all_title, query.server) - all_category["title"] = all_title - if is_cached: - all_category["id"] = MACCache.cached_all_category - update_all_category = dict( - alias=MACCache.update_all_category, + # clean queries for other servers + for existing_query in self.loaded_queries.copy(): + if existing_query.server != query.server: + del self.loaded_queries[existing_query] + # always update the loaded query since it might have changed + loaded = self.loaded_queries[query] = MacCacheLoad(self.cache_dir, query) + if loaded.valid: + cached_all_category = dict( censored=0, - id=MACCache.update_all_category, - title=self.all_updated.update_all_title(query, path), + alias=MACCache.all_category, + id=MACCache.cached_all_category, + title=self.all_cached.title(loaded), ) - categories.insert(1, update_all_category) - logger.info("Inject cached '%s' category for '%s'", all_title, query.server) - response.text = json.dumps(dict(js=categories)) + categories.insert(1, cached_all_category) + response.content = json_encoder.encode(set_js(categories)) diff --git a/src/mitm/epg/cache.py b/src/mitm/epg/cache.py index 723b9e99..9256bd28 100644 --- a/src/mitm/epg/cache.py +++ b/src/mitm/epg/cache.py @@ -3,15 +3,7 @@ import pickle from contextlib import contextmanager from pathlib import Path -from typing import ( - IO, - Iterator, - KeysView, - Literal, - NamedTuple, - Optional, - Sequence, -) +from typing import IO, Iterator, KeysView, Literal, NamedTuple, Optional, Sequence from shared.md5 import compute_md5 @@ -85,7 +77,7 @@ def get_programmes(self, epg_id: str) -> ProgrammesT: try: with self.cache_file.open("rb") as f: if f: - all_programmes = [] + all_programmes: list[InternalProgramme] = [] for position in positions: f.seek(position.seek) pickle_str = f.read(position.length) diff --git a/src/mitm/epg/update.py b/src/mitm/epg/update.py index 7206ca48..bddbf078 100644 --- a/src/mitm/epg/update.py +++ b/src/mitm/epg/update.py @@ -7,15 +7,7 @@ from contextlib import contextmanager from enum import Enum, auto, member from pathlib import Path -from typing import ( - IO, - Callable, - Container, - Iterator, - NamedTuple, - Optional, - Self, -) +from typing import IO, Callable, Container, Iterator, NamedTuple, Optional, Self from urllib.parse import urlparse import lxml.etree as ET @@ -57,12 +49,16 @@ class EPGProcess(NamedTuple): def _normalize(name: str) -> str: - name = re.sub(r"(\.)([\d]+)", r"\2", name) # turn channel.2 into channel2 + # turn channel.2 into channel2 + name = re.sub(r"(\.)([\d]+)", r"\2", name) for sub, repl in ("+", "plus"), ("*", "star"): name = name.replace(sub, repl) for char in ".|()[]-": name = name.replace(char, " ") - name = re.sub(r"\s+", " ", name).strip() # remove extra white spaces + # insert space if uppercase letter is preceded and followed by one lowercase letter + # name = re.sub(r"([A-Z][a-z]+)([A-Z][a-z]+)", r"\1 \2", name) + # remove extra white spaces + name = re.sub(r"\s+", " ", name).strip() return name @@ -75,7 +71,7 @@ def _valid_url(url: str) -> bool: def parse_programme(file_obj: IO[bytes] | gzip.GzipFile, epg_process: EPGProcess) -> Iterator[NamedProgrammes]: - current_programmes: dict[InternalProgramme, bool] = {} + current_programmes: list[InternalProgramme] = [] current_channel_id: Optional[str] = None normalized: dict[str, str] = {} progress_step = ProgressStep() @@ -94,25 +90,24 @@ def parse_programme(file_obj: IO[bytes] | gzip.GzipFile, epg_process: EPGProcess case "channel": if channel_id := elem.get("id", None): progress_step.increment_total(1) + normalized[channel_id] = _normalize(channel_id) case "title": # child of title = elem.text or "" case "desc": # child of desc = elem.text or "" case "programme": - if channel_id := elem.get("channel", None): - if not (norm_channel_id := normalized.get(channel_id)): - norm_channel_id = normalized[channel_id] = _normalize(channel_id) + if norm_channel_id := normalized.get(elem.get("channel", None)): if norm_channel_id != current_channel_id: if current_channel_id and current_programmes: yield NamedProgrammes(tuple(current_programmes), current_channel_id) - current_programmes = {} + current_programmes = [] current_channel_id = norm_channel_id if progress := progress_step.increment_progress(1): epg_process.update_status(EPGProgress(EPGstatus.PROCESSING, progress)) start = elem.get("start", "") stop = elem.get("stop", "") programme = InternalProgramme(start=start, stop=stop, title=title, desc=desc) - current_programmes[programme] = True + current_programmes.append(programme) title = "" desc = "" elem.clear(False) diff --git a/src/mitm/proxies.py b/src/mitm/proxies.py index 6e4e0427..c5799883 100644 --- a/src/mitm/proxies.py +++ b/src/mitm/proxies.py @@ -2,39 +2,35 @@ import asyncio import logging import multiprocessing +import socket import threading from typing import Any, NamedTuple, Optional, Sequence from mitmproxy import options -from mitmproxy.addons import ( - core, - disable_h2c, - dns_resolver, - next_layer, - proxyserver, - tlsconfig, -) +from mitmproxy.addons import core, next_layer, proxyserver, tlsconfig from mitmproxy.master import Master from mitmproxy.net import server_spec from shared import LogProcess +from ..winapi.process import set_current_process_high_priority from .addon import SfVipAddOn logger = logging.getLogger(__name__) # use only the needed addons, -# Note: use addons.default_addons() instead if any issues -def _minimum_addons() -> Sequence[Any]: +def _minimum_addons(user_addon: SfVipAddOn) -> Sequence[Any]: return ( core.Core(), - disable_h2c.DisableH2C(), proxyserver.Proxyserver(), - dns_resolver.DnsResolver(), + user_addon, next_layer.NextLayer(), tlsconfig.TlsConfig(), ) + # if any issues: + # from mitmproxy.addons import default_addons, script + # return [user_addon if isinstance(addon, script.ScriptLoader) else addon for addon in default_addons()] class Mode(NamedTuple): @@ -66,17 +62,20 @@ def __init__(self, addon: SfVipAddOn, modes: set[Mode]) -> None: super().__init__() def run(self) -> None: + socket.setdefaulttimeout(0) # is it better ?? with LogProcess(logger, "Mitmproxy"): + if set_current_process_high_priority(): + logger.info("Set process to high priority") threading.Thread(target=self._wait_for_stop).start() if self._modes: # launch one proxy per mode modes = [mode.to_mitm() for mode in self._modes] - # do not verify upstream server SSL/TLS certificates - opts = options.Options(ssl_insecure=True, mode=modes) loop = asyncio.get_event_loop() with self._master_lock: - self._master = Master(opts, event_loop=loop) - self._master.addons.add(self._addon, *_minimum_addons()) + self._master = Master(options.Options(), event_loop=loop) + self._master.addons.add(*_minimum_addons(self._addon)) + # do not verify upstream server SSL/TLS certificates + self._master.options.update(ssl_insecure=True, mode=modes) loop.run_until_complete(self._master.run()) else: self._addon.running() diff --git a/src/mitm/utils.py b/src/mitm/utils.py index 2cc5fd93..51889f79 100644 --- a/src/mitm/utils.py +++ b/src/mitm/utils.py @@ -1,10 +1,13 @@ -import json from enum import Enum, auto from typing import Any, Optional +import msgspec from mitmproxy import http from mitmproxy.coretypes.multidict import MultiDictView +json_decoder = msgspec.json.Decoder() +json_encoder = msgspec.json.Encoder() + class APItype(Enum): XC = auto() @@ -13,7 +16,7 @@ class APItype(Enum): def _query(request: http.Request) -> MultiDictView[str, str]: - return getattr(request, "urlencoded_form" if request.method == "POST" else "query") + return request.urlencoded_form if request.method == "POST" else request.query def del_query_key(flow: http.HTTPFlow, key: str) -> None: @@ -24,22 +27,22 @@ def get_query_key(flow: http.HTTPFlow, key: str) -> Optional[str]: return _query(flow.request).get(key) -def response_json(response: Optional[http.Response]) -> Any: +def content_json(content: Optional[bytes]) -> Any: try: - if ( - response - and (content := response.text) - # could be other types like javascript - # and "application/json" in response.headers.get("content-type", "") - and (content_json := json.loads(content)) - ): - return content_json - except json.JSONDecodeError: + if content and (json_ := json_decoder.decode(content)): + return json_ + except msgspec.MsgspecError: pass return None -def get_int(text: Optional[str]) -> Optional[int]: +def response_json(response: Optional[http.Response]) -> Any: + if response and (json_ := content_json(response.content)): + return json_ + return None + + +def get_int(text: Optional[str | int]) -> Optional[int]: try: if text: return int(text) diff --git a/src/sfvip/__init__.py b/src/sfvip/__init__.py index 228a0e2a..fb487da5 100644 --- a/src/sfvip/__init__.py +++ b/src/sfvip/__init__.py @@ -29,13 +29,12 @@ def run_app(at_last_register: AltLastRegisterT, app_info: AppInfo, keep_logs: in app_updater = AppUpdater(app_info, at_last_register) app_auto_updater = AppAutoUpdater(app_updater, app_config, ui, player.stop) - inject_in_live = app_config.AllCategory.inject_in_live if player.has_all_channels else True def run() -> None: while player.want_to_launch(): ui.splash.show(player.rect) accounts_proxies = AccountsProxies(app_info.roaming, ui) - with LocalProxies(app_info, inject_in_live, accounts_proxies, ui) as local_proxies: + with LocalProxies(app_info, player.capabilities, accounts_proxies, ui) as local_proxies: with accounts_proxies.set(local_proxies.by_upstreams) as restore_accounts_proxies: with app_auto_updater: with player.run(): diff --git a/src/sfvip/accounts.py b/src/sfvip/accounts.py index e1492bce..966f196b 100644 --- a/src/sfvip/accounts.py +++ b/src/sfvip/accounts.py @@ -166,6 +166,7 @@ def set_proxies() -> None: def restore_proxies() -> None: self._set_proxies(proxies_to_restore.all, "restore") + # TODO new account !!!!!!!!!!!!!! def restore_after_being_read(player_relaunch: Callable[[int], None]) -> None: def on_modified(last_modified: float) -> None: # to prevent recursion check it occured after any modification done by any instance diff --git a/src/sfvip/player/__init__.py b/src/sfvip/player/__init__.py index 97c48f8a..9287636c 100644 --- a/src/sfvip/player/__init__.py +++ b/src/sfvip/player/__init__.py @@ -10,7 +10,7 @@ from ..ui import UI, sticky from ..watchers import RegistryWatcher, WindowWatcher from .config import PlayerConfig, PlayerConfigDirSettingWatcher -from .find_exe import PlayerExe +from .find_exe import PlayerCapabilities, PlayerExe from .libmpv_updater import PlayerLibmpvAutoUpdater from .updater import PlayerAutoUpdater @@ -135,8 +135,8 @@ def __init__(self, app_info: AppInfo, ui: UI) -> None: self._launcher = _Launcher() @property - def has_all_channels(self) -> bool: - return self._player_exe.has_all_channels + def capabilities(self) -> PlayerCapabilities: + return self._player_exe.capabilities def want_to_launch(self) -> bool: if self._launcher.want_to_launch(): diff --git a/src/sfvip/player/find_exe.py b/src/sfvip/player/find_exe.py index 17455275..9f0a2027 100644 --- a/src/sfvip/player/find_exe.py +++ b/src/sfvip/player/find_exe.py @@ -57,6 +57,11 @@ def update(self) -> Self: return self.__class__.from_exe(self.exe) +class PlayerCapabilities(NamedTuple): + has_all_channels: bool + has_all_categories: bool + + class PlayerExe: """find the player exe""" @@ -64,6 +69,7 @@ class PlayerExe: _pattern = "*sf*vip*player*.exe" _min_version = "1.2.5.7" _version_without_all_channels = "1.2.5.7" + _version_without_all_categories = "1.2.7.72" def __init__(self, app_info: AppInfo, ui: UI) -> None: self._ui = ui @@ -83,8 +89,12 @@ def exe(self) -> Path: return self._found.exe @property - def has_all_channels(self) -> bool: - return self._found.version > Version(PlayerExe._version_without_all_channels) + def capabilities(self) -> PlayerCapabilities: + self._found = self._found.update() + return PlayerCapabilities( + has_all_channels=self._found.version > Version(PlayerExe._version_without_all_channels), + has_all_categories=self._found.version > Version(PlayerExe._version_without_all_categories), + ) @property def found(self) -> FoundExe: diff --git a/src/sfvip/proxies.py b/src/sfvip/proxies.py index caa0151e..17f4bc9f 100644 --- a/src/sfvip/proxies.py +++ b/src/sfvip/proxies.py @@ -6,7 +6,7 @@ from translations.loc import LOC from ..mitm.addon import AddonAllConfig, AllCategoryName, EpgCallbacks, SfVipAddOn -from ..mitm.cache import AllUpdated +from ..mitm.cache import AllCached from ..mitm.proxies import MitmLocalProxy, Mode, validate_upstream from ..winapi import mutex from .accounts import AccountsProxies @@ -14,6 +14,7 @@ from .cache import CacheProgressListener from .epg import EpgUpdater, EPGUpdates from .exceptions import LocalproxyError +from .player import PlayerCapabilities from .ui import UI logger = logging.getLogger(__name__) @@ -50,20 +51,21 @@ def _fix_upstream(url: str) -> Optional[str]: return None -def get_all_config(inject_in_live: bool) -> AddonAllConfig: +def get_all_config(player_capabilities: PlayerCapabilities) -> AddonAllConfig: return AddonAllConfig( AllCategoryName( - live=LOC.AllChannels if inject_in_live else None, - series=LOC.AllSeries, - vod=LOC.AllMovies, + live=None if player_capabilities.has_all_channels else LOC.AllChannels, + series=None if player_capabilities.has_all_categories else LOC.AllSeries, + vod=None if player_capabilities.has_all_categories else LOC.AllMovies, ), - AllUpdated( + AllCached( + missing=LOC.Missing, + complete=LOC.Complete, today=LOC.UpdatedToday, one_day=LOC.Updated1DayAgo, several_days=LOC.UpdatedDaysAgo, fast_cached=LOC.FastCached, all_names={"vod": LOC.AllMovies, "series": LOC.AllSeries}, - all_updates={"vod": LOC.UpdateAllMovies, "series": LOC.UpdateAllSeries}, ), ) @@ -75,7 +77,9 @@ class LocalProxies: _mitmproxy_start_timeout = 10 _find_ports_retry = 10 - def __init__(self, app_info: AppInfo, inject_in_live: bool, accounts_proxies: AccountsProxies, ui: UI) -> None: + def __init__( + self, app_info: AppInfo, player_capabilities: PlayerCapabilities, accounts_proxies: AccountsProxies, ui: UI + ) -> None: self._epg_updater = EpgUpdater( app_info.config, EPGUpdates( @@ -88,7 +92,7 @@ def __init__(self, app_info: AppInfo, inject_in_live: bool, accounts_proxies: Ac self._cache_progress = CacheProgressListener(ui) self._addon = SfVipAddOn( accounts_proxies.urls, - get_all_config(inject_in_live), + get_all_config(player_capabilities), app_info.roaming, EpgCallbacks( self._epg_updater.update_status, diff --git a/src/sfvip/ui/hover.py b/src/sfvip/ui/hover.py index 0c7934cd..99139e9c 100644 --- a/src/sfvip/ui/hover.py +++ b/src/sfvip/ui/hover.py @@ -9,14 +9,7 @@ from .fx import Fade from .sticky import Offset, StickyWindow, sticky_windows from .style import Style -from .widgets import ( - Border, - Button, - GetWidgetT, - HorizontalProgressBar, - RoundedBox, - RoundedBoxScroll, -) +from .widgets import Border, Button, GetWidgetT, HorizontalProgressBar, RoundedBox, RoundedBoxScroll # TODO check max width & height diff --git a/src/sfvip/ui/infos.py b/src/sfvip/ui/infos.py index 746b79fb..ec1ca2f8 100644 --- a/src/sfvip/ui/infos.py +++ b/src/sfvip/ui/infos.py @@ -10,15 +10,7 @@ from .fx import Fade from .sticky import Offset, StickyWindow from .style import Style -from .widgets import ( - Border, - Button, - CheckBox, - HorizontalScale, - ListView, - ScrollBar, - get_border, -) +from .widgets import Border, Button, CheckBox, HorizontalScale, ListView, ScrollBar, get_border class Info(NamedTuple): diff --git a/src/sfvip/ui/progress.py b/src/sfvip/ui/progress.py index 66e3b1d9..d944f9e7 100644 --- a/src/sfvip/ui/progress.py +++ b/src/sfvip/ui/progress.py @@ -19,6 +19,7 @@ def _get_bar_style(bg: str) -> str: return bar_style_name +# TODO add % label class ProgressBar(StickyWindow): _bg = "#1c1b1a" _height = 10 diff --git a/src/winapi/process.py b/src/winapi/process.py new file mode 100644 index 00000000..ae3daf9c --- /dev/null +++ b/src/winapi/process.py @@ -0,0 +1,21 @@ +import ctypes +import os +from ctypes.wintypes import BOOL, DWORD, HANDLE + +HIGH_PRIORITY_CLASS = 0x0080 + +_kernel32 = ctypes.windll.kernel32 +_OpenProcess = _kernel32.OpenProcess +_OpenProcess.argtypes = [DWORD, BOOL, DWORD] +_OpenProcess.restype = HANDLE +_SetPriorityClass = _kernel32.SetPriorityClass +_SetPriorityClass.argtypes = [HANDLE, DWORD] +_SetPriorityClass.restype = BOOL + +PROCESS_ALL_ACCESS = 0x000F0000 | 0x00100000 | 0xFFFF + + +def set_current_process_high_priority() -> bool: + if handle := _OpenProcess(PROCESS_ALL_ACCESS, True, os.getpid()): + return _SetPriorityClass(handle, HIGH_PRIORITY_CLASS) + return False diff --git a/src/winapi/version.py b/src/winapi/version.py index 0405d905..cd759f22 100644 --- a/src/winapi/version.py +++ b/src/winapi/version.py @@ -44,7 +44,10 @@ class LANGANDCODEPAGE(Structure): # the following arbitrarily gets the first language and codepage from # the list ret = windll.version.VerQueryValueW( - buffer, r"\VarFileInfo\Translation", byref(value), byref(value_size) # type: ignore + buffer, + r"\VarFileInfo\Translation", + byref(value), + byref(value_size), # type: ignore ) if ret == 0: diff --git a/translations/bulgarian.json b/translations/bulgarian.json index 094c35ef..62712b03 100644 --- a/translations/bulgarian.json +++ b/translations/bulgarian.json @@ -47,11 +47,9 @@ "UpdatedToday": "Актуализиран днес", "Updated1DayAgo": "Актуализиран преди 1 ден", "UpdatedDaysAgo": "Актуализиран преди %s дни", - "UpdateAllSeries": "Актуализиране на всички серии", - "UpdateAllMovies": "Актуализиране на всички филми", - "Confidence0": "0 %: Не се доверявате на EPG и ще получите само точно съвпадение и често никакво.", - "Confidence100": "100 %: Доверявате се напълно на EPG и винаги получавате съвпадение, дори и с лошо качество", - "EPGPrefer": "Търсене първо при доставчика на IPTV", + "Confidence0": "0 %: Не се доверявате на EPG и ще получите само точно съвпадение, а често и никакво.", + "Confidence100": "100 %: Напълно се доверявате на EPG и винаги получавате съвпадение, дори и с лошо качество.", + "EPGPrefer": "Първо потърсете доставчика на IPTV", "Yes": "Да", "No": "Не", "EPGPreferYes": "Да: Първо търсите в EPG на IPTV доставчика. Използвайте външния EPG само когато той не успее.", @@ -59,5 +57,7 @@ "EPGUrlTip": "Въведете URL адреса на външния EPG, който трябва да завършва с \"%s\" или \"%s\".", "LibmpvTip": "Libmpv декодира и визуализира аудио и видео. Активирайте актуализациите, за да получите последната версия, оптимизирана за вашия компютър.", "ProxyTip": "%s използва локален прокси сървър за прихващане на всички заявки към доставчика на IPTV и инжектиране на категориите 'all' и външния EPG", - "UserProxyTip": "Действително потребителско прокси, ако съществува такова" + "UserProxyTip": "Действително потребителско прокси, ако съществува такова", + "Missing": "%s липсва", + "Complete": "Пълен" } \ No newline at end of file diff --git a/translations/english.json b/translations/english.json index 76992fce..26c27a61 100644 --- a/translations/english.json +++ b/translations/english.json @@ -47,8 +47,6 @@ "UpdatedToday": "Updated today", "Updated1DayAgo": "Updated 1 day ago", "UpdatedDaysAgo": "Updated %s days ago", - "UpdateAllSeries": "Update All series", - "UpdateAllMovies": "Update All movies", "Confidence0": "0 %: You don't trust the EPG and you'll only get an exact match and often none", "Confidence100": "100 %: You completely trust the EPG and you'll always get a match even one of poor quality", "EPGPrefer": "Search the IPTV provider first", @@ -59,5 +57,7 @@ "EPGUrlTip": "Enter the URL of the external EPG, it should end up with '%s' or '%s'", "LibmpvTip": "Libmpv decodes & renders audio and video. Enable the updates to get the last version optimized for your computer.", "ProxyTip": "%s uses a local proxy to intercept all requests to the IPTV provider and inject the 'all' categories and the external EPG", - "UserProxyTip": "Actual user proxy if it exists" + "UserProxyTip": "Actual user proxy if it exists", + "Missing": "%s missing", + "Complete": "Complete" } \ No newline at end of file diff --git a/translations/french.json b/translations/french.json index 8a6f746b..11b841f4 100644 --- a/translations/french.json +++ b/translations/french.json @@ -7,7 +7,7 @@ "HideProxies": "Cacher les proxies", "RestartFixProxy": "Redémarrer pour corriger les proxies", "ShouldUseVersion": "Vous devriez utiliser la version %s", - "SearchWholeCatalog": "Rechercher dans tout votre catalogue", + "SearchWholeCatalog": "Rechercher dans l'ensemble de votre catalogue", "Download": "Télécharger", "Install": "Installer", "Extract": "Extraire", @@ -46,12 +46,10 @@ "ChangeLog": "Journal des modifications pour %s", "UpdatedToday": "Mis à jour aujourd'hui", "Updated1DayAgo": "Mis à jour il y a 1 jour", - "UpdatedDaysAgo": "Mis à jour il y a %s jours", - "UpdateAllSeries": "Mise à jour de toutes les séries", - "UpdateAllMovies": "Mise à jour de tous les films", + "UpdatedDaysAgo": "Mise à jour il y a %s jours", "Confidence0": "0 % : Vous ne faites pas confiance à l'EPG et vous n'obtiendrez qu'une correspondance exacte et souvent aucune.", "Confidence100": "100 % : Vous faites entièrement confiance à l'EPG et vous obtiendrez toujours une correspondance, même de mauvaise qualité.", - "EPGPrefer": "Chercher d'abord chez le fournisseur IPTV", + "EPGPrefer": "Chercher d'abord chez le fournisseur de TVIP", "Yes": "Oui", "No": "Non", "EPGPreferYes": "Oui : Recherchez d'abord dans l'EPG du fournisseur de télévision par internet. N'utilisez l'EPG externe qu'en cas d'échec.", @@ -59,5 +57,7 @@ "EPGUrlTip": "Entrez l'URL de l'EPG externe, qui devrait se terminer par '%s' ou '%s'.", "LibmpvTip": "Libmpv décode et rend l'audio et la vidéo. Activez les mises à jour pour obtenir la dernière version optimisée pour votre ordinateur.", "ProxyTip": "%s utilise un proxy local pour intercepter toutes les requêtes adressées au fournisseur IPTV et injecter les catégories 'all' et l'EPG externe.", - "UserProxyTip": "Proxy de l'utilisateur actuel s'il existe" + "UserProxyTip": "Proxy utilisateur réel s'il existe", + "Missing": "%s manquant", + "Complete": "Complète" } \ No newline at end of file diff --git a/translations/german.json b/translations/german.json index 7e6a5d6f..40c68ea0 100644 --- a/translations/german.json +++ b/translations/german.json @@ -47,10 +47,8 @@ "UpdatedToday": "Heute aktualisiert", "Updated1DayAgo": "Aktualisiert vor 1 Tag", "UpdatedDaysAgo": "Aktualisiert vor %s Tagen", - "UpdateAllSeries": "Alle Serien aktualisieren", - "UpdateAllMovies": "Alle Filme aktualisieren", - "Confidence0": "0 %: Sie vertrauen dem EPG nicht und erhalten nur eine exakte Übereinstimmung und oft keine", - "Confidence100": "100 %: Sie vertrauen dem EPG voll und ganz und erhalten immer einen Treffer, auch wenn er von schlechter Qualität ist", + "Confidence0": "0 %: Sie trauen dem EPG nicht und erhalten nur eine exakte Übereinstimmung und oft keine", + "Confidence100": "100 %: Sie vertrauen dem EPG voll und ganz und erhalten immer eine Übereinstimmung, auch wenn diese von schlechter Qualität ist.", "EPGPrefer": "Suchen Sie zuerst den IPTV-Anbieter", "Yes": "Ja", "No": "Nein", @@ -59,5 +57,7 @@ "EPGUrlTip": "Geben Sie die URL des externen EPGs ein, sie sollte mit '%s' oder '%s' enden.", "LibmpvTip": "Libmpv dekodiert und rendert Audio und Video. Aktivieren Sie die Updates, um die letzte für Ihren Computer optimierte Version zu erhalten.", "ProxyTip": "%s verwendet einen lokalen Proxy, um alle Anfragen an den IPTV-Anbieter abzufangen und die 'all'-Kategorien und den externen EPG zu injizieren", - "UserProxyTip": "Tatsächlicher Benutzer-Proxy, wenn er existiert" + "UserProxyTip": "Tatsächlicher Benutzer-Proxy, wenn er existiert", + "Missing": "%s fehlt", + "Complete": "Vollständig" } \ No newline at end of file diff --git a/translations/greek.json b/translations/greek.json index 08aa6bda..77083911 100644 --- a/translations/greek.json +++ b/translations/greek.json @@ -47,9 +47,7 @@ "UpdatedToday": "Ενημερώθηκε σήμερα", "Updated1DayAgo": "Ενημερώθηκε πριν από 1 ημέρα", "UpdatedDaysAgo": "Ενημερώθηκε πριν από %s ημέρες", - "UpdateAllSeries": "Ενημέρωση Όλες οι σειρές", - "UpdateAllMovies": "Ενημέρωση όλων των ταινιών", - "Confidence0": "0 %: Δεν εμπιστεύεστε το EPG και θα έχετε μόνο μια ακριβή αντιστοιχία και συχνά καμία.", + "Confidence0": "0 %: Δεν εμπιστεύεστε το EPG και θα βρείτε μόνο μια ακριβή αντιστοιχία και συχνά καμία", "Confidence100": "100 %: Εμπιστεύεστε πλήρως το EPG και θα έχετε πάντα μια αντιστοιχία, ακόμη και κακής ποιότητας.", "EPGPrefer": "Αναζητήστε πρώτα τον πάροχο IPTV", "Yes": "Ναι", @@ -59,5 +57,7 @@ "EPGUrlTip": "Εισάγετε τη διεύθυνση URL του εξωτερικού EPG, θα πρέπει να καταλήγει σε '%s' ή '%s'.", "LibmpvTip": "Το Libmpv αποκωδικοποιεί και αποδίδει ήχο και βίντεο. Ενεργοποιήστε τις ενημερώσεις για να λάβετε την τελευταία έκδοση βελτιστοποιημένη για τον υπολογιστή σας.", "ProxyTip": "Το %s χρησιμοποιεί έναν τοπικό μεσάζοντα για να υποκλέψει όλα τα αιτήματα προς τον πάροχο IPTV και να εισάγει τις κατηγορίες 'all' και το εξωτερικό EPG", - "UserProxyTip": "Πραγματικός μεσολάβησης χρήστη, εάν υπάρχει" + "UserProxyTip": "Πραγματικός μεσολάβησης χρήστη, εάν υπάρχει", + "Missing": "%s λείπει", + "Complete": "Πλήρης" } \ No newline at end of file diff --git a/translations/italian.json b/translations/italian.json index 8855ae94..474a521e 100644 --- a/translations/italian.json +++ b/translations/italian.json @@ -47,9 +47,7 @@ "UpdatedToday": "Aggiornato oggi", "Updated1DayAgo": "Aggiornato 1 giorno fa", "UpdatedDaysAgo": "Aggiornato %s giorni fa", - "UpdateAllSeries": "Aggiorna tutte le serie", - "UpdateAllMovies": "Aggiorna tutti i film", - "Confidence0": "0 %: Non ci si fida dell'EPG e si ottiene solo una corrispondenza esatta e spesso nessuna.", + "Confidence0": "0 %: non ci si fida dell'EPG e si ottiene solo una corrispondenza esatta e spesso nessuna.", "Confidence100": "100 %: ci si fida completamente dell'EPG e si ottiene sempre una corrispondenza, anche se di scarsa qualità.", "EPGPrefer": "Cercare prima il provider IPTV", "Yes": "Si", @@ -59,5 +57,7 @@ "EPGUrlTip": "Immettere l'URL dell'EPG esterno, che dovrebbe finire con '%s' o '%s'.", "LibmpvTip": "Libmpv decodifica e rende audio e video. Attivare gli aggiornamenti per ottenere l'ultima versione ottimizzata per il proprio computer.", "ProxyTip": "%s utilizza un proxy locale per intercettare tutte le richieste al provider IPTV e iniettare le categorie 'all' e l'EPG esterno.", - "UserProxyTip": "Proxy utente effettivo, se esiste" + "UserProxyTip": "Proxy utente effettivo, se esiste", + "Missing": "%s mancante", + "Complete": "Completo" } \ No newline at end of file diff --git a/translations/loc/texts.py b/translations/loc/texts.py index bf1fe75e..e4aa582b 100644 --- a/translations/loc/texts.py +++ b/translations/loc/texts.py @@ -1,6 +1,8 @@ import dataclasses from typing import ClassVar +# TODO force IPTV no translate + # pylint: disable=too-many-instance-attributes, invalid-name @dataclasses.dataclass @@ -55,8 +57,6 @@ class Texts: UpdatedToday: str = "Updated today" Updated1DayAgo: str = "Updated 1 day ago" UpdatedDaysAgo: str = "Updated %s days ago" - UpdateAllSeries: str = "Update All series" - UpdateAllMovies: str = "Update All movies" Confidence0: str = "0 %: You don't trust the EPG and you'll only get an exact match and often none" Confidence100: str = ( "100 %: You completely trust the EPG and you'll always get a match even one of poor quality" @@ -76,6 +76,8 @@ class Texts: "and inject the 'all' categories and the external EPG" ) UserProxyTip: str = "Actual user proxy if it exists" + Missing: str = "%s missing" + Complete: str = "Complete" def as_dict(self) -> dict[str, str]: return dataclasses.asdict(self) diff --git a/translations/polish.json b/translations/polish.json index c972ec6e..4d995a1a 100644 --- a/translations/polish.json +++ b/translations/polish.json @@ -46,9 +46,7 @@ "ChangeLog": "Dziennik zmian dla %s", "UpdatedToday": "Zaktualizowano dzisiaj", "Updated1DayAgo": "Zaktualizowano 1 dzień temu", - "UpdatedDaysAgo": "Aktualizacja %s dni temu", - "UpdateAllSeries": "Aktualizacja wszystkich seriali", - "UpdateAllMovies": "Aktualizacja wszystkich filmów", + "UpdatedDaysAgo": "Zaktualizowano %s dni temu", "Confidence0": "0%: Nie ufasz EPG i otrzymasz tylko dokładne dopasowanie, a często żadnego.", "Confidence100": "100%: Całkowicie ufasz EPG i zawsze otrzymasz dopasowanie, nawet słabej jakości.", "EPGPrefer": "Najpierw wyszukaj dostawcę IPTV", @@ -59,5 +57,7 @@ "EPGUrlTip": "Wprowadź adres URL zewnętrznego EPG, powinien on kończyć się na \"%s\" lub \"%s\".", "LibmpvTip": "Libmpv dekoduje i renderuje audio i wideo. Włącz aktualizacje, aby uzyskać ostatnią wersję zoptymalizowaną dla twojego komputera.", "ProxyTip": "%s używa lokalnego proxy do przechwytywania wszystkich żądań do dostawcy IPTV i wstrzykiwania kategorii \"all\" i zewnętrznego EPG.", - "UserProxyTip": "Rzeczywiste proxy użytkownika, jeśli istnieje" + "UserProxyTip": "Rzeczywiste proxy użytkownika, jeśli istnieje", + "Missing": "%s brak", + "Complete": "Kompletny" } \ No newline at end of file diff --git a/translations/russian.json b/translations/russian.json index e049deb6..d90d77a0 100644 --- a/translations/russian.json +++ b/translations/russian.json @@ -47,10 +47,8 @@ "UpdatedToday": "Обновлено сегодня", "Updated1DayAgo": "Обновлено 1 день назад", "UpdatedDaysAgo": "Обновлено %s дней назад", - "UpdateAllSeries": "Обновить все серии", - "UpdateAllMovies": "Обновить все фильмы", - "Confidence0": "0 %: Вы не доверяете EPG и получаете только точные совпадения, а часто и вовсе их не получаете", - "Confidence100": "100 %: Вы полностью доверяете EPG и всегда получите совпадение, даже если оно будет плохого качества.", + "Confidence0": "0 %: Вы не доверяете EPG и получите только точное совпадение, а зачастую и ни одного.", + "Confidence100": "100 %: Вы полностью доверяете EPG и всегда получите совпадение, даже некачественное.", "EPGPrefer": "Поиск IPTV-провайдера в первую очередь", "Yes": "Да", "No": "Нет", @@ -59,5 +57,7 @@ "EPGUrlTip": "Введите URL-адрес внешнего EPG, в итоге должно получиться '%s' или '%s'.", "LibmpvTip": "Libmpv декодирует и рендерит аудио и видео. Включите обновления, чтобы получить последнюю версию, оптимизированную для вашего компьютера.", "ProxyTip": "%s использует локальный прокси для перехвата всех запросов к IPTV-провайдеру и введения категорий 'all' и внешнего EPG.", - "UserProxyTip": "Фактический пользовательский прокси, если он существует" + "UserProxyTip": "Фактический пользовательский прокси, если он существует", + "Missing": "%s отсутствует", + "Complete": "Полный" } \ No newline at end of file diff --git a/translations/serbian.json b/translations/serbian.json index 667ebbfa..980df19e 100644 --- a/translations/serbian.json +++ b/translations/serbian.json @@ -47,8 +47,6 @@ "UpdatedToday": "Ажурирано данас", "Updated1DayAgo": "Ажурирано пре 1 дан", "UpdatedDaysAgo": "Ажурирано пре %s дана", - "UpdateAllSeries": "Ажурирајте све серије", - "UpdateAllMovies": "Ажурирајте све филмове", "Confidence0": "0 %: Не верујете ЕПГ-у и добићете само потпуно подударање, а често ни једно", "Confidence100": "100 %: У потпуности верујете ЕПГ-у и увек ћете добити меч, чак и онај лошег квалитета", "EPGPrefer": "Прво потражите ИПТВ провајдера", @@ -59,5 +57,7 @@ "EPGUrlTip": "Унесите УРЛ спољног ЕПГ-а, требало би да се заврши са '%s' или '%s'", "LibmpvTip": "Либмпв декодира и приказује аудио и видео. Омогућите ажурирања да бисте последњу верзију оптимизовали за ваш рачунар.", "ProxyTip": "%s користи локални прокси да пресретне све захтеве ИПТВ провајдеру и убаци 'све' категорије и екстерни ЕПГ", - "UserProxyTip": "Стварни кориснички прокси ако постоји" + "UserProxyTip": "Стварни кориснички прокси ако постоји", + "Missing": "%s недостаје", + "Complete": "комплетан" } \ No newline at end of file diff --git a/translations/slovenian.json b/translations/slovenian.json index 2f3d50a8..3b6ccc77 100644 --- a/translations/slovenian.json +++ b/translations/slovenian.json @@ -47,9 +47,7 @@ "UpdatedToday": "Posodobljeno danes", "Updated1DayAgo": "Posodobljeno pred 1 dnem", "UpdatedDaysAgo": "Posodobljeno pred %s dnevi", - "UpdateAllSeries": "Posodobitev vseh serij", - "UpdateAllMovies": "Posodobitev vseh filmov", - "Confidence0": "0 %: Ne zaupate EPG in dobite le natančno ujemanje, pogosto pa nobenega.", + "Confidence0": "0 %: ne zaupate EPG in dobite le natančno ujemanje, pogosto pa nobenega.", "Confidence100": "100 %: popolnoma zaupate EPG in vedno dobite ujemanje, tudi če je slabe kakovosti", "EPGPrefer": "Najprej poiščite ponudnika IPTV", "Yes": "Da", @@ -59,5 +57,7 @@ "EPGUrlTip": "Vnesite URL zunanjega EPG, ki se mora končati z \"%s\" ali \"%s\".", "LibmpvTip": "Libmpv dekodira in prikazuje zvok in video. Omogočite posodobitve, da dobite zadnjo različico, optimizirano za vaš računalnik.", "ProxyTip": "%s uporablja lokalni posrednik za prestrezanje vseh zahtevkov ponudniku IPTV in vnašanje kategorij 'all' ter zunanjega EPG", - "UserProxyTip": "Dejanski uporabniški proxy, če obstaja" + "UserProxyTip": "Dejanski uporabniški proxy, če obstaja", + "Missing": "%s manjka", + "Complete": "Popolna" } \ No newline at end of file diff --git a/translations/spanish.json b/translations/spanish.json index e0748740..40241133 100644 --- a/translations/spanish.json +++ b/translations/spanish.json @@ -47,8 +47,6 @@ "UpdatedToday": "Actualizado hoy", "Updated1DayAgo": "Actualizado hace 1 día", "UpdatedDaysAgo": "Actualizado hace %s días", - "UpdateAllSeries": "Actualizar todas las series", - "UpdateAllMovies": "Actualizar todas las películas", "Confidence0": "0 %: No confías en la EPG y sólo obtendrás una coincidencia exacta y a menudo ninguna", "Confidence100": "100 %: Confías plenamente en la EPG y siempre obtendrás una coincidencia, aunque sea de mala calidad.", "EPGPrefer": "Busca primero en el proveedor de IPTV", @@ -56,8 +54,10 @@ "No": "No", "EPGPreferYes": "Sí: Busca primero en la EPG del proveedor de IPTV. Utiliza la EPG externa sólo cuando falle.", "EPGPreferNo": "No: Buscar primero en la EPG externa. Utilice la EPG del proveedor de IPTV sólo cuando falle.", - "EPGUrlTip": "Introduce la URL de la EPG externa, debe terminar con '%s' o '%s'.", + "EPGUrlTip": "Introduce la URL de la EPG externa, debería terminar con '%s' o '%s'.", "LibmpvTip": "Libmpv decodifica y renderiza audio y video. Activa las actualizaciones para obtener la última versión optimizada para tu ordenador.", "ProxyTip": "%s utiliza un proxy local para interceptar todas las peticiones al proveedor de IPTV e inyectar las categorías 'all' y la EPG externa", - "UserProxyTip": "Proxy de usuario real si existe" + "UserProxyTip": "Proxy de usuario real si existe", + "Missing": "Falta %s", + "Complete": "Completo" } \ No newline at end of file diff --git a/translations/turkish.json b/translations/turkish.json index dd4a772d..ad23dbac 100644 --- a/translations/turkish.json +++ b/translations/turkish.json @@ -47,8 +47,6 @@ "UpdatedToday": "Bugün güncellendi", "Updated1DayAgo": "1 gün önce güncellendi", "UpdatedDaysAgo": "%s gün önce güncellendi", - "UpdateAllSeries": "Tüm serileri güncelle", - "UpdateAllMovies": "Tüm filmleri güncelle", "Confidence0": "0 %: EPG'ye güvenmiyorsunuz ve yalnızca tam bir eşleşme elde edeceksiniz ve genellikle hiçbir eşleşme elde edemeyeceksiniz", "Confidence100": "100: EPG'ye tamamen güvenirsiniz ve düşük kalitede bile olsa her zaman bir eşleşme elde edersiniz", "EPGPrefer": "Önce IPTV sağlayıcısını arayın", @@ -59,5 +57,7 @@ "EPGUrlTip": "Harici EPG'nin URL'sini girin, '%s' veya '%s' ile bitmelidir", "LibmpvTip": "Libmpv ses ve videonun kodunu çözer ve işler. Bilgisayarınız için optimize edilmiş son sürümü almak için güncellemeleri etkinleştirin.", "ProxyTip": "%s, IPTV sağlayıcısına yapılan tüm istekleri kesmek ve 'tüm' kategorileri ve harici EPG'yi enjekte etmek için yerel bir proxy kullanır", - "UserProxyTip": "Varsa gerçek kullanıcı proxy'si" + "UserProxyTip": "Varsa gerçek kullanıcı proxy'si", + "Missing": "%s kayıp", + "Complete": "Tamamlandı" } \ No newline at end of file